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..c52a91498 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ target .env /test.sqlite* +/builtin-apps/build/* +!/builtin-apps/build/dist/ +/builtin-apps/build/dist/* +!/builtin-apps/build/dist/.gitkeep diff --git a/Cargo.lock b/Cargo.lock index 61bdbc102..e7762a5cc 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,6 +6344,37 @@ 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", + "tempfile", + "tokio", + "tracing", + "url", + "uuid", + "zip", +] + [[package]] name = "sage-assets" version = "0.12.11" @@ -6396,6 +6510,7 @@ dependencies = [ "sage", "sage-api", "sage-api-macro", + "sage-apps", "sage-config", "sage-rpc", "sage-wallet", @@ -6417,6 +6532,7 @@ dependencies = [ "tauri-plugin-sharesheet", "tauri-plugin-window-state", "tauri-specta", + "tempfile", "tokio", "tracing", ] @@ -6508,12 +6624,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 +6780,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 +7039,7 @@ dependencies = [ "paste", "specta-macros", "thiserror 1.0.69", + "url", ] [[package]] @@ -7553,9 +7664,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 +7682,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 +7700,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 +7932,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 +8062,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 +8131,6 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -8103,13 +8216,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 +8256,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 +8315,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 +8330,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 +8513,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 +8583,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 +8837,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 +8916,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 +8951,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 +8972,6 @@ dependencies = [ "cc", "downcast-rs", "rustix 1.1.3", - "scoped-tls", "smallvec", "wayland-sys", ] @@ -8839,8 +9030,6 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ - "dlib", - "log", "pkg-config", ] @@ -9506,6 +9695,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 +9717,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 +10013,6 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", @@ -9862,6 +10144,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 +10265,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/build/dist/.gitkeep b/builtin-apps/build/dist/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/builtin-apps/build/dist/.gitkeep @@ -0,0 +1 @@ + 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..1039bb288 --- /dev/null +++ b/builtin-apps/src/runtime/origin-cleanup/index.html @@ -0,0 +1,11 @@ + + + + + Origin Cleanup + + + + + + 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..f860ece78 --- /dev/null +++ b/builtin-apps/src/runtime/origin-cleanup/sage-manifest.json @@ -0,0 +1,22 @@ +{ + "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 @@ + + + T + 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..cdcbb716a --- /dev/null +++ b/builtin-apps/src/sandbox-test/_shared/index.html @@ -0,0 +1,12 @@ + + + + + Sage Builtin Test App + + + + + + + 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..6c562925b --- /dev/null +++ b/builtin-apps/src/sandbox-test/network-allow-a/sage-manifest.json @@ -0,0 +1,22 @@ +{ + "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..c62d04c58 --- /dev/null +++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/app.js @@ -0,0 +1,156 @@ +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(expectedValue) { + return await new Promise((resolve, reject) => { + let db = null; + + try { + const open = indexedDB.open(DB_NAME); + + open.onerror = () => + reject(open.error ?? new Error('IndexedDB open failed')); + open.onblocked = () => reject(new Error('IndexedDB open was blocked')); + + open.onupgradeneeded = () => { + try { + const upgradeDb = open.result; + if (!upgradeDb.objectStoreNames.contains(STORE_NAME)) { + upgradeDb.createObjectStore(STORE_NAME); + } + } catch (error) { + try { + open.transaction?.abort(); + } catch {} + reject(error); + } + }; + + open.onsuccess = () => { + try { + 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(); + reject(req.error ?? new Error('IndexedDB probe read failed')); + }; + + req.onsuccess = () => { + db.close(); + resolve(req.result === expectedValue); + }; + } catch (error) { + try { + db?.close(); + } catch {} + reject(error); + } + }; + } catch (error) { + try { + db?.close(); + } catch {} + reject(error); + } + }); + } + + 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 { + const value = localStorage.getItem(LOCAL_STORAGE_KEY); + localStorageVisible = value === runId; + log('localStorageVisible', localStorageVisible); + + indexedDbVisible = await readIndexedDbProbe(runId); + 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..22d121a12 --- /dev/null +++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.incognito.json @@ -0,0 +1,17 @@ +{ + "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..d70a18d50 --- /dev/null +++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.persistent.json @@ -0,0 +1,17 @@ +{ + "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..81e3e4991 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/index.html @@ -0,0 +1,12 @@ + + + + + + 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..ba27d9364 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/components/ErrorState.tsx @@ -0,0 +1,11 @@ +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..a09920f69 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/components/LoadingState.tsx @@ -0,0 +1,9 @@ +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..ba5d45908 --- /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..0be3e72f1 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/types.ts @@ -0,0 +1,19 @@ +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..81e3e4991 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/index.html @@ -0,0 +1,12 @@ + + + + + + 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..1941e1aa2 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/App.tsx @@ -0,0 +1,38 @@ +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..cc32bbb59 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx @@ -0,0 +1,206 @@ +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 [needsReload, setNeedsReload] = useState(false); + 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 reviewedManifestHash = state.app.pendingUpdate?.manifestHash; + + if (!reviewedManifestHash) { + throw new Error( + 'The pending update changed before it could be applied. Review the latest update and try again.', + ); + } + + const client = await getSageSystemClient(); + + await client.appUpdate.applyUpdate({ + appId: state.app.common.identity.id, + additionalGrantedPermissions, + reviewedManifestHash, + }); + + await close(); + } catch (err) { + setError(formatSageError(err)); + setNeedsReload(true); + } finally { + setSubmitting(false); + } + } + + if (!preview) { + return ( + } + > + + + ); + } + + if (preview.manifest.kind === 'partial') { + return ( + } + > + + + ); + } + + if (!state.app.pendingUpdate) { + return ( + } + > +
+

Review needs refreshing

+

+ The pending update changed while this review was loading. Re-check + the update to review the latest version and permissions. +

+
+
+ ); + } + + 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..af8025e46 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/hooks/useLoadState.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useRef, 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 latestLoad = useRef(0); + + const reload = useCallback(async () => { + const loadId = latestLoad.current + 1; + latestLoad.current = loadId; + 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) { + if (latestLoad.current === loadId) { + 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 (latestLoad.current !== loadId) return; + + setState({ + kind: 'ready', + mode, + app: permissionsContext.app, + permissionsContext, + updateContext: null, + definitions, + wallets: walletsResult.wallets, + }); + return; + } + + const updateContext = await sage.appUpdate.getReviewContext({ appId }); + + if (latestLoad.current !== loadId) return; + + setState({ + kind: 'ready', + mode: 'review-update', + app: updateContext.app, + updateContext, + permissionsContext: null, + definitions, + wallets: walletsResult.wallets, + }); + } catch (err) { + if (latestLoad.current === loadId) { + setState({ kind: 'error', error: formatSageError(err) }); + } + } + }, [sage]); + + useEffect(() => { + void reload(); + + return () => { + latestLoad.current += 1; + }; + }, [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..ba5d45908 --- /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..bf71a152d --- /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..f23e10df3 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/SendXchApprovalCard.tsx @@ -0,0 +1,98 @@ +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 ( +
+
+
+ +
+ +
+
+
Send XCH
+ Wallet +
+ +
+ {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..ba5d45908 --- /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..8dda8285f --- /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..ba5d45908 --- /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..4fa772c4e --- /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..ba5d45908 --- /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..326a6058c --- /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 ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( +
+
+
+
+
+ Built-in system app +
+

+ Task Manager +

+
+ {runtimes.length} runtimes · updated {formatTime(now)} +
+
+ + + {loading ? 'Refreshing…' : 'Refresh'} + +
+ +
+ + + !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..44d34a339 --- /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..877c8f5d0 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/src/taskManagerApi.ts @@ -0,0 +1,45 @@ +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..d084526e9 --- /dev/null +++ b/crates/sage-apps/Cargo.toml @@ -0,0 +1,37 @@ +[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"] } +specta-typescript = { workspace = true } +tauri = { workspace = true, features = ["macos-private-api"] } +tauri-plugin-dialog = "2.7.1" +tokio = { workspace = true, features = ["net"] } +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/bin/generate_docs.rs b/crates/sage-apps/src/bin/generate_docs.rs new file mode 100644 index 000000000..081c52fda --- /dev/null +++ b/crates/sage-apps/src/bin/generate_docs.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(err) = sage_apps::generate_docs() { + 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..8422a0fc4 --- /dev/null +++ b/crates/sage-apps/src/bridge/bridge_request.rs @@ -0,0 +1,440 @@ +use tauri::{AppHandle, Manager, State, Webview}; + +use crate::{ + AppState, AppsHostState, BridgeApprovalsChangedEvent, BridgeCapability, BridgeContext, + BridgeMethod, BridgeMethodCapability, BridgeOrigin, BridgeRegistry, BridgeRegistryKind, + BridgeTools, PendingBridgeApproval, ResolveBridgeApprovalArgs, RustBridgeApprovalBody, + 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_system_capability_definition, + get_user_capability_definition, list_pending_approvals, resolve_app, + start_bridge_approval_runtime, sync_bridge_approval_runtime, take_pending_approval, + unix_timestamp_ms, 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 = take_pending_approval(apps_state, &args.approval_id) + .await + .ok_or_else(|| format!("No pending approval with id {}", args.approval_id))?; + + 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 { + RustBridgeInvokeResult::error( + &pending.request.id, + "user_denied", + args.reason + .unwrap_or_else(|| "User denied the request".to_string()), + ) + } else if unix_timestamp_ms() as u64 > pending.expires_at_ms { + RustBridgeInvokeResult::error( + &pending.request.id, + "approval_timeout", + "Approval expired before it was resolved".to_string(), + ) + } else if wallet_binding_violated(app_state, &pending).await { + RustBridgeInvokeResult::error( + &pending.request.id, + "wallet_changed", + "Active wallet changed since the approval was requested".to_string(), + ) + } else { + process_shared( + app_handle, + app_state, + &origin, + pending.registry_kind, + &pending.request, + true, + ) + .await? + }; + + 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_state, + 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 active_wallet_fingerprint(app_state: &State<'_, AppState>) -> Option { + app_state + .lock() + .await + .wallet() + .map(|wallet| wallet.fingerprint) + .ok() +} + +async fn wallet_binding_violated( + app_state: &State<'_, AppState>, + pending: &PendingBridgeApproval, +) -> bool { + let requires_wallet_binding = matches!( + pending.approval.body, + RustBridgeApprovalBody::SendXch { .. } + ); + + if !requires_wallet_binding { + return false; + } + + let Some(approved_fingerprint) = pending.approved_fingerprint else { + return true; + }; + + active_wallet_fingerprint(app_state).await != Some(approved_fingerprint) +} + +async fn request_approval( + app_handle: &AppHandle, + app_state: &State<'_, AppState>, + app_id: String, + registry_kind: BridgeRegistryKind, + approval: RustBridgeApprovalRequest, + request: &RustBridgeRequest, +) -> Result<(), String> { + let apps_state = app_handle.state::(); + let approved_fingerprint = active_wallet_fingerprint(app_state).await; + + write_pending_approval( + &apps_state, + app_id.clone(), + registry_kind, + &approval, + request, + approved_fingerprint, + ) + .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..4f5bebd9d --- /dev/null +++ b/crates/sage-apps/src/bridge/event_emit.rs @@ -0,0 +1,271 @@ +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> { + let webview_label = app.webview_label(); + get_webview_in_sage_window(app_handle, &webview_label)? + .emit_to(webview_label.as_str(), "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_to( + webview_label.as_str(), + 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, BridgeMethodHandleError>; + +impl BridgeMethodHandleError { + pub(super) fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub(super) fn invalid_request(message: impl Into) -> Self { + Self::new("invalid_request", message) + } + + pub(super) fn internal_error(message: impl Into) -> Self { + Self::new("internal_error", message) + } +} + +pub(crate) type BridgeHandleResult = + Result, BridgeMethodHandleError>; + +impl BridgeMethodCapability { + pub(super) fn ungated() -> Self { + Self::Ungated + } + + pub(super) fn user(cap: UserBridgeCapability) -> Self { + Self::Required(BridgeCapability::User(cap)) + } + + pub(super) fn system(cap: SystemBridgeCapability) -> Self { + Self::Required(BridgeCapability::System(cap)) + } +} + +pub(crate) fn parse_required_params( + method: &impl BridgeMethod, + request: &RustBridgeRequest, +) -> Result +where + T: DeserializeOwned, +{ + let Some(params_json) = request.params_json.as_deref() else { + return Err(BridgeMethodHandleError::new( + "invalid_request", + format!("{} requires params", method.name()), + )); + }; + + serde_json::from_str(params_json).map_err(|err| { + BridgeMethodHandleError::new( + "invalid_request", + format!("Failed to decode {} params: {err}", method.name()), + ) + }) +} diff --git a/crates/sage-apps/src/bridge/methods/system.rs b/crates/sage-apps/src/bridge/methods/system.rs new file mode 100644 index 000000000..da5eac225 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system.rs @@ -0,0 +1,23 @@ +mod app_install; +mod app_permissions; +mod app_registry; +mod app_update; +mod bridge_approvals; +mod capabilities; +mod donation; +mod file_system; +mod runtime_manager; +mod sandbox; +mod wallet; + +pub(crate) use app_install::*; +pub(crate) use app_permissions::*; +pub(crate) use app_registry::*; +pub(crate) use app_update::*; +pub(crate) use bridge_approvals::*; +pub(crate) use capabilities::*; +pub(crate) use donation::*; +pub(crate) use file_system::*; +pub(crate) use runtime_manager::*; +pub(crate) use sandbox::*; +pub(crate) use wallet::*; diff --git a/crates/sage-apps/src/bridge/methods/system/app_install.rs b/crates/sage-apps/src/bridge/methods/system/app_install.rs new file mode 100644 index 000000000..d4be30a61 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_install.rs @@ -0,0 +1,26 @@ +mod install_url; +mod install_zip; +mod preview_url; +mod preview_zip; + +pub(crate) use install_url::*; +pub(crate) use install_zip::*; +pub(crate) use preview_url::*; +pub(crate) use preview_zip::*; + +use serde::Serialize; +use specta::Type; + +use crate::UserSageAppView; + +#[derive(Debug, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AppInstallInstallResult { + app: UserSageAppView, +} + +impl AppInstallInstallResult { + pub fn new(app: UserSageAppView) -> Self { + Self { app } + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_install/install_url.rs b/crates/sage-apps/src/bridge/methods/system/app_install/install_url.rs new file mode 100644 index 000000000..3b2d04da5 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_install/install_url.rs @@ -0,0 +1,95 @@ +use std::io; + +use async_trait::async_trait; +use serde::Deserialize; +use specta::Type; +use tauri::{AppHandle, Manager, State}; + +use crate::{ + AppInstallInstallResult, AppState, AppsHostState, BridgeApprovalRequestResult, BridgeContext, + BridgeHandleResult, BridgeMethod, BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, + Result, RustBridgeRequest, SageAppUrl, SageAppWalletScope, SageGrantedPermissionsInput, + SystemBridgeCapability, UserSageAppView, install_app_from_source, parse_required_params, +}; + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppInstallInstallUrlParams { + app_url: String, + granted_permissions: SageGrantedPermissionsInput, + wallet_scope: SageAppWalletScope, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppInstallInstallUrl; + +#[async_trait] +impl BridgeMethod for AppInstallInstallUrl { + fn name(&self) -> &'static str { + "appInstall.installUrl" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppInstallApply) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppInstallInstallUrlParams = parse_required_params(self, request)?; + let state = tools.app_handle.state::(); + + let app = install_app_url( + tools.app_handle.clone(), + state, + tools.host_state.clone(), + params.app_url, + params.granted_permissions, + params.wallet_scope, + ) + .await + .map_err(|err| BridgeMethodHandleError::internal_error(err.to_string()))?; + + Ok(Box::new(AppInstallInstallResult::new(app))) + } +} + +pub async fn install_app_url( + app: AppHandle, + state: State<'_, AppState>, + host_state: State<'_, AppsHostState>, + app_url: String, + granted_permissions_input: SageGrantedPermissionsInput, + wallet_scope: SageAppWalletScope, +) -> Result { + let base_path = { + let state = state.lock().await; + state.path.clone() + }; + let parsed_app_url = SageAppUrl::parse(&app_url) + .map_err(|err| io::Error::other(format!("invalid app URL {app_url}: {err}")))?; + let result = install_app_from_source( + &app, + &host_state, + &base_path, + granted_permissions_input, + wallet_scope, + parsed_app_url, + ) + .await; + + result.map(|app| (&app).into()).map_err(|err| { + io::Error::other(format!("failed to install app URL {app_url}: {err}")).into() + }) +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_install/install_zip.rs b/crates/sage-apps/src/bridge/methods/system/app_install/install_zip.rs new file mode 100644 index 000000000..ae8b64def --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_install/install_zip.rs @@ -0,0 +1,111 @@ +use std::{fs, io}; + +use async_trait::async_trait; +use serde::Deserialize; +use specta::Type; +use tauri::{AppHandle, Manager, State}; + +use crate::{ + AppInstallInstallResult, AppState, AppsHostState, BridgeApprovalRequestResult, BridgeContext, + BridgeHandleResult, BridgeMethod, BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, + Result, RustBridgeRequest, SageAppWalletScope, SageGrantedPermissionsInput, + SystemBridgeCapability, UserSageAppView, ZipInstallSource, apps_root, install_app_from_source, + parse_required_params, +}; + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppInstallInstallZipParams { + zip_path: String, + granted_permissions: SageGrantedPermissionsInput, + wallet_scope: SageAppWalletScope, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppInstallInstallZip; + +#[async_trait] +impl BridgeMethod for AppInstallInstallZip { + fn name(&self) -> &'static str { + "appInstall.installZip" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppInstallApply) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppInstallInstallZipParams = parse_required_params(self, request)?; + let state = tools.app_handle.state::(); + + let app = install_app_zip( + tools.app_handle.clone(), + state, + tools.host_state.clone(), + params.zip_path, + params.granted_permissions, + params.wallet_scope, + ) + .await + .map_err(|err| BridgeMethodHandleError::internal_error(err.to_string()))?; + + Ok(Box::new(AppInstallInstallResult::new(app))) + } +} + +pub async fn install_app_zip( + app: AppHandle, + state: State<'_, AppState>, + host_state: State<'_, AppsHostState>, + zip_path: String, + granted_permissions_input: SageGrantedPermissionsInput, + wallet_scope: SageAppWalletScope, +) -> Result { + let base_path = { + let state = state.lock().await; + state.path.clone() + }; + + let root = apps_root(&base_path); + + fs::create_dir_all(&root).map_err(|err| { + io::Error::other(format!( + "failed to create apps directory {}: {err}", + root.display() + )) + })?; + + let source = ZipInstallSource::new(&root, zip_path.clone()); + let unpack_dir = source.unpack_dir.clone(); + + let result = install_app_from_source( + &app, + &host_state, + &base_path, + granted_permissions_input, + wallet_scope, + source, + ) + .await; + + if unpack_dir.exists() { + let _ = fs::remove_dir_all(&unpack_dir); + } + + result.map(|app| (&app).into()).map_err(|err| { + io::Error::other(format!("failed to install app zip {zip_path}: {err}")).into() + }) +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_install/preview_url.rs b/crates/sage-apps/src/bridge/methods/system/app_install/preview_url.rs new file mode 100644 index 000000000..fe9efae06 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_install/preview_url.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +use serde::Deserialize; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, SageAppUrl, + SageAppUrlPreview, SystemBridgeCapability, fetch_url_manifest_preview, parse_required_params, +}; + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppInstallPreviewUrlParams { + app_url: String, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppInstallPreviewUrl; + +#[async_trait] +impl BridgeMethod for AppInstallPreviewUrl { + fn name(&self) -> &'static str { + "appInstall.previewUrl" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppInstallPreview) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + _tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppInstallPreviewUrlParams = parse_required_params(self, request)?; + + let preview = fetch_preview(params.app_url) + .await + .map_err(BridgeMethodHandleError::internal_error)?; + + Ok(Box::new(preview)) + } +} + +async fn fetch_preview(app_url: String) -> Result { + let app_url = + SageAppUrl::parse(&app_url).map_err(|err| format!("invalid app URL {app_url}: {err}"))?; + + let (manifest, manifest_hash) = fetch_url_manifest_preview(&app_url.manifest_url()) + .await + .map_err(|err| format!("failed to fetch app manifest: {err}"))?; + + SageAppUrlPreview::new(&app_url, manifest, manifest_hash) + .await + .map_err(|err| format!("failed to preview app URL: {err}")) +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_install/preview_zip.rs b/crates/sage-apps/src/bridge/methods/system/app_install/preview_zip.rs new file mode 100644 index 000000000..0a606b629 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_install/preview_zip.rs @@ -0,0 +1,72 @@ +use std::fs; +use std::path::Path; + +use async_trait::async_trait; +use serde::Deserialize; +use specta::Type; +use uuid::Uuid; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + SageAppPackageManifest, SystemBridgeCapability, detect_package_root, parse_required_params, + read_manifest, unzip_to_dir, +}; + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppInstallPreviewZipParams { + zip_path: String, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppInstallPreviewZip; + +#[async_trait] +impl BridgeMethod for AppInstallPreviewZip { + fn name(&self) -> &'static str { + "appInstall.previewZip" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppInstallPreview) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + _tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppInstallPreviewZipParams = parse_required_params(self, request)?; + + let manifest = + preview_manifest(¶ms.zip_path).map_err(BridgeMethodHandleError::internal_error)?; + + Ok(Box::new(manifest)) + } +} + +fn preview_manifest(zip_path: &String) -> Result { + let unpack_dir = std::env::temp_dir().join(format!(".sage-preview-{}", Uuid::new_v4())); + + let result = (|| -> anyhow::Result { + unzip_to_dir(Path::new(&zip_path), &unpack_dir)?; + let package_root = detect_package_root(&unpack_dir)?; + let manifest = read_manifest(&package_root)?; + + Ok(manifest) + })(); + + let _ = fs::remove_dir_all(&unpack_dir); + + result.map_err(|err| format!("failed to preview app zip {zip_path}: {err}")) +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_permissions.rs b/crates/sage-apps/src/bridge/methods/system/app_permissions.rs new file mode 100644 index 000000000..85470b722 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_permissions.rs @@ -0,0 +1,5 @@ +mod apply_permissions; +mod get_review_context; + +pub(crate) use apply_permissions::*; +pub(crate) use get_review_context::*; diff --git a/crates/sage-apps/src/bridge/methods/system/app_permissions/apply_permissions.rs b/crates/sage-apps/src/bridge/methods/system/app_permissions/apply_permissions.rs new file mode 100644 index 000000000..ad0aa5b70 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_permissions/apply_permissions.rs @@ -0,0 +1,116 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, SageApp, + SageAppWalletScope, SageGrantedPermissionsInput, SystemBridgeCapability, UserSageAppView, + parse_required_params, resolve_app, update_app_permissions_for_app, + update_app_wallet_scope_for_app, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppPermissionsApplyPermissionsParams { + pub app_id: String, + pub granted_permissions: SageGrantedPermissionsInput, + pub wallet_scope: SageAppWalletScope, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppPermissionsApplyPermissionsResult { + pub app: UserSageAppView, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppPermissionsApplyPermissions; + +#[async_trait] +impl BridgeMethod for AppPermissionsApplyPermissions { + fn name(&self) -> &'static str { + "appPermissions.applyPermissions" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppPermissionsApply) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppPermissionsApplyPermissionsParams = parse_required_params(self, request)?; + + let resolved = resolve_app(tools.app_handle, ¶ms.app_id) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to resolve app {}: {err}", + params.app_id + )) + })?; + + let requested = + resolved.with_app(|app| app.with(|sage_app| sage_app.requested_permissions().clone())); + + let granted_permissions = + params + .granted_permissions + .resolve(&requested) + .map_err(|err| { + BridgeMethodHandleError::invalid_request(format!( + "invalid granted permissions: {err}" + )) + })?; + + let app = resolved.clone_app_for_operation(); + + update_app_permissions_for_app( + tools.app_handle, + tools.host_state, + &app, + &granted_permissions, + ) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to update app permissions: {err}" + )) + })?; + update_app_wallet_scope_for_app(tools.app_handle, &app, params.wallet_scope) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to update app wallet scope: {err}" + )) + })?; + + let app_view = app + .with(|sage_app| match sage_app { + SageApp::User(user_app) => Some(UserSageAppView::from(user_app)), + SageApp::System(_) => None, + }) + .ok_or_else(|| { + BridgeMethodHandleError::invalid_request(format!( + "app {} is not a user app", + params.app_id + )) + })?; + + Ok(Box::new(AppPermissionsApplyPermissionsResult { + app: app_view, + })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_permissions/get_review_context.rs b/crates/sage-apps/src/bridge/methods/system/app_permissions/get_review_context.rs new file mode 100644 index 000000000..7ba712bce --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_permissions/get_review_context.rs @@ -0,0 +1,77 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, SageApp, + SystemBridgeCapability, UserSageAppView, parse_required_params, resolve_app, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppPermissionsGetReviewContextParams { + pub app_id: String, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppPermissionsReviewContext { + pub app: UserSageAppView, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppPermissionsGetReviewContext; + +#[async_trait] +impl BridgeMethod for AppPermissionsGetReviewContext { + fn name(&self) -> &'static str { + "appPermissions.getReviewContext" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppPermissionsRead) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppPermissionsGetReviewContextParams = parse_required_params(self, request)?; + + let resolved = resolve_app(tools.app_handle, ¶ms.app_id) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to resolve app {}: {err}", + params.app_id + )) + })?; + + let app = resolved + .with_app(|app| { + app.with(|sage_app| match sage_app { + SageApp::User(user_app) => Some(UserSageAppView::from(user_app)), + SageApp::System(_) => None, + }) + }) + .ok_or_else(|| { + BridgeMethodHandleError::invalid_request(format!( + "app {} is not a user app", + params.app_id + )) + })?; + + Ok(Box::new(AppPermissionsReviewContext { app })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_registry.rs b/crates/sage-apps/src/bridge/methods/system/app_registry.rs new file mode 100644 index 000000000..40ef58ccc --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_registry.rs @@ -0,0 +1,3 @@ +mod events; + +pub(crate) use events::*; diff --git a/crates/sage-apps/src/bridge/methods/system/app_registry/events.rs b/crates/sage-apps/src/bridge/methods/system/app_registry/events.rs new file mode 100644 index 000000000..0cd80fad6 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_registry/events.rs @@ -0,0 +1,37 @@ +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, State}; + +use crate::{ + AppsHostState, ListedSageAppView, SystemBridgeCapability, SystemRuntimeEvent, + emit_system_runtime_event_to_listeners, list_installed_apps_internal, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ListedAppsChangedEvent { + pub apps: Vec, +} + +impl SystemRuntimeEvent for ListedAppsChangedEvent { + const TYPE: &'static str = "appRegistry.listedAppsChanged"; + const REQUIRED_CAPABILITY: SystemBridgeCapability = + SystemBridgeCapability::AppRegistryListenListedAppsChanged; +} + +pub(crate) async fn emit_listed_apps_changed( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + let Ok(apps) = list_installed_apps_internal(&apps_state.db).await else { + return; + }; + + let apps = apps + .iter() + .map(Into::into) + .collect::>(); + + emit_system_runtime_event_to_listeners(app_handle, apps_state, ListedAppsChangedEvent { apps }) + .await; +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_update.rs b/crates/sage-apps/src/bridge/methods/system/app_update.rs new file mode 100644 index 000000000..14b242f00 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_update.rs @@ -0,0 +1,7 @@ +mod apply_update; +mod events; +mod get_review_context; + +pub(crate) use apply_update::*; +pub(crate) use events::*; +pub(crate) use get_review_context::*; diff --git a/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs b/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs new file mode 100644 index 000000000..f00eafe08 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs @@ -0,0 +1,74 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, SageAppView, + SageGrantedPermissionsInput, SystemBridgeCapability, apply_app_update_inner, + parse_required_params, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppUpdateApplyUpdateParams { + pub app_id: String, + pub additional_granted_permissions: SageGrantedPermissionsInput, + /// Manifest hash of the pending update the user reviewed. Apply is rejected + /// if the pending update no longer matches this hash. + pub reviewed_manifest_hash: String, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppUpdateApplyUpdateResult { + pub app: SageAppView, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppUpdateApplyUpdate; + +#[async_trait] +impl BridgeMethod for AppUpdateApplyUpdate { + fn name(&self) -> &'static str { + "appUpdate.applyUpdate" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppUpdateApply) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppUpdateApplyUpdateParams = parse_required_params(self, request)?; + + let app = apply_app_update_inner( + tools.app_handle, + tools.host_state, + ¶ms.app_id, + Some(params.additional_granted_permissions), + Some(¶ms.reviewed_manifest_hash), + ) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to apply update for {}: {err}", + params.app_id + )) + })?; + + Ok(Box::new(AppUpdateApplyUpdateResult { app })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_update/events.rs b/crates/sage-apps/src/bridge/methods/system/app_update/events.rs new file mode 100644 index 000000000..829b58c6b --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_update/events.rs @@ -0,0 +1,58 @@ +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, State}; + +use crate::{ + AppsHostState, SageApp, SharedSageApp, SystemBridgeCapability, SystemRuntimeEvent, + emit_system_runtime_event_to_listeners, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub(crate) enum PendingUpdateStatusView { + None, + ReadyToApply, + RequiresReview, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingUpdateChangedEvent { + pub app_id: String, + pub status: PendingUpdateStatusView, +} + +impl SystemRuntimeEvent for PendingUpdateChangedEvent { + const TYPE: &'static str = "appUpdate.pendingUpdateChanged"; + const REQUIRED_CAPABILITY: SystemBridgeCapability = SystemBridgeCapability::AppUpdateRead; +} + +pub(crate) async fn emit_pending_update_changed( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + shared_sage_app: &SharedSageApp, +) { + let Some((app_id, status)) = shared_sage_app.with(|sage_app| match sage_app { + SageApp::User(user_app) => { + let status = if user_app.pending_update().is_none() { + PendingUpdateStatusView::None + } else if shared_sage_app.should_review_pending_update() { + PendingUpdateStatusView::RequiresReview + } else { + PendingUpdateStatusView::ReadyToApply + }; + + Some((user_app.common().id().to_string(), status)) + } + SageApp::System(_) => None, + }) else { + return; + }; + + emit_system_runtime_event_to_listeners( + app_handle, + apps_state, + PendingUpdateChangedEvent { app_id, status }, + ) + .await; +} diff --git a/crates/sage-apps/src/bridge/methods/system/app_update/get_review_context.rs b/crates/sage-apps/src/bridge/methods/system/app_update/get_review_context.rs new file mode 100644 index 000000000..b758249da --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_update/get_review_context.rs @@ -0,0 +1,90 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, SageApp, + SageAppUrlPreview, SystemBridgeCapability, UserSageAppView, check_app_update_inner, + parse_required_params, resolve_app, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppUpdateGetReviewContextParams { + pub app_id: String, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppUpdateReviewContext { + pub app: UserSageAppView, + + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct AppUpdateGetReviewContext; + +#[async_trait] +impl BridgeMethod for AppUpdateGetReviewContext { + fn name(&self) -> &'static str { + "appUpdate.getReviewContext" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::AppUpdateRead) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: AppUpdateGetReviewContextParams = parse_required_params(self, request)?; + + let preview = check_app_update_inner(tools.app_handle, tools.host_state, ¶ms.app_id) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to check update for {}: {err}", + params.app_id + )) + })?; + + let resolved = resolve_app(tools.app_handle, ¶ms.app_id) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to resolve app {}: {err}", + params.app_id + )) + })?; + + let app = resolved + .with_app(|app| { + app.with(|sage_app| match sage_app { + SageApp::User(user_app) => Some(UserSageAppView::from(user_app)), + SageApp::System(_) => None, + }) + }) + .ok_or_else(|| { + BridgeMethodHandleError::invalid_request(format!( + "app {} is not a user app", + params.app_id + )) + })?; + + Ok(Box::new(AppUpdateReviewContext { app, preview })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/bridge_approvals.rs b/crates/sage-apps/src/bridge/methods/system/bridge_approvals.rs new file mode 100644 index 000000000..c5391eb70 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/bridge_approvals.rs @@ -0,0 +1,7 @@ +mod events; +mod list; +mod resolve; + +pub(crate) use events::*; +pub(crate) use list::*; +pub(crate) use resolve::*; diff --git a/crates/sage-apps/src/bridge/methods/system/bridge_approvals/events.rs b/crates/sage-apps/src/bridge/methods/system/bridge_approvals/events.rs new file mode 100644 index 000000000..84bf66a81 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/bridge_approvals/events.rs @@ -0,0 +1,26 @@ +use serde::Serialize; +use specta::Type; + +use crate::{ + PendingBridgeApproval, PendingBridgeApprovalView, SystemBridgeCapability, SystemRuntimeEvent, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BridgeApprovalsChangedEvent { + approvals: Vec, +} + +impl BridgeApprovalsChangedEvent { + pub(crate) fn new_from_list(approvals: Vec) -> Self { + Self { + approvals: approvals.into_iter().map(Into::into).collect(), + } + } +} + +impl SystemRuntimeEvent for BridgeApprovalsChangedEvent { + const TYPE: &'static str = "bridgeApproval.changed"; + const REQUIRED_CAPABILITY: SystemBridgeCapability = + SystemBridgeCapability::BridgeApprovalListenApprovalsChanged; +} diff --git a/crates/sage-apps/src/bridge/methods/system/bridge_approvals/list.rs b/crates/sage-apps/src/bridge/methods/system/bridge_approvals/list.rs new file mode 100644 index 000000000..03c7a6ab0 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/bridge_approvals/list.rs @@ -0,0 +1,67 @@ +use async_trait::async_trait; +use serde::Serialize; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, PendingBridgeApproval, RustBridgeApprovalRequest, + RustBridgeRequest, SystemBridgeCapability, list_pending_approvals, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct BridgeApprovalsListPending; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingBridgeApprovalView { + pub approval_id: String, + pub app_id: String, + pub approval: RustBridgeApprovalRequest, + pub created_at_ms: u64, + pub expires_at_ms: u64, +} + +#[async_trait] +impl BridgeMethod for BridgeApprovalsListPending { + fn name(&self) -> &'static str { + "bridgeApprovals.listPending" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::BridgeApprovalList) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let approvals = list_pending_approvals(tools.host_state) + .await + .into_iter() + .map(PendingBridgeApprovalView::from) + .collect::>(); + Ok(Box::new(approvals)) + } +} + +impl From for PendingBridgeApprovalView { + fn from(approval: PendingBridgeApproval) -> Self { + Self { + approval_id: approval.approval_id, + app_id: approval.app_id, + approval: approval.approval, + created_at_ms: approval.created_at_ms, + expires_at_ms: approval.expires_at_ms, + } + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/bridge_approvals/resolve.rs b/crates/sage-apps/src/bridge/methods/system/bridge_approvals/resolve.rs new file mode 100644 index 000000000..b4fe8e491 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/bridge_approvals/resolve.rs @@ -0,0 +1,44 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, ResolveBridgeApprovalArgs, + RustBridgeRequest, SystemBridgeCapability, parse_required_params, process_after_approval, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct BridgeApprovalsResolve; + +#[async_trait] +impl BridgeMethod for BridgeApprovalsResolve { + fn name(&self) -> &'static str { + "bridgeApprovals.resolve" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::BridgeApprovalResolve) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: ResolveBridgeApprovalArgs = parse_required_params(self, request)?; + + process_after_approval(tools.app_handle, tools.app_state, tools.host_state, params) + .await + .map_err(BridgeMethodHandleError::internal_error)?; + + Ok(Box::new(())) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/capabilities.rs b/crates/sage-apps/src/bridge/methods/system/capabilities.rs new file mode 100644 index 000000000..c3a5f34fb --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/capabilities.rs @@ -0,0 +1,3 @@ +mod list_user_definitions; + +pub(crate) use list_user_definitions::*; diff --git a/crates/sage-apps/src/bridge/methods/system/capabilities/list_user_definitions.rs b/crates/sage-apps/src/bridge/methods/system/capabilities/list_user_definitions.rs new file mode 100644 index 000000000..be5275114 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/capabilities/list_user_definitions.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RustBridgeRequest, SageAppCapabilityDefinitionView, + SystemBridgeCapability, user_registry, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct CapabilitiesListUserDefinitions; + +#[async_trait] +impl BridgeMethod for CapabilitiesListUserDefinitions { + fn name(&self) -> &'static str { + "capabilities.listUserDefinitions" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::CapabilityDefinitionsRead) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + _tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let definitions = user_registry() + .into_values() + .map(Into::into) + .collect::>(); + + Ok(Box::new(definitions)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/donation.rs b/crates/sage-apps/src/bridge/methods/system/donation.rs new file mode 100644 index 000000000..74551da52 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/donation.rs @@ -0,0 +1,3 @@ +mod get_details; + +pub(crate) use get_details::*; diff --git a/crates/sage-apps/src/bridge/methods/system/donation/get_details.rs b/crates/sage-apps/src/bridge/methods/system/donation/get_details.rs new file mode 100644 index 000000000..983b6d879 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/donation/get_details.rs @@ -0,0 +1,87 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + SageAppIconView, SystemBridgeCapability, parse_required_params, resolve_app, +}; + +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DonationGetDetailsParams { + pub app_id: String, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DonationDetails { + pub app_id: String, + pub app_name: String, + pub app_icon: Option, + pub author_name: Option, + pub author_avatar: Option, + pub donation_address: String, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct DonationGetDetails; + +#[async_trait] +impl BridgeMethod for DonationGetDetails { + fn name(&self) -> &'static str { + "donations.getDetails" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::DonationGetDetails) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: DonationGetDetailsParams = parse_required_params(self, request)?; + + let resolved = resolve_app(tools.app_handle, ¶ms.app_id) + .await + .map_err(|err| BridgeMethodHandleError::invalid_request(err.to_string()))?; + + let details = resolved + .with_app(|app| { + app.with(|app| { + let common = app.common(); + let manifest = common.active_snapshot().manifest(); + + let donation = manifest + .donation() + .ok_or_else(|| "App does not have a donation address".to_string())?; + + let author = manifest.author(); + + Ok::(DonationDetails { + app_id: app.id().to_string(), + app_name: manifest.name().to_string(), + app_icon: SageAppIconView::from_common(common), + author_name: author.map(|author| author.name().to_string()), + author_avatar: SageAppIconView::author_avatar_from_common(common), + donation_address: donation.address().to_string(), + }) + }) + }) + .map_err(BridgeMethodHandleError::invalid_request)?; + + Ok(Box::new(details)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/file_system.rs b/crates/sage-apps/src/bridge/methods/system/file_system.rs new file mode 100644 index 000000000..fd5c17bb5 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/file_system.rs @@ -0,0 +1,3 @@ +mod select_file; + +pub(crate) use select_file::*; diff --git a/crates/sage-apps/src/bridge/methods/system/file_system/select_file.rs b/crates/sage-apps/src/bridge/methods/system/file_system/select_file.rs new file mode 100644 index 000000000..456f9b540 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/file_system/select_file.rs @@ -0,0 +1,84 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri_plugin_dialog::DialogExt; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RustBridgeRequest, SystemBridgeCapability, + parse_required_params, +}; + +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileSystemSelectFileFilter { + name: String, + extensions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileSystemSelectFileParams { + #[serde(default)] + title: Option, + + #[serde(default)] + filters: Vec, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileSystemSelectFileResult { + path: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct FileSystemSelectFile; + +#[async_trait] +impl BridgeMethod for FileSystemSelectFile { + fn name(&self) -> &'static str { + "fileSystem.selectFile" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::FileSystemSelectFile) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: FileSystemSelectFileParams = parse_required_params(self, request)?; + + let mut dialog = tools.app_handle.dialog().file(); + + if let Some(title) = params.title { + dialog = dialog.set_title(title); + } + + for filter in params.filters { + let extensions = filter + .extensions + .iter() + .map(String::as_str) + .collect::>(); + + dialog = dialog.add_filter(filter.name, &extensions); + } + + let selected = dialog.blocking_pick_file().map(|path| path.to_string()); + + Ok(Box::new(FileSystemSelectFileResult { path: selected })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager.rs new file mode 100644 index 000000000..27415e00d --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager.rs @@ -0,0 +1,17 @@ +mod close_self; +mod events; +mod focus_runtime; +mod get_active_taskbar_runtime; +mod hide_runtime; +mod hide_self; +mod kill_runtime; +mod list_runtimes; + +pub(crate) use close_self::*; +pub(crate) use events::*; +pub(crate) use focus_runtime::*; +pub(crate) use get_active_taskbar_runtime::*; +pub(crate) use hide_runtime::*; +pub(crate) use hide_self::*; +pub(crate) use kill_runtime::*; +pub(crate) use list_runtimes::*; diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/close_self.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/close_self.rs new file mode 100644 index 000000000..790ead5e9 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/close_self.rs @@ -0,0 +1,45 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RustBridgeRequest, SystemBridgeCapability, + SystemKillRuntimeError, kill_runtime, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimeManagerCloseSelf; + +#[async_trait] +impl BridgeMethod for RuntimeManagerCloseSelf { + fn name(&self) -> &'static str { + "runtimeManager.closeSelf" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::RuntimeManagerCloseSelf) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let app_id = ctx.app.id(); + + match kill_runtime(tools.app_handle, tools.host_state, &app_id, "self_close").await { + Ok(()) + | Err(SystemKillRuntimeError::NotFound | SystemKillRuntimeError::RuntimeSync(_)) => { + Ok(Box::new(())) + } + } + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/events.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/events.rs new file mode 100644 index 000000000..d71ec3bc0 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/events.rs @@ -0,0 +1,36 @@ +use serde::Serialize; +use specta::Type; + +use crate::{SageAppRuntimeRecordView, SystemBridgeCapability, SystemRuntimeEvent}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeManagerRuntimesChangedEvent { + pub runtimes: Vec, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeManagerActiveTaskbarRuntimeChangedEvent { + pub host_window_label: String, + pub app_id: Option, + pub runtime_id: Option, +} + +impl RuntimeManagerRuntimesChangedEvent { + pub(crate) fn new(runtimes: Vec) -> Self { + Self { runtimes } + } +} + +impl SystemRuntimeEvent for RuntimeManagerRuntimesChangedEvent { + const TYPE: &'static str = "runtimeManager.runtimesChanged"; + const REQUIRED_CAPABILITY: SystemBridgeCapability = + SystemBridgeCapability::RuntimeManagerListenRuntimesChanged; +} + +impl SystemRuntimeEvent for RuntimeManagerActiveTaskbarRuntimeChangedEvent { + const TYPE: &'static str = "runtimeManager.activeTaskbarRuntimeChanged"; + const REQUIRED_CAPABILITY: SystemBridgeCapability = + SystemBridgeCapability::RuntimeManagerListenActiveTaskbarRuntimeChanged; +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/focus_runtime.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/focus_runtime.rs new file mode 100644 index 000000000..694d451a9 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/focus_runtime.rs @@ -0,0 +1,47 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RuntimeTargetParams, + RustBridgeRequest, SageAppRuntimeRecordView, SystemBridgeCapability, focus_taskbar_runtime, + parse_required_params, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimeManagerFocusTaskbarRuntime; + +#[async_trait] +impl BridgeMethod for RuntimeManagerFocusTaskbarRuntime { + fn name(&self) -> &'static str { + "runtimeManager.focusTaskbarRuntime" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::RuntimeManagerFocusTaskbarRuntime) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: RuntimeTargetParams = parse_required_params(self, request)?; + + let runtime = focus_taskbar_runtime(tools.app_handle, tools.host_state, ¶ms.app_id) + .await + .map_err(BridgeMethodHandleError::internal_error)?; + + let runtime_view: SageAppRuntimeRecordView = runtime.into(); + + Ok(Box::new(runtime_view)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/get_active_taskbar_runtime.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/get_active_taskbar_runtime.rs new file mode 100644 index 000000000..76fdac464 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/get_active_taskbar_runtime.rs @@ -0,0 +1,66 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + SageAppRuntimeRecordView, SystemBridgeCapability, find_active_taskbar_runtime, + find_runtime_by_runtime_id_optional, resolve_running_app, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimeManagerGetActiveTaskbarRuntime; + +#[async_trait] +impl BridgeMethod for RuntimeManagerGetActiveTaskbarRuntime { + fn name(&self) -> &'static str { + "runtimeManager.getActiveTaskbarRuntime" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system( + SystemBridgeCapability::RuntimeManagerGetActiveTaskbarRuntime, + ) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let app = resolve_running_app(tools.host_state, &ctx.app.id()) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to resolve caller runtime: {err}" + )) + })?; + let host_window_label = app + .runtime() + .with_runtime(|runtime| runtime.host_window_label().to_string()); + + let active_taskbar_runtime = + find_active_taskbar_runtime(tools.host_state, &host_window_label).await; + + let Some(active_taskbar_runtime) = active_taskbar_runtime else { + return Ok(Box::new(None::)); + }; + + let runtime: Option = find_runtime_by_runtime_id_optional( + tools.host_state, + &active_taskbar_runtime.runtime_id(), + ) + .await + .map(Into::into); + + Ok(Box::new(runtime)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/hide_runtime.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/hide_runtime.rs new file mode 100644 index 000000000..aec7b5858 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/hide_runtime.rs @@ -0,0 +1,46 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RuntimeTargetParams, + RustBridgeRequest, SageAppRuntimeRecordView, SystemBridgeCapability, hide_runtime, + parse_required_params, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimeManagerHideRuntime; + +#[async_trait] +impl BridgeMethod for RuntimeManagerHideRuntime { + fn name(&self) -> &'static str { + "runtimeManager.hideRuntime" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::RuntimeManagerHideRuntime) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: RuntimeTargetParams = parse_required_params(self, request)?; + + let runtime = hide_runtime(tools.app_handle, tools.host_state, ¶ms.app_id) + .await + .map_err(BridgeMethodHandleError::internal_error)?; + + let runtime_view: SageAppRuntimeRecordView = runtime.into(); + Ok(Box::new(runtime_view)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/hide_self.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/hide_self.rs new file mode 100644 index 000000000..ff27bd80f --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/hide_self.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + SystemBridgeCapability, hide_runtime, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimeManagerHideSelf; + +#[async_trait] +impl BridgeMethod for RuntimeManagerHideSelf { + fn name(&self) -> &'static str { + "runtimeManager.hideSelf" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::RuntimeManagerHideSelf) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + hide_runtime(tools.app_handle, tools.host_state, &ctx.app.id()) + .await + .map_err(BridgeMethodHandleError::internal_error)?; + + Ok(Box::new(())) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/kill_runtime.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/kill_runtime.rs new file mode 100644 index 000000000..49e264600 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/kill_runtime.rs @@ -0,0 +1,64 @@ +use async_trait::async_trait; +use serde::Serialize; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RuntimeTargetParams, RustBridgeRequest, + SystemBridgeCapability, SystemKillRuntimeError, kill_runtime, parse_required_params, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimeManagerKillRuntime; + +#[derive(Debug, Copy, Clone, Serialize)] +pub struct RuntimeManagerKillRuntimeResponse { + ok: bool, +} + +impl RuntimeManagerKillRuntimeResponse { + pub fn ok() -> Self { + Self { ok: true } + } +} + +#[async_trait] +impl BridgeMethod for RuntimeManagerKillRuntime { + fn name(&self) -> &'static str { + "runtimeManager.killRuntime" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::RuntimeManagerKillRuntime) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: RuntimeTargetParams = parse_required_params(self, request)?; + + match kill_runtime( + tools.app_handle, + tools.host_state, + ¶ms.app_id, + "user_kill", + ) + .await + { + Ok(()) + | Err(SystemKillRuntimeError::NotFound | SystemKillRuntimeError::RuntimeSync(_)) => { + Ok(Box::new(RuntimeManagerKillRuntimeResponse::ok())) + } + } + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/runtime_manager/list_runtimes.rs b/crates/sage-apps/src/bridge/methods/system/runtime_manager/list_runtimes.rs new file mode 100644 index 000000000..35578dc4a --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/runtime_manager/list_runtimes.rs @@ -0,0 +1,47 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + SageAppRuntimeRecordView, SystemBridgeCapability, list_runtimes, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RuntimeManagerListRuntimes; + +#[async_trait] +impl BridgeMethod for RuntimeManagerListRuntimes { + fn name(&self) -> &'static str { + "runtimeManager.listRuntimes" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::RuntimeManagerListRuntimes) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let runtimes = list_runtimes(tools.host_state) + .await + .map_err(BridgeMethodHandleError::internal_error)?; + + let runtime_views = runtimes + .iter() + .map(Into::into) + .collect::>(); + + Ok(Box::new(runtime_views)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/sandbox.rs b/crates/sage-apps/src/bridge/methods/system/sandbox.rs new file mode 100644 index 000000000..1e5e6f91b --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/sandbox.rs @@ -0,0 +1,7 @@ +mod events; +mod get_state; +mod rerun_tests; + +pub(crate) use events::*; +pub(crate) use get_state::*; +pub(crate) use rerun_tests::*; diff --git a/crates/sage-apps/src/bridge/methods/system/sandbox/events.rs b/crates/sage-apps/src/bridge/methods/system/sandbox/events.rs new file mode 100644 index 000000000..c66f2d5a0 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/sandbox/events.rs @@ -0,0 +1,36 @@ +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, State}; + +use crate::{ + AppsHostState, SandboxStateView, SystemBridgeCapability, SystemRuntimeEvent, build_state_view, + emit_system_runtime_event_to_listeners, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SandboxStateChangedEvent { + pub state: SandboxStateView, +} + +impl SandboxStateChangedEvent { + pub fn new(state: SandboxStateView) -> Self { + Self { state } + } +} + +impl SystemRuntimeEvent for SandboxStateChangedEvent { + const TYPE: &'static str = "sandbox.stateChanged"; + const REQUIRED_CAPABILITY: SystemBridgeCapability = + SystemBridgeCapability::SandboxListenStateChanged; +} + +pub(crate) async fn emit_sandbox_state_changed( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + let view = build_state_view(apps_state).await; + + emit_system_runtime_event_to_listeners(app, apps_state, SandboxStateChangedEvent::new(view)) + .await; +} diff --git a/crates/sage-apps/src/bridge/methods/system/sandbox/get_state.rs b/crates/sage-apps/src/bridge/methods/system/sandbox/get_state.rs new file mode 100644 index 000000000..bb5cc280a --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/sandbox/get_state.rs @@ -0,0 +1,40 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RustBridgeRequest, SystemBridgeCapability, + build_state_view, +}; + +#[derive(Debug, Clone, Copy)] +pub struct SandboxGetState; + +#[async_trait] +impl BridgeMethod for SandboxGetState { + fn name(&self) -> &'static str { + "sandbox.getState" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::SandboxGetState) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let state = build_state_view(tools.host_state).await; + + Ok(Box::new(state)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/sandbox/rerun_tests.rs b/crates/sage-apps/src/bridge/methods/system/sandbox/rerun_tests.rs new file mode 100644 index 000000000..70d005026 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/sandbox/rerun_tests.rs @@ -0,0 +1,48 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + SystemBridgeCapability, begin_sandbox_run, sandbox_runner, +}; + +#[derive(Debug, Clone, Copy)] +pub struct SandboxRerunTests; + +#[async_trait] +impl BridgeMethod for SandboxRerunTests { + fn name(&self) -> &'static str { + "sandbox.rerunTests" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::SandboxRerunTests) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let view = begin_sandbox_run(tools.app_handle, tools.host_state) + .await + .map_err(BridgeMethodHandleError::internal_error)?; + + let runner_app = tools.app_handle.clone(); + + tokio::spawn(async move { + Box::pin(sandbox_runner(runner_app)).await; + }); + + Ok(Box::new(view)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/system/wallet.rs b/crates/sage-apps/src/bridge/methods/system/wallet.rs new file mode 100644 index 000000000..e95543d72 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/wallet.rs @@ -0,0 +1,3 @@ +mod list_wallets; + +pub(crate) use list_wallets::*; diff --git a/crates/sage-apps/src/bridge/methods/system/wallet/list_wallets.rs b/crates/sage-apps/src/bridge/methods/system/wallet/list_wallets.rs new file mode 100644 index 000000000..45b6626fb --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/wallet/list_wallets.rs @@ -0,0 +1,71 @@ +use async_trait::async_trait; +use sage_api::GetKeys; +use serde::Serialize; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + SystemBridgeCapability, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct WalletListWallets; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SystemWalletView { + pub fingerprint: u32, + pub name: String, + pub emoji: Option, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WalletListWalletsResult { + pub wallets: Vec, +} + +#[async_trait] +impl BridgeMethod for WalletListWallets { + fn name(&self) -> &'static str { + "wallet.listWallets" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::system(SystemBridgeCapability::WalletListWallets) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let sage = tools.app_state.lock().await; + + let keys = sage.get_keys(GetKeys {}).map_err(|err| { + BridgeMethodHandleError::internal_error(format!("{} failed: {err}", self.name())) + })?; + + Ok(Box::new(WalletListWalletsResult { + wallets: keys + .keys + .into_iter() + .map(|key| SystemWalletView { + fingerprint: key.fingerprint, + name: key.name, + emoji: key.emoji, + }) + .collect(), + })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user.rs b/crates/sage-apps/src/bridge/methods/user.rs new file mode 100644 index 000000000..059a186ed --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user.rs @@ -0,0 +1,9 @@ +mod app; +mod bridge; +mod environment; +mod wallet; + +pub(crate) use app::*; +pub(crate) use bridge::*; +pub(crate) use environment::*; +pub(crate) use wallet::*; diff --git a/crates/sage-apps/src/bridge/methods/user/app.rs b/crates/sage-apps/src/bridge/methods/user/app.rs new file mode 100644 index 000000000..9cffac374 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/app.rs @@ -0,0 +1,27 @@ +mod events; +mod get_capabilities; +mod get_info; +mod lifecycle; +mod request_capability_grant; +mod request_network_whitelist_grant; + +pub(crate) use events::*; +pub(crate) use get_capabilities::*; +pub(crate) use get_info::*; +pub(crate) use lifecycle::*; +pub(crate) use request_capability_grant::*; +pub(crate) use request_network_whitelist_grant::*; + +use std::path::PathBuf; + +use tauri::Manager; + +use crate::{BridgeMethodHandleError, BridgeTools}; + +pub(crate) fn resolve_app_base_path( + tools: &BridgeTools<'_>, +) -> Result { + tools.app_handle.path().app_data_dir().map_err(|err| { + BridgeMethodHandleError::internal_error(format!("failed to resolve app data dir: {err}")) + }) +} diff --git a/crates/sage-apps/src/bridge/methods/user/app/events.rs b/crates/sage-apps/src/bridge/methods/user/app/events.rs new file mode 100644 index 000000000..5289525bd --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/app/events.rs @@ -0,0 +1,75 @@ +use serde::Serialize; +use specta::Type; + +use crate::{ + GrantedCapabilitiesChange, GrantedNetworkWhitelistChange, SageNetworkWhitelistEntry, + UserBridgeCapability, UserRuntimeEvent, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GrantedCapabilitiesChangeEvent { + pub removed: Vec, + pub added: Vec, + pub full: Vec, +} + +impl GrantedCapabilitiesChangeEvent { + pub fn from_change(change: &GrantedCapabilitiesChange) -> Self { + Self { + removed: change.removed.clone(), + added: change.added.clone(), + full: change.full.clone(), + } + } +} + +impl UserRuntimeEvent for GrantedCapabilitiesChangeEvent { + const TYPE: &'static str = "grantedCapabilitiesChange"; + const REQUIRED_CAPABILITY: UserBridgeCapability = + UserBridgeCapability::AppRequestCapabilityGrant; +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct GrantedNetworkWhitelistChangeEvent { + pub removed: Vec, + pub added: Vec, + pub full: Vec, +} + +impl GrantedNetworkWhitelistChangeEvent { + pub fn from_change(change: &GrantedNetworkWhitelistChange) -> Self { + Self { + removed: change.removed.clone(), + added: change.added.clone(), + full: change.full.clone(), + } + } +} + +impl UserRuntimeEvent for GrantedNetworkWhitelistChangeEvent { + const TYPE: &'static str = "grantedNetworkWhitelistChange"; + const REQUIRED_CAPABILITY: UserBridgeCapability = + UserBridgeCapability::AppRequestNetworkWhitelistGrant; +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BeforeStopEvent { + pub request_id: String, +} + +impl BeforeStopEvent { + pub fn new(request_id: impl Into) -> Self { + Self { + request_id: request_id.into(), + } + } +} + +impl UserRuntimeEvent for BeforeStopEvent { + const TYPE: &'static str = "lifecycle.beforeStop"; + const REQUIRED_CAPABILITY: UserBridgeCapability = + UserBridgeCapability::AppLifecycleSetBeforeStopListener; +} diff --git a/crates/sage-apps/src/bridge/methods/user/app/get_capabilities.rs b/crates/sage-apps/src/bridge/methods/user/app/get_capabilities.rs new file mode 100644 index 000000000..4a56ada19 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/app/get_capabilities.rs @@ -0,0 +1,56 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RustBridgeRequest, SageApp, SharedCapabilitiesExt, + UserBridgeCapability, +}; + +#[derive(Debug, Clone, Copy)] +pub struct AppGetCapabilities; + +#[async_trait] +impl BridgeMethod for AppGetCapabilities { + fn name(&self) -> &'static str { + "app.getCapabilities" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::AppGetCapabilities) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + _tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let effective_capabilities = ctx.app.with(|app| match app { + SageApp::User(user_app) => user_app + .common() + .requested_permissions() + .capabilities() + .resolve_effective_grants( + user_app + .common() + .granted_permissions() + .capabilities() + .copied(), + ), + + SageApp::System(_) => app.granted_permissions().capabilities().copied().collect(), + }); + + let shared_capabilities = effective_capabilities.shared(); + + Ok(Box::new(shared_capabilities)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/app/get_info.rs b/crates/sage-apps/src/bridge/methods/user/app/get_info.rs new file mode 100644 index 000000000..47365ae1d --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/app/get_info.rs @@ -0,0 +1,84 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RustBridgeRequest, SageRequestedPermissions, + UserBridgeCapability, +}; + +#[derive(Debug, Clone, Copy)] +pub struct AppGetInfo; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SageNetworkPermissionInfo { + pub scheme: String, + pub host: String, + pub required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppGetInfoResult { + pub id: String, + pub name: String, + pub version: String, + pub requested_permissions: SageRequestedPermissions, + pub capabilities: Vec, + pub network: Vec, +} + +#[async_trait] +impl BridgeMethod for AppGetInfo { + fn name(&self) -> &'static str { + "app.getInfo" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::AppGetInfo) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + _tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let result = ctx.app.with(|app| { + let network = app + .granted_permissions() + .network() + .whitelist_iter() + .map(|entry| SageNetworkPermissionInfo { + scheme: entry.scheme().to_string(), + host: entry.host().to_string(), + required: app + .requested_permissions() + .network() + .whitelist() + .is_required(entry), + }) + .collect::>(); + AppGetInfoResult { + id: app.id().to_string(), + name: app.name().to_string(), + version: app.version().to_string(), + requested_permissions: app.requested_permissions().clone(), + capabilities: app.granted_permissions().shared_capabilities(), + network, + } + }); + + Ok(Box::new(result)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/app/lifecycle.rs b/crates/sage-apps/src/bridge/methods/user/app/lifecycle.rs new file mode 100644 index 000000000..2a63548fc --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/app/lifecycle.rs @@ -0,0 +1,95 @@ +use async_trait::async_trait; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, ReadyToStopParams, RuntimeAckResult, RustBridgeRequest, + SetBeforeStopListenerParams, UserBridgeCapability, parse_required_params, +}; + +#[derive(Debug, Clone, Copy)] +pub struct AppLifecycleSetBeforeStopListener; + +#[derive(Debug, Clone, Copy)] +pub struct AppLifecycleReadyToStop; + +#[async_trait] +impl BridgeMethod for AppLifecycleSetBeforeStopListener { + fn name(&self) -> &'static str { + "app.lifecycle.setBeforeStopListener" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::AppLifecycleSetBeforeStopListener) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: SetBeforeStopListenerParams = parse_required_params(self, request)?; + + let mut listeners = tools + .host_state + .runtime + .before_stop_listeners_by_app_id + .lock() + .await; + + if params.active() { + listeners.insert(ctx.app.id().to_string()); + } else { + listeners.remove(&ctx.app.id()); + } + + Ok(Box::new(RuntimeAckResult { ok: true })) + } +} + +#[async_trait] +impl BridgeMethod for AppLifecycleReadyToStop { + fn name(&self) -> &'static str { + "app.lifecycle.readyToStop" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::AppLifecycleReadyToStop) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: ReadyToStopParams = parse_required_params(self, request)?; + + let sender = { + let mut pending = tools.host_state.runtime.pending_stop_ready.lock().await; + pending.remove(params.request_id()) + }; + + if let Some(sender) = sender { + let _ = sender.send(()); + } + + Ok(Box::new(RuntimeAckResult { ok: true })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/app/request_capability_grant.rs b/crates/sage-apps/src/bridge/methods/user/app/request_capability_grant.rs new file mode 100644 index 000000000..cf6e4e2c3 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/app/request_capability_grant.rs @@ -0,0 +1,153 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, GrantCapabilityOutcome, + RustBridgeApprovalBody, RustBridgeApprovalRequest, RustBridgeRequest, UserBridgeCapability, + get_user_capability_definition, grant_capability, parse_required_params, resolve_app_base_path, +}; + +#[derive(Debug, Clone, Copy)] +pub struct AppRequestCapabilityGrant; + +#[derive(Debug, Copy, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RequestCapabilityGrantParams { + pub capability: UserBridgeCapability, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RequestCapabilityGrantResult { + pub granted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub already_granted: Option, + pub capability: UserBridgeCapability, + pub full_granted_capabilities: Vec, +} + +fn ensure_capability_requestable_by_app( + capability: UserBridgeCapability, +) -> Result<(), BridgeMethodHandleError> { + let definition = get_user_capability_definition(capability); + + if !definition.flags().requestable_by_app() { + return Err(BridgeMethodHandleError::invalid_request(format!( + "capability cannot be requested by app: {}", + capability.key() + ))); + } + + Ok(()) +} + +#[async_trait] +impl BridgeMethod for AppRequestCapabilityGrant { + fn name(&self) -> &'static str { + "app.requestCapabilityGrant" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::AppRequestCapabilityGrant) + } + + fn approval_request( + &self, + ctx: BridgeContext<'_>, + request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + let params: RequestCapabilityGrantParams = parse_required_params(self, request)?; + + ensure_capability_requestable_by_app(params.capability)?; + + if ctx.app.is_capability_granted(params.capability.into()) { + return Ok(None); + } + + let definition = get_user_capability_definition(params.capability); + + Ok(Some(RustBridgeApprovalRequest { + body: RustBridgeApprovalBody::CapabilityGrant { + capability: params.capability, + definition: definition.into(), + }, + })) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: RequestCapabilityGrantParams = parse_required_params(self, request)?; + + ensure_capability_requestable_by_app(params.capability)?; + + let base_path = resolve_app_base_path(&tools)?; + + let result = match grant_capability( + tools.app_handle, + tools.host_state, + &base_path, + &ctx.app.id(), + params.capability, + ) + .await + { + Ok(GrantCapabilityOutcome::AlreadyGranted { + capability, + full_granted_capabilities, + }) => RequestCapabilityGrantResult { + granted: true, + already_granted: Some(true), + capability, + full_granted_capabilities, + }, + + Ok(GrantCapabilityOutcome::Granted { capability, change }) => { + RequestCapabilityGrantResult { + granted: true, + already_granted: None, + capability, + full_granted_capabilities: change.full, + } + } + + Err(err) => { + return Err(BridgeMethodHandleError::internal_error(format!( + "failed to grant requested capability: {err}" + ))); + } + }; + + Ok(Box::new(result)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_non_requestable_capability() { + let err = + ensure_capability_requestable_by_app(UserBridgeCapability::WalletSendXchAutoSubmit) + .expect_err("auto-submit send must not be requestable by running apps"); + + let message = format!("{err:?}"); + + assert!( + message.contains("wallet.send_xch_auto_submit"), + "error should mention rejected capability, got: {message}" + ); + } + + #[test] + fn allows_requestable_capability() { + ensure_capability_requestable_by_app(UserBridgeCapability::WalletSendXch) + .expect("regular send capability should be requestable by running apps"); + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/app/request_network_whitelist_grant.rs b/crates/sage-apps/src/bridge/methods/user/app/request_network_whitelist_grant.rs new file mode 100644 index 000000000..ea3006e88 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/app/request_network_whitelist_grant.rs @@ -0,0 +1,133 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, GrantNetworkWhitelistOutcome, + RustBridgeApprovalBody, RustBridgeApprovalRequest, RustBridgeRequest, + SageNetworkWhitelistEntry, UserBridgeCapability, grant_network_whitelist_entry, + parse_required_params, resolve_app_base_path, +}; + +#[derive(Debug, Clone, Copy)] +pub struct AppRequestNetworkWhitelistGrant; + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RequestNetworkWhitelistGrantParams { + pub entry: SageNetworkWhitelistEntry, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RequestNetworkWhitelistGrantResult { + pub granted: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub already_granted: Option, + + pub entry: SageNetworkWhitelistEntry, + + #[serde(skip_serializing_if = "Option::is_none")] + pub network_id: Option, + + pub full_granted_network_whitelist: Vec, +} + +#[async_trait] +impl BridgeMethod for AppRequestNetworkWhitelistGrant { + fn name(&self) -> &'static str { + "app.requestNetworkWhitelistGrant" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::AppRequestNetworkWhitelistGrant) + } + + fn approval_request( + &self, + ctx: BridgeContext<'_>, + request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + let params: RequestNetworkWhitelistGrantParams = parse_required_params(self, request)?; + + let already_granted = ctx.app.with(|app| { + let network = app.granted_permissions().network(); + + match params.network_id.as_deref() { + Some(network_id) => network + .whitelist_by_network() + .get(network_id) + .is_some_and(|entries| entries.contains(¶ms.entry)), + None => network.whitelist_iter().any(|entry| entry == ¶ms.entry), + } + }); + + if already_granted { + return Ok(None); + } + + Ok(Some(RustBridgeApprovalRequest { + body: RustBridgeApprovalBody::NetworkWhitelistGrant { + entry: params.entry, + network_id: params.network_id, + }, + })) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: RequestNetworkWhitelistGrantParams = parse_required_params(self, request)?; + + let base_path = resolve_app_base_path(&tools)?; + + let grant_result = grant_network_whitelist_entry( + tools.app_handle, + tools.host_state, + &base_path, + &ctx.app.id(), + params.network_id.as_deref(), + ¶ms.entry, + ) + .await; + + let result = match grant_result { + Ok(GrantNetworkWhitelistOutcome::AlreadyGranted { + entry, + full_granted_network_whitelist, + }) => RequestNetworkWhitelistGrantResult { + granted: true, + already_granted: Some(true), + entry, + network_id: params.network_id, + full_granted_network_whitelist, + }, + + Ok(GrantNetworkWhitelistOutcome::Granted { entry, change }) => { + RequestNetworkWhitelistGrantResult { + granted: true, + already_granted: None, + entry, + network_id: params.network_id, + full_granted_network_whitelist: change.full, + } + } + + Err(err) => { + return Err(BridgeMethodHandleError::internal_error(format!( + "failed to grant requested network whitelist entry: {err}" + ))); + } + }; + + Ok(Box::new(result)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/bridge.rs b/crates/sage-apps/src/bridge/methods/user/bridge.rs new file mode 100644 index 000000000..e93fff822 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/bridge.rs @@ -0,0 +1,5 @@ +mod ping; +mod send; + +pub(crate) use ping::*; +pub(crate) use send::*; diff --git a/crates/sage-apps/src/bridge/methods/user/bridge/ping.rs b/crates/sage-apps/src/bridge/methods/user/bridge/ping.rs new file mode 100644 index 000000000..5cc040f22 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/bridge/ping.rs @@ -0,0 +1,51 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeTools, RustBridgeRequest, +}; + +#[derive(Debug, Clone, Copy)] +pub struct BridgePing; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BridgePingResult { + pub ok: bool, + pub app_id: String, + pub app_name: String, +} + +#[async_trait] +impl BridgeMethod for BridgePing { + fn name(&self) -> &'static str { + "bridge.ping" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::ungated() + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + _tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + Ok(Box::new(BridgePingResult { + ok: true, + app_id: ctx.app.id(), + app_name: ctx.app.name(), + })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/bridge/send.rs b/crates/sage-apps/src/bridge/methods/user/bridge/send.rs new file mode 100644 index 000000000..162e22ca3 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/bridge/send.rs @@ -0,0 +1,108 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID, BridgeApprovalRequestResult, BridgeContext, + BridgeHandleResult, BridgeMethod, BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, + RustBridgeRequest, UserBridgeCapability, ingest_bridge_send_payload, + ingest_origin_cleanup_bridge_send_payload, parse_required_params, +}; + +#[derive(Debug, Clone, Copy)] +pub struct BridgeSend; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeSendRequest { + pub kind: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct BridgeSendResult { + pub ok: bool, +} + +#[derive(Debug, Clone, Copy)] +enum BridgeSendContextKind { + Sandbox, + OriginCleanup, +} + +#[async_trait] +impl BridgeMethod for BridgeSend { + fn name(&self) -> &'static str { + "bridge.send" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::BridgeSend) + } + + fn approval_request( + &self, + ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + check_ctx(&ctx)?; + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let ctx_kind = check_ctx(&ctx)?; + + let payload: BridgeSendRequest = parse_required_params(self, request)?; + + let payload_value = serde_json::to_value(&payload).map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to encode bridge.send payload: {err}" + )) + })?; + + match ctx_kind { + BridgeSendContextKind::Sandbox => { + ingest_bridge_send_payload(&ctx.app.id(), &payload_value, tools.host_state).await; + } + + BridgeSendContextKind::OriginCleanup => { + ingest_origin_cleanup_bridge_send_payload( + &ctx.app.id(), + &payload_value, + tools.host_state, + ) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to ingest origin cleanup payload: {err}" + )) + })?; + } + } + + Ok(Box::new(BridgeSendResult { ok: true })) + } +} + +fn check_ctx(ctx: &BridgeContext<'_>) -> Result { + let is_sandbox_test = ctx.app.with(|app| app.common().is_sandbox_test()); + if is_sandbox_test { + return Ok(BridgeSendContextKind::Sandbox); + } + + let app_id = ctx.app.id(); + if app_id == BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID { + return Ok(BridgeSendContextKind::OriginCleanup); + } + + Err(BridgeMethodHandleError::invalid_request( + "Method use is not allowed", + )) +} diff --git a/crates/sage-apps/src/bridge/methods/user/environment.rs b/crates/sage-apps/src/bridge/methods/user/environment.rs new file mode 100644 index 000000000..28b6a235e --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/environment.rs @@ -0,0 +1,5 @@ +mod get_network; +mod theme; + +pub(crate) use get_network::*; +pub(crate) use theme::*; diff --git a/crates/sage-apps/src/bridge/methods/user/environment/get_network.rs b/crates/sage-apps/src/bridge/methods/user/environment/get_network.rs new file mode 100644 index 000000000..ad28dfe6f --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/environment/get_network.rs @@ -0,0 +1,66 @@ +use async_trait::async_trait; +use sage_api::{GetNetwork, NetworkKind}; +use serde::Serialize; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + UserBridgeCapability, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentGetNetworkResult { + pub name: String, + pub network_id: String, + pub kind: NetworkKind, + pub ticker: String, + pub prefix: String, + pub precision: u8, +} + +#[derive(Debug, Clone, Copy)] +pub struct EnvironmentGetNetwork; + +#[async_trait] +impl BridgeMethod for EnvironmentGetNetwork { + fn name(&self) -> &'static str { + "environment.getNetwork" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::EnvironmentGetNetwork) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let current = tools + .app_state + .lock() + .await + .get_network(GetNetwork {}) + .map_err(|err| BridgeMethodHandleError::internal_error(err.to_string()))?; + + Ok(Box::new(EnvironmentGetNetworkResult { + name: current.network.name.clone(), + network_id: current.network.network_id(), + kind: current.kind, + ticker: current.network.ticker.clone(), + prefix: current.network.prefix(), + precision: current.network.precision, + })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/environment/theme.rs b/crates/sage-apps/src/bridge/methods/user/environment/theme.rs new file mode 100644 index 000000000..1c9ceba53 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/environment/theme.rs @@ -0,0 +1,5 @@ +mod events; +mod get_current; + +pub use events::*; +pub use get_current::*; diff --git a/crates/sage-apps/src/bridge/methods/user/environment/theme/events.rs b/crates/sage-apps/src/bridge/methods/user/environment/theme/events.rs new file mode 100644 index 000000000..039e32cbc --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/environment/theme/events.rs @@ -0,0 +1,16 @@ +use serde::Serialize; +use specta::Type; + +use crate::{EnvironmentThemeView, UserBridgeCapability, UserRuntimeEvent}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentThemeChangedEvent { + pub theme: EnvironmentThemeView, +} + +impl UserRuntimeEvent for EnvironmentThemeChangedEvent { + const TYPE: &'static str = "environment.theme.changed"; + const REQUIRED_CAPABILITY: UserBridgeCapability = + UserBridgeCapability::EnvironmentThemeListenChanged; +} diff --git a/crates/sage-apps/src/bridge/methods/user/environment/theme/get_current.rs b/crates/sage-apps/src/bridge/methods/user/environment/theme/get_current.rs new file mode 100644 index 000000000..96d6a5bef --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/environment/theme/get_current.rs @@ -0,0 +1,75 @@ +use std::collections::BTreeMap; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + UserBridgeCapability, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentThemeView { + pub name: String, + pub display_name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub most_like: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub inherits: Option, + + pub css_vars: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentThemeGetCurrentResult { + pub theme: EnvironmentThemeView, +} + +#[derive(Debug, Clone, Copy)] +pub struct EnvironmentThemeGetCurrent; + +#[async_trait] +impl BridgeMethod for EnvironmentThemeGetCurrent { + fn name(&self) -> &'static str { + "environment.theme.getCurrent" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::EnvironmentThemeGetCurrent) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let theme = tools + .host_state + .environment + .theme + .current + .lock() + .await + .clone() + .ok_or_else(|| { + BridgeMethodHandleError::internal_error("current theme is not initialized") + })?; + + Ok(Box::new(EnvironmentThemeGetCurrentResult { theme })) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/wallet.rs b/crates/sage-apps/src/bridge/methods/user/wallet.rs new file mode 100644 index 000000000..fe85044ee --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/wallet.rs @@ -0,0 +1,28 @@ +mod get_key; +mod get_secret_key; +mod read_methods; +mod send_xch; + +pub(crate) use get_key::*; +pub(crate) use get_secret_key::*; +pub(crate) use read_methods::*; +pub(crate) use send_xch::*; + +use crate::{BridgeContext, BridgeMethodHandleError}; + +pub(crate) fn require_scoped_fingerprint( + ctx: &BridgeContext<'_>, + fingerprint: Option, +) -> Result { + let fingerprint = fingerprint.ok_or_else(|| { + BridgeMethodHandleError::invalid_request("wallet fingerprint is required for apps") + })?; + + if !ctx.app.is_wallet_in_scope(fingerprint) { + return Err(BridgeMethodHandleError::invalid_request(format!( + "wallet fingerprint not in app wallet scope: {fingerprint}" + ))); + } + + Ok(fingerprint) +} diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/get_key.rs b/crates/sage-apps/src/bridge/methods/user/wallet/get_key.rs new file mode 100644 index 000000000..4e896e7fb --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/wallet/get_key.rs @@ -0,0 +1,56 @@ +use async_trait::async_trait; +use sage_api::GetKey; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + UserBridgeCapability, parse_required_params, require_scoped_fingerprint, +}; + +#[derive(Debug, Clone, Copy)] +pub struct WalletGetKey; + +#[async_trait] +impl BridgeMethod for WalletGetKey { + fn name(&self) -> &'static str { + "wallet.getKey" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::WalletGetKey) + } + + fn approval_request( + &self, + ctx: BridgeContext<'_>, + request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + let params: GetKey = parse_required_params(self, request)?; + + require_scoped_fingerprint(&ctx, params.fingerprint)?; + + Ok(None) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: GetKey = parse_required_params(self, request)?; + + require_scoped_fingerprint(&ctx, params.fingerprint)?; + + let sage = tools.app_state.lock().await; + + let result = sage.get_key(params).map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to execute {}: {err}", + self.name() + )) + })?; + + Ok(Box::new(result)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/get_secret_key.rs b/crates/sage-apps/src/bridge/methods/user/wallet/get_secret_key.rs new file mode 100644 index 000000000..bf04fa1e1 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/wallet/get_secret_key.rs @@ -0,0 +1,56 @@ +use async_trait::async_trait; +use sage_api::GetSecretKey; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeApprovalBody, + RustBridgeApprovalRequest, RustBridgeRequest, UserBridgeCapability, parse_required_params, + require_scoped_fingerprint, +}; + +#[derive(Debug, Clone, Copy)] +pub struct WalletGetSecretKey; + +#[async_trait] +impl BridgeMethod for WalletGetSecretKey { + fn name(&self) -> &'static str { + "wallet.getSecretKey" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::WalletGetSecretKey) + } + + fn approval_request( + &self, + ctx: BridgeContext<'_>, + request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + let params: GetSecretKey = parse_required_params(self, request)?; + require_scoped_fingerprint(&ctx, Some(params.fingerprint))?; + + Ok(Some(RustBridgeApprovalRequest { + body: RustBridgeApprovalBody::GetSecretKey { + fingerprint: params.fingerprint, + }, + })) + } + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: GetSecretKey = parse_required_params(self, request)?; + require_scoped_fingerprint(&ctx, Some(params.fingerprint))?; + + let sage = tools.app_state.lock().await; + + let response = sage.get_secret_key(params).map_err(|err| { + BridgeMethodHandleError::internal_error(format!("{} failed: {err}", self.name())) + })?; + + Ok(Box::new(response)) + } +} diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/read_methods.rs b/crates/sage-apps/src/bridge/methods/user/wallet/read_methods.rs new file mode 100644 index 000000000..13c282b9a --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/wallet/read_methods.rs @@ -0,0 +1,234 @@ +use async_trait::async_trait; +use sage_api::{ + CheckAddress, GetCoins, GetCoinsByIds, GetDerivations, GetPendingTransactions, + GetSpendableCoinCount, GetSyncStatus, GetTransaction, GetTransactions, GetVersion, + GetXchUsdPrice, +}; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeRequest, + UserBridgeCapability, parse_required_params, +}; + +macro_rules! define_wallet_read_no_params_async_method { + ($struct_name:ident, $capability:ident, $method_name:expr, $request_ident:ident, $handler:ident) => { + #[derive(Debug, Clone, Copy)] + pub struct $struct_name; + + #[async_trait] + impl BridgeMethod for $struct_name { + fn name(&self) -> &'static str { + $method_name + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::$capability) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let sage = tools.app_state.lock().await; + + let result = sage.$handler($request_ident {}).await.map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to execute {}: {err}", + self.name() + )) + })?; + + Ok(Box::new(result)) + } + } + }; +} + +macro_rules! define_wallet_read_no_params_sync_method { + ($struct_name:ident, $capability:ident, $method_name:expr, $request_ident:ident, $handler:ident) => { + #[derive(Debug, Clone, Copy)] + pub struct $struct_name; + + #[async_trait] + impl BridgeMethod for $struct_name { + fn name(&self) -> &'static str { + $method_name + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::$capability) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + _request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let sage = tools.app_state.lock().await; + + let result = sage.$handler($request_ident {}).map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to execute {}: {err}", + self.name() + )) + })?; + + Ok(Box::new(result)) + } + } + }; +} + +macro_rules! define_wallet_read_params_async_method { + ($struct_name:ident, $capability:ident, $method_name:expr, $request_ty:ty, $handler:ident) => { + #[derive(Debug, Clone, Copy)] + pub struct $struct_name; + + #[async_trait] + impl BridgeMethod for $struct_name { + fn name(&self) -> &'static str { + $method_name + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::$capability) + } + + fn approval_request( + &self, + _ctx: BridgeContext<'_>, + _request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + Ok(None) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: $request_ty = parse_required_params(self, request)?; + + let sage = tools.app_state.lock().await; + + let result = sage.$handler(params).await.map_err(|err| { + BridgeMethodHandleError::internal_error(format!( + "failed to execute {}: {err}", + self.name() + )) + })?; + + Ok(Box::new(result)) + } + } + }; +} + +define_wallet_read_no_params_async_method!( + WalletGetSyncStatus, + WalletGetSyncStatus, + "wallet.getSyncStatus", + GetSyncStatus, + get_sync_status +); + +define_wallet_read_no_params_sync_method!( + WalletGetVersion, + WalletGetVersion, + "wallet.getVersion", + GetVersion, + get_version +); + +define_wallet_read_no_params_async_method!( + WalletGetPendingTransactions, + WalletGetPendingTransactions, + "wallet.getPendingTransactions", + GetPendingTransactions, + get_pending_transactions +); + +define_wallet_read_no_params_async_method!( + WalletGetXchUsdPrice, + WalletGetXchUsdPrice, + "wallet.getXchUsdPrice", + GetXchUsdPrice, + get_xch_usd_price +); + +define_wallet_read_params_async_method!( + WalletCheckAddress, + WalletCheckAddress, + "wallet.checkAddress", + CheckAddress, + check_address +); + +define_wallet_read_params_async_method!( + WalletGetDerivations, + WalletGetDerivations, + "wallet.getDerivations", + GetDerivations, + get_derivations +); + +define_wallet_read_params_async_method!( + WalletGetSpendableCoinCount, + WalletGetSpendableCoinCount, + "wallet.getSpendableCoinCount", + GetSpendableCoinCount, + get_spendable_coin_count +); + +define_wallet_read_params_async_method!( + WalletGetCoinsByIds, + WalletGetCoinsByIds, + "wallet.getCoinsByIds", + GetCoinsByIds, + get_coins_by_ids +); + +define_wallet_read_params_async_method!( + WalletGetCoins, + WalletGetCoins, + "wallet.getCoins", + GetCoins, + get_coins +); + +define_wallet_read_params_async_method!( + WalletGetTransaction, + WalletGetTransaction, + "wallet.getTransaction", + GetTransaction, + get_transaction +); + +define_wallet_read_params_async_method!( + WalletGetTransactions, + WalletGetTransactions, + "wallet.getTransactions", + GetTransactions, + get_transactions +); diff --git a/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs new file mode 100644 index 000000000..1becd5ab4 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/user/wallet/send_xch.rs @@ -0,0 +1,97 @@ +use async_trait::async_trait; +use sage_api::SendXch; +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + BridgeApprovalRequestResult, BridgeContext, BridgeHandleResult, BridgeMethod, + BridgeMethodCapability, BridgeMethodHandleError, BridgeTools, RustBridgeApprovalBody, + RustBridgeApprovalRequest, RustBridgeRequest, UserBridgeCapability, parse_required_params, +}; + +#[derive(Debug, Clone, Copy)] +pub struct WalletSendXch; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WalletSendXchParams { + pub address: String, + pub amount: String, + pub fee: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub memos: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub clawback: Option, +} + +fn parse_amount(value: String) -> sage_api::Amount { + match value.parse::() { + Ok(number) => sage_api::Amount::Number(number), + Err(_) => sage_api::Amount::String(value), + } +} + +impl From for SendXch { + fn from(v: WalletSendXchParams) -> Self { + Self { + address: v.address, + amount: parse_amount(v.amount), + fee: parse_amount(v.fee), + memos: v.memos.unwrap_or_default(), + clawback: v.clawback, + auto_submit: true, + } + } +} + +#[async_trait] +impl BridgeMethod for WalletSendXch { + fn name(&self) -> &'static str { + "wallet.sendXch" + } + + fn capability(&self) -> BridgeMethodCapability { + BridgeMethodCapability::user(UserBridgeCapability::WalletSendXch) + } + + fn approval_request( + &self, + ctx: BridgeContext<'_>, + request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult { + if ctx + .app + .is_capability_granted(UserBridgeCapability::WalletSendXchAutoSubmit.into()) + { + return Ok(None); + } + + let params = parse_required_params::(self, request)?; + + Ok(Some(RustBridgeApprovalRequest { + body: RustBridgeApprovalBody::SendXch { summary: params }, + })) + } + + async fn handle( + &self, + _ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult { + let params: WalletSendXchParams = parse_required_params(self, request)?; + let req: SendXch = params.into(); + + let result = tools + .app_state + .lock() + .await + .send_xch(req) + .await + .map_err(|err| { + BridgeMethodHandleError::internal_error(format!("{} failed: {err}", self.name())) + })?; + + Ok(Box::new(result)) + } +} diff --git a/crates/sage-apps/src/bridge/registry.rs b/crates/sage-apps/src/bridge/registry.rs new file mode 100644 index 000000000..141bcce39 --- /dev/null +++ b/crates/sage-apps/src/bridge/registry.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; + +use crate::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BridgeRegistryKind { + User, + System, +} + +pub(crate) struct BridgeRegistry { + methods: HashMap<&'static str, Box>, +} + +impl BridgeRegistry { + pub(crate) fn new(kind: BridgeRegistryKind) -> Self { + match kind { + BridgeRegistryKind::User => Self { + methods: build_user_methods(), + }, + BridgeRegistryKind::System => Self { + methods: build_system_methods(), + }, + } + } + + pub(crate) fn get(&self, method: &str) -> Option<&dyn BridgeMethod> { + self.methods.get(method).map(AsRef::as_ref) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.methods + .iter() + .map(|(name, method)| (*name, method.as_ref())) + } +} + +fn build_user_methods() -> HashMap<&'static str, Box> { + let mut methods: HashMap<&'static str, Box> = HashMap::new(); + + // Bridge + insert_method(&mut methods, BridgePing); + insert_method(&mut methods, BridgeSend); + + // App + insert_method(&mut methods, AppGetInfo); + insert_method(&mut methods, AppGetCapabilities); + insert_method(&mut methods, AppRequestCapabilityGrant); + insert_method(&mut methods, AppRequestNetworkWhitelistGrant); + insert_method(&mut methods, AppLifecycleSetBeforeStopListener); + insert_method(&mut methods, AppLifecycleReadyToStop); + + // Wallet keys / secrets + insert_method(&mut methods, WalletGetKey); + insert_method(&mut methods, WalletGetSecretKey); + + // Wallet XCH + insert_method(&mut methods, WalletSendXch); + + // Wallet read/query + insert_method(&mut methods, WalletGetSyncStatus); + insert_method(&mut methods, WalletGetVersion); + insert_method(&mut methods, WalletGetPendingTransactions); + insert_method(&mut methods, WalletGetXchUsdPrice); + insert_method(&mut methods, WalletCheckAddress); + insert_method(&mut methods, WalletGetDerivations); + insert_method(&mut methods, WalletGetSpendableCoinCount); + insert_method(&mut methods, WalletGetCoinsByIds); + insert_method(&mut methods, WalletGetCoins); + insert_method(&mut methods, WalletGetTransaction); + insert_method(&mut methods, WalletGetTransactions); + + // Environment + insert_method(&mut methods, EnvironmentThemeGetCurrent); + insert_method(&mut methods, EnvironmentGetNetwork); + + methods +} + +fn build_system_methods() -> HashMap<&'static str, Box> { + let mut methods: HashMap<&'static str, Box> = HashMap::new(); + + insert_method(&mut methods, RuntimeManagerListRuntimes); + insert_method(&mut methods, RuntimeManagerFocusTaskbarRuntime); + insert_method(&mut methods, RuntimeManagerHideRuntime); + insert_method(&mut methods, RuntimeManagerKillRuntime); + insert_method(&mut methods, RuntimeManagerHideSelf); + insert_method(&mut methods, RuntimeManagerCloseSelf); + insert_method(&mut methods, RuntimeManagerGetActiveTaskbarRuntime); + + insert_method(&mut methods, AppInstallPreviewUrl); + insert_method(&mut methods, AppInstallPreviewZip); + insert_method(&mut methods, AppInstallInstallUrl); + insert_method(&mut methods, AppInstallInstallZip); + + insert_method(&mut methods, AppUpdateGetReviewContext); + insert_method(&mut methods, AppUpdateApplyUpdate); + + insert_method(&mut methods, CapabilitiesListUserDefinitions); + insert_method(&mut methods, AppPermissionsGetReviewContext); + insert_method(&mut methods, AppPermissionsApplyPermissions); + + insert_method(&mut methods, FileSystemSelectFile); + + insert_method(&mut methods, BridgeApprovalsListPending); + insert_method(&mut methods, BridgeApprovalsResolve); + + insert_method(&mut methods, DonationGetDetails); + + insert_method(&mut methods, SandboxGetState); + insert_method(&mut methods, SandboxRerunTests); + + insert_method(&mut methods, WalletListWallets); + + methods +} + +impl std::fmt::Debug for BridgeRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BridgeRegistry") + .field("method_count", &self.methods.len()) + .finish() + } +} + +fn insert_method(methods: &mut HashMap<&'static str, Box>, method: M) +where + M: BridgeMethod + 'static, +{ + methods.insert(method.name(), Box::new(method)); +} diff --git a/crates/sage-apps/src/bridge/state.rs b/crates/sage-apps/src/bridge/state.rs new file mode 100644 index 000000000..f0d49462d --- /dev/null +++ b/crates/sage-apps/src/bridge/state.rs @@ -0,0 +1,199 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use tauri::async_runtime::JoinHandle; +use tauri::{AppHandle, Manager, State}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::{ + AppsHostState, BridgeRegistryKind, PendingBridgeApproval, RustBridgeApprovalRequest, + RustBridgeRequest, comms_debug, emit_bridge_approvals_changed, + emit_timeout_for_pending_approval, sync_bridge_approval_runtime, unix_timestamp_ms, +}; + +const BRIDGE_APPROVAL_TIMEOUT_MS: u64 = 30_000; + +#[derive(Debug, Default)] +pub struct BridgeState { + pending_approvals: Mutex>, + approval_expiry_task: Mutex>>, +} + +pub(crate) async fn write_pending_approval( + apps_state: &State<'_, AppsHostState>, + app_id: String, + registry_kind: BridgeRegistryKind, + approval: &RustBridgeApprovalRequest, + request: &RustBridgeRequest, + approved_fingerprint: Option, +) -> String { + let approval_id = Uuid::new_v4().to_string(); + let now = unix_timestamp_ms() as u64; + let mut pending = apps_state.bridge.pending_approvals.lock().await; + pending.insert( + approval_id.to_string(), + PendingBridgeApproval { + approval_id: approval_id.clone(), + app_id, + registry_kind, + approval: approval.clone(), + request: request.clone(), + created_at_ms: now, + expires_at_ms: now + BRIDGE_APPROVAL_TIMEOUT_MS, + approved_fingerprint, + }, + ); + + approval_id +} + +pub(crate) async fn list_pending_approvals( + apps_state: &State<'_, AppsHostState>, +) -> Vec { + let pending = apps_state.bridge.pending_approvals.lock().await; + + pending.values().cloned().collect() +} + +pub(crate) async fn remove_pending_approval( + apps_state: &State<'_, AppsHostState>, + approval_id: &str, +) { + let mut pending = apps_state.bridge.pending_approvals.lock().await; + pending.remove(approval_id); +} + +pub(crate) async fn take_pending_approval( + apps_state: &State<'_, AppsHostState>, + approval_id: &str, +) -> Option { + let mut pending = apps_state.bridge.pending_approvals.lock().await; + pending.remove(approval_id) +} + +pub(crate) async fn pending_approval_app_ids(apps_state: &State<'_, AppsHostState>) -> Vec { + use std::collections::BTreeSet; + + list_pending_approvals(apps_state) + .await + .into_iter() + .map(|approval| approval.app_id) + .collect::>() + .into_iter() + .collect() +} + +pub async fn ensure_approval_expiry_loop( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + let mut guard = apps_state.bridge.approval_expiry_task.lock().await; + + if guard.is_some() { + return; + } + + let handle = { + let app_handle = app_handle.clone(); + + tauri::async_runtime::spawn(async move { + approval_expiry_loop(app_handle).await; + }) + }; + + *guard = Some(handle); +} + +async fn approval_expiry_loop(app_handle: AppHandle) { + comms_debug!("approval_expiry:loop_started"); + + loop { + let apps_state: State<'_, AppsHostState> = app_handle.state(); + let pending = list_pending_approvals(&apps_state).await; + + comms_debug!("approval_expiry:tick pending={}", pending.len()); + + if pending.is_empty() { + comms_debug!("approval_expiry:empty_stop"); + + let mut guard = apps_state.bridge.approval_expiry_task.lock().await; + + let pending = apps_state.bridge.pending_approvals.lock().await; + if pending.is_empty() { + *guard = None; + return; + } + + comms_debug!("approval_expiry:empty_stop_aborted_new_pending"); + continue; + } + + let now = unix_timestamp_ms() as u64; + let mut next_expiry: Option = None; + let mut expired = Vec::new(); + + for approval in pending { + comms_debug!( + "approval_expiry:check id={} app={} now={} expires_at={} remaining_ms={}", + approval.approval_id, + approval.app_id, + now, + approval.expires_at_ms, + approval.expires_at_ms.saturating_sub(now), + ); + + if approval.expires_at_ms <= now { + expired.push(approval); + } else { + next_expiry = Some(match next_expiry { + Some(current) => current.min(approval.expires_at_ms), + None => approval.expires_at_ms, + }); + } + } + + comms_debug!("approval_expiry:expired count={}", expired.len()); + + for approval in &expired { + comms_debug!( + "approval_expiry:remove id={} app={}", + approval.approval_id, + approval.app_id, + ); + + remove_pending_approval(&apps_state, &approval.approval_id).await; + + if let Err(err) = + emit_timeout_for_pending_approval(&app_handle, &apps_state, approval).await + { + comms_debug!( + "approval_expiry:timeout_emit_failed id={} error={}", + approval.approval_id, + err, + ); + } + } + + if !expired.is_empty() { + comms_debug!("approval_expiry:sync_after_expired"); + + if let Err(err) = sync_bridge_approval_runtime(&app_handle, &apps_state).await { + comms_debug!("approval_expiry:sync_failed error={}", err); + } + + emit_bridge_approvals_changed(&app_handle, &apps_state).await; + } + + let Some(next_expiry) = next_expiry else { + comms_debug!("approval_expiry:no_next_continue"); + continue; + }; + + if next_expiry > now { + let sleep_ms = next_expiry - now; + comms_debug!("approval_expiry:sleep ms={}", sleep_ms); + tokio::time::sleep(Duration::from_millis(sleep_ms)).await; + } + } +} diff --git a/crates/sage-apps/src/bridge/ts_exports.rs b/crates/sage-apps/src/bridge/ts_exports.rs new file mode 100644 index 000000000..6fc57e5b9 --- /dev/null +++ b/crates/sage-apps/src/bridge/ts_exports.rs @@ -0,0 +1,137 @@ +use sage_api::{ + CheckAddress, CheckAddressResponse, GetCoins, GetCoinsByIds, GetCoinsByIdsResponse, + GetCoinsResponse, GetDerivations, GetDerivationsResponse, GetKey, GetKeyResponse, + GetPendingTransactions, GetPendingTransactionsResponse, GetSecretKey, GetSecretKeyResponse, + GetSpendableCoinCount, GetSpendableCoinCountResponse, GetSyncStatus, GetSyncStatusResponse, + GetTransaction, GetTransactionResponse, GetTransactions, GetTransactionsResponse, GetVersion, + GetVersionResponse, GetXchUsdPriceResponse, TransactionResponse, +}; +use specta::TypeCollection; +use specta_typescript::{BigIntExportBehavior, Typescript}; + +use crate::{ + AppGetInfoResult, AppInstallInstallResult, AppInstallInstallUrlParams, + AppInstallInstallZipParams, AppInstallPreviewUrlParams, AppInstallPreviewZipParams, + AppPermissionsApplyPermissionsParams, AppPermissionsApplyPermissionsResult, + AppPermissionsGetReviewContextParams, AppPermissionsReviewContext, AppUpdateApplyUpdateParams, + AppUpdateApplyUpdateResult, AppUpdateGetReviewContextParams, AppUpdateReviewContext, + BeforeStopEvent, BridgeApprovalsChangedEvent, BridgePingResult, BridgeSendResult, + DonationDetails, DonationGetDetailsParams, EnvironmentGetNetworkResult, + EnvironmentThemeChangedEvent, EnvironmentThemeGetCurrentResult, FileSystemSelectFileParams, + FileSystemSelectFileResult, GrantedCapabilitiesChangeEvent, GrantedNetworkWhitelistChangeEvent, + ListedAppsChangedEvent, PendingBridgeApprovalView, PendingUpdateChangedEvent, + ReadyToStopParams, RequestCapabilityGrantParams, RequestCapabilityGrantResult, + RequestNetworkWhitelistGrantParams, RequestNetworkWhitelistGrantResult, + ResolveBridgeApprovalArgs, RuntimeAckResult, RuntimeManagerActiveTaskbarRuntimeChangedEvent, + RuntimeManagerRuntimesChangedEvent, RuntimeTargetParams, RustBridgeInvokeResult, + SageAppCapabilityDefinitionView, SageAppWalletScope, SageNetworkPermissionInfo, + SandboxStateChangedEvent, SandboxStateView, SetBeforeStopListenerParams, + SystemKillRuntimeResult, SystemWalletView, WalletListWalletsResult, WalletSendXchParams, +}; + +pub fn export_user_bridge_typescript() -> Result { + let mut types = TypeCollection::default(); + + types.register::(); + + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + + Typescript::default() + .bigint(BigIntExportBehavior::Number) + .export(&types) + .map_err(|err| format!("failed to export user bridge TS types: {err}")) +} + +pub fn export_system_bridge_typescript() -> Result { + let mut types = TypeCollection::default(); + + types.register::(); + types.register::(); + types.register::(); + types.register::(); + + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + + types.register::(); + + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + + types.register::(); + types.register::(); + + types.register::(); + types.register::(); + types.register::(); + + types.register::(); + types.register::(); + + types.register::(); + types.register::(); + + types.register::(); + types.register::(); + types.register::(); + + Typescript::default() + .bigint(BigIntExportBehavior::Number) + .export(&types) + .map_err(|err| format!("failed to export system bridge TS types: {err}")) +} diff --git a/crates/sage-apps/src/bridge/types.rs b/crates/sage-apps/src/bridge/types.rs new file mode 100644 index 000000000..f2de23021 --- /dev/null +++ b/crates/sage-apps/src/bridge/types.rs @@ -0,0 +1,179 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use specta::Type; + +use crate::{ + BridgeRegistryKind, SageAppCapabilityDefinitionView, SageNetworkWhitelistEntry, SharedSageApp, + UserBridgeCapability, WalletSendXchParams, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RustBridgeRequest { + pub bridge_version: Option, + pub id: String, + pub method: String, + pub params_json: Option, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum RustBridgeInvokeResult { + Success(RustBridgeSuccessResponse), + Error(RustBridgeErrorResponse), + Pending, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(untagged)] +pub enum RustBridgeResponse { + Success(RustBridgeSuccessResponse), + Error(RustBridgeErrorResponse), +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RustBridgeSuccessResponse { + pub bridge_version: String, + pub id: String, + pub ok: bool, + pub result_json: String, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RustBridgeErrorResponse { + pub bridge_version: String, + pub id: String, + pub ok: bool, + pub error: RustBridgeErrorPayload, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RustBridgeErrorPayload { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ResolveBridgeApprovalArgs { + pub approval_id: String, + pub approved: bool, + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RustBridgeApprovalRequest { + #[serde(flatten)] + pub body: RustBridgeApprovalBody, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum RustBridgeApprovalBody { + GetSecretKey { + fingerprint: u32, + }, + SendXch { + summary: WalletSendXchParams, + }, + CapabilityGrant { + capability: UserBridgeCapability, + definition: SageAppCapabilityDefinitionView, + }, + NetworkWhitelistGrant { + entry: SageNetworkWhitelistEntry, + + #[serde(skip_serializing_if = "Option::is_none")] + network_id: Option, + }, +} + +pub(crate) struct BridgeOrigin { + pub app: SharedSageApp, +} + +#[derive(Debug, Clone)] +pub(crate) struct PendingBridgeApproval { + pub approval_id: String, + pub app_id: String, + pub registry_kind: BridgeRegistryKind, + pub approval: RustBridgeApprovalRequest, + pub request: RustBridgeRequest, + pub created_at_ms: u64, + pub expires_at_ms: u64, + pub approved_fingerprint: Option, +} + +impl RustBridgeInvokeResult { + pub fn success(id: &str, result: &Value) -> Self { + RustBridgeInvokeResult::Success(RustBridgeSuccessResponse::new(id, result)) + } + + pub fn error(id: &str, code: &str, message: impl Into) -> Self { + RustBridgeInvokeResult::Error(RustBridgeErrorResponse::new(id, code, message)) + } + + pub fn pending() -> Self { + RustBridgeInvokeResult::Pending + } +} + +impl TryFrom for RustBridgeResponse { + type Error = String; + + fn try_from(value: RustBridgeInvokeResult) -> Result { + match value { + RustBridgeInvokeResult::Success(response) => Ok(Self::Success(response)), + RustBridgeInvokeResult::Error(response) => Ok(Self::Error(response)), + RustBridgeInvokeResult::Pending => Err("Invoke result is pending".to_string()), + } + } +} + +impl From for RustBridgeInvokeResult { + fn from(response: RustBridgeResponse) -> Self { + match response { + RustBridgeResponse::Success(response) => RustBridgeInvokeResult::Success(response), + RustBridgeResponse::Error(response) => RustBridgeInvokeResult::Error(response), + } + } +} + +impl RustBridgeResponse { + pub fn success(id: &str, result: &Value) -> Self { + Self::Success(RustBridgeSuccessResponse::new(id, result)) + } + + pub fn error(id: &str, code: &str, message: impl Into) -> Self { + RustBridgeResponse::Error(RustBridgeErrorResponse::new(id, code, message)) + } +} + +impl RustBridgeSuccessResponse { + pub(crate) fn new(id: &str, result: &Value) -> Self { + Self { + bridge_version: "v1".into(), + id: id.into(), + ok: true, + result_json: serde_json::to_string(result).unwrap_or_else(|_| "null".to_string()), + } + } +} + +impl RustBridgeErrorResponse { + pub(crate) fn new(id: &str, code: &str, message: impl Into) -> Self { + Self { + bridge_version: "v1".into(), + id: id.into(), + ok: false, + error: RustBridgeErrorPayload { + code: code.into(), + message: message.into(), + }, + } + } +} diff --git a/crates/sage-apps/src/build.rs b/crates/sage-apps/src/build.rs new file mode 100644 index 000000000..07c921635 --- /dev/null +++ b/crates/sage-apps/src/build.rs @@ -0,0 +1,3 @@ +mod docs; + +pub use docs::*; diff --git a/crates/sage-apps/src/build/docs.rs b/crates/sage-apps/src/build/docs.rs new file mode 100644 index 000000000..8131b632c --- /dev/null +++ b/crates/sage-apps/src/build/docs.rs @@ -0,0 +1,235 @@ +use std::fmt::Write; +use std::{fs, path::PathBuf}; + +use crate::{ + BridgeCapability, BridgeMethodCapability, BridgeRegistry, BridgeRegistryKind, CapabilityFlags, + SystemBridgeCapability, UserBridgeCapability, get_system_capability_definition, + get_user_capability_definition, +}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("failed to resolve workspace root") + .to_path_buf() +} + +fn write_if_changed(path: PathBuf, content: String) -> anyhow::Result<()> { + if fs::read_to_string(&path).ok().as_deref() == Some(content.as_str()) { + return Ok(()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, content)?; + Ok(()) +} + +fn bool_cell(value: bool) -> &'static str { + if value { "`true`" } else { "`false`" } +} + +fn bridge_capability_key(capability: BridgeCapability) -> &'static str { + match capability { + BridgeCapability::User(capability) => capability.key(), + BridgeCapability::System(capability) => capability.key(), + } +} + +fn method_capability_cell(capability: BridgeMethodCapability) -> String { + match capability { + BridgeMethodCapability::Ungated => "`ungated`".to_string(), + BridgeMethodCapability::Required(cap) => { + format!("`{}`", bridge_capability_key(cap)) + } + } +} + +fn push_markdown_table(out: &mut String, headers: (&str, &str), rows: &[(&str, String)]) { + let first_width = rows + .iter() + .map(|(first, _)| first.len()) + .chain([headers.0.len()]) + .max() + .unwrap_or(3) + .max(3); + let second_width = rows + .iter() + .map(|(_, second)| second.len()) + .chain([headers.1.len()]) + .max() + .unwrap_or(3) + .max(3); + + writeln!( + out, + "| {:first_width$} | {:second_width$} |", + headers.0, headers.1 + ) + .unwrap(); + writeln!( + out, + "| {} | {} |", + "-".repeat(first_width), + "-".repeat(second_width) + ) + .unwrap(); + + for (first, second) in rows { + writeln!(out, "| {first:first_width$} | {second:second_width$} |").unwrap(); + } +} + +fn finish_markdown(mut out: String) -> String { + let content_len = out.trim_end_matches('\n').len(); + out.truncate(content_len); + out.push('\n'); + out +} + +fn capability_flag_rows(flags: CapabilityFlags) -> [(&'static str, String); 5] { + [ + ( + "Requestable by app", + bool_cell(flags.requestable_by_app()).to_string(), + ), + ( + "User grantable", + bool_cell(flags.user_grantable()).to_string(), + ), + ( + "Shared with app", + bool_cell(flags.shared_with_app()).to_string(), + ), + ( + "Externally observable", + bool_cell(flags.externally_observable()).to_string(), + ), + ( + "Accesses sensitive secret", + bool_cell(flags.accesses_sensitive_secret()).to_string(), + ), + ] +} + +pub fn user_capabilities_markdown() -> String { + let mut out = String::from("# User bridge capabilities\n\n"); + + for capability in UserBridgeCapability::ALL { + let definition = get_user_capability_definition(*capability); + + writeln!(out, "## `{}`\n", definition.capability().key()).unwrap(); + writeln!(out, "**{}**\n", definition.label()).unwrap(); + writeln!(out, "{}\n", definition.description()).unwrap(); + + push_markdown_table( + &mut out, + ("Flag", "Value"), + &capability_flag_rows(definition.flags()), + ); + out.push('\n'); + } + + finish_markdown(out) +} + +pub fn system_capabilities_markdown() -> String { + let mut out = String::from("# System bridge capabilities\n\n"); + + for capability in SystemBridgeCapability::ALL { + let definition = get_system_capability_definition(*capability); + + writeln!(out, "## `{}`\n", definition.capability().key()).unwrap(); + writeln!(out, "**{}**\n", definition.label()).unwrap(); + writeln!(out, "{}\n", definition.description()).unwrap(); + + push_markdown_table( + &mut out, + ("Flag", "Value"), + &capability_flag_rows(definition.flags()), + ); + out.push('\n'); + } + + finish_markdown(out) +} + +pub(crate) fn bridge_methods_markdown(kind: BridgeRegistryKind) -> String { + let title = match kind { + BridgeRegistryKind::User => "User bridge methods", + BridgeRegistryKind::System => "System bridge methods", + }; + + let registry = BridgeRegistry::new(kind); + let mut methods = registry.iter().collect::>(); + methods.sort_by_key(|(name, _)| *name); + + let mut out = format!("# {title}\n\n"); + + for (name, method) in methods { + writeln!(out, "## `{name}`\n").unwrap(); + + push_markdown_table( + &mut out, + ("Field", "Value"), + &[("Capability", method_capability_cell(method.capability()))], + ); + out.push('\n'); + } + + finish_markdown(out) +} + +pub fn generate_docs() -> anyhow::Result<()> { + let docs = workspace_root().join("docs").join("generated"); + + write_if_changed( + docs.join("user-bridge-capabilities.md"), + user_capabilities_markdown(), + )?; + + write_if_changed( + docs.join("system-bridge-capabilities.md"), + system_capabilities_markdown(), + )?; + + write_if_changed( + docs.join("user-bridge-methods.md"), + bridge_methods_markdown(BridgeRegistryKind::User), + )?; + + write_if_changed( + docs.join("system-bridge-methods.md"), + bridge_methods_markdown(BridgeRegistryKind::System), + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{finish_markdown, push_markdown_table}; + + #[test] + fn markdown_table_matches_prettier_alignment() { + let mut output = String::new(); + push_markdown_table( + &mut output, + ("Field", "Value"), + &[("Capability", "`app_update.apply`".to_string())], + ); + + assert_eq!( + output, + "| Field | Value |\n| ---------- | ------------------ |\n| Capability | `app_update.apply` |\n" + ); + } + + #[test] + fn markdown_has_exactly_one_trailing_newline() { + assert_eq!(finish_markdown("content\n\n".to_string()), "content\n"); + } +} diff --git a/crates/sage-apps/src/capabilities.rs b/crates/sage-apps/src/capabilities.rs new file mode 100644 index 000000000..57b32f5de --- /dev/null +++ b/crates/sage-apps/src/capabilities.rs @@ -0,0 +1,7 @@ +mod definitions; +mod list; +mod types; + +pub(crate) use definitions::*; +pub(crate) use list::*; +pub(crate) use types::*; diff --git a/crates/sage-apps/src/capabilities/definitions.rs b/crates/sage-apps/src/capabilities/definitions.rs new file mode 100644 index 000000000..e5e0ad954 --- /dev/null +++ b/crates/sage-apps/src/capabilities/definitions.rs @@ -0,0 +1,352 @@ +use std::collections::BTreeMap; + +use crate::{ + CapabilityDefinition, CapabilityFlags, SystemBridgeCapability, SystemCapabilityDefinition, + UserBridgeCapability, UserCapabilityDefinition, +}; + +pub(crate) fn get_user_capability_definition( + capability: UserBridgeCapability, +) -> UserCapabilityDefinition { + match capability { + UserBridgeCapability::StoragePersistentWebview => CapabilityDefinition::new( + capability, + "Persistent storage", + "Allows the app to store data on this device between sessions.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::BridgeSend => CapabilityDefinition::new( + capability, + "Bridge messaging", + "Allows the app to send messages through the Sage bridge. (Only for sandbox tests)", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::AppGetCapabilities => CapabilityDefinition::new( + capability, + "Read granted capabilities", + "Allows the app to read the capabilities currently visible to it.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::AppGetInfo => CapabilityDefinition::new( + capability, + "Read app information", + "Allows the app to read its Sage app identity and permission information.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::AppLifecycleReadyToStop => CapabilityDefinition::new( + capability, + "Acknowledge app shutdown", + "Allows the app to acknowledge that it is ready to stop after a lifecycle request.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::AppLifecycleSetBeforeStopListener => CapabilityDefinition::new( + capability, + "Listen before app shutdown", + "Allows the app to register a before-stop lifecycle listener.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::AppRequestCapabilityGrant => CapabilityDefinition::new( + capability, + "Request additional capability", + "Allows the app to request a capability grant after installation.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::AppRequestNetworkWhitelistGrant => CapabilityDefinition::new( + capability, + "Request network access", + "Allows the app to request access to an additional network target after installation.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::WalletGetKey => CapabilityDefinition::new( + capability, + "Read wallet key", + "Allows the app to read public information about a wallet key.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetSecretKey => CapabilityDefinition::new( + capability, + "Read wallet secret key", + "Allows the app to read wallet secrets, including the mnemonic or private key when available.", + CapabilityFlags::new(false, true, true, true, true), + ), + UserBridgeCapability::WalletSendXch => CapabilityDefinition::new( + capability, + "Send XCH", + "Allows the app to request XCH transactions from your wallet.", + CapabilityFlags::new(true, false, true, true, true), + ), + UserBridgeCapability::WalletSendXchAutoSubmit => CapabilityDefinition::new( + capability, + "Automatic XCH send", + "Allows the app to submit XCH transactions without asking for per-transaction approval.", + CapabilityFlags::new(false, false, false, true, false), + ), + UserBridgeCapability::WalletGetSyncStatus => CapabilityDefinition::new( + capability, + "Read sync status", + "Allows the app to read wallet sync status and current wallet balance summary.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetVersion => CapabilityDefinition::new( + capability, + "Read wallet version", + "Allows the app to read the current Sage wallet version.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetXchUsdPrice => CapabilityDefinition::new( + capability, + "Read XCH/USD price", + "Allows the app to read the current estimated XCH price in USD.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::WalletCheckAddress => CapabilityDefinition::new( + capability, + "Check address", + "Allows the app to validate whether an address belongs to this wallet.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetDerivations => CapabilityDefinition::new( + capability, + "Read derivations", + "Allows the app to read wallet derivation records and addresses.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetSpendableCoinCount => CapabilityDefinition::new( + capability, + "Read spendable coin count", + "Allows the app to read the number of spendable coins in the wallet.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetCoinsByIds => CapabilityDefinition::new( + capability, + "Read coins by IDs", + "Allows the app to read specific wallet coin records by coin ID.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetCoins => CapabilityDefinition::new( + capability, + "Read coins", + "Allows the app to list wallet coins.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetPendingTransactions => CapabilityDefinition::new( + capability, + "Read pending transactions", + "Allows the app to read pending wallet transactions.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetTransaction => CapabilityDefinition::new( + capability, + "Read transaction", + "Allows the app to read a wallet transaction by height.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::WalletGetTransactions => CapabilityDefinition::new( + capability, + "Read transactions", + "Allows the app to list wallet transactions.", + CapabilityFlags::new(false, false, true, true, true), + ), + UserBridgeCapability::EnvironmentThemeGetCurrent => CapabilityDefinition::new( + capability, + "Read current theme", + "Allows the app to read Sage's current theme.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::EnvironmentThemeCssVars => CapabilityDefinition::new( + capability, + "Use Sage theme CSS variables", + "Allows Sage to inject current theme CSS variables into the app runtime.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::EnvironmentThemeListenChanged => CapabilityDefinition::new( + capability, + "Observe theme changes", + "Allows the app to receive events when Sage's theme changes.", + CapabilityFlags::new(false, false, true, false, true), + ), + UserBridgeCapability::EnvironmentGetNetwork => CapabilityDefinition::new( + capability, + "Read current network", + "Allows the app to read Sage's currently active network information.", + CapabilityFlags::new(false, false, true, false, true), + ), + } +} + +pub(crate) fn get_system_capability_definition( + capability: SystemBridgeCapability, +) -> SystemCapabilityDefinition { + match capability { + SystemBridgeCapability::RuntimeManagerListRuntimes => CapabilityDefinition::new( + capability, + "List app runtimes", + "Allows the system app to inspect running Sage app runtimes.", + system_app_flags(), + ), + SystemBridgeCapability::RuntimeManagerFocusTaskbarRuntime => CapabilityDefinition::new( + capability, + "Focus taskbar app runtime", + "Allows the system app to focus running Sage taskbar app runtime.", + system_app_flags(), + ), + SystemBridgeCapability::RuntimeManagerHideRuntime => CapabilityDefinition::new( + capability, + "Hide app runtime", + "Allows the system app to hide running Sage app runtime.", + system_app_flags(), + ), + SystemBridgeCapability::RuntimeManagerKillRuntime => CapabilityDefinition::new( + capability, + "Kill app runtime", + "Allows the system app to stop running Sage app runtime.", + system_app_flags(), + ), + SystemBridgeCapability::RuntimeManagerGetActiveTaskbarRuntime => CapabilityDefinition::new( + capability, + "Get active runtime", + "Allows the system app to retrieve the currently active Sage app runtime.", + system_app_flags(), + ), + SystemBridgeCapability::RuntimeManagerListenRuntimesChanged => CapabilityDefinition::new( + capability, + "Observe runtime changes", + "Allows the system app to receive events when Sage app runtimes change.", + system_app_flags(), + ), + SystemBridgeCapability::RuntimeManagerListenActiveTaskbarRuntimeChanged => { + CapabilityDefinition::new( + capability, + "Observe active runtime changes", + "Allows the system app to receive events when the active Sage app runtime changes.", + system_app_flags(), + ) + } + SystemBridgeCapability::RuntimeManagerHideSelf => CapabilityDefinition::new( + capability, + "Hide itself", + "Allows the system app to hide its own runtime.", + system_app_flags(), + ), + SystemBridgeCapability::RuntimeManagerCloseSelf => CapabilityDefinition::new( + capability, + "Close itself", + "Allows the system app to close its own runtime.", + system_app_flags(), + ), + SystemBridgeCapability::AppUpdateRead => CapabilityDefinition::new( + capability, + "Read app update review context", + "Allows the system app to read update information for installed Sage apps.", + system_app_flags(), + ), + SystemBridgeCapability::AppUpdateApply => CapabilityDefinition::new( + capability, + "Apply app updates", + "Allows the system app to download and apply approved Sage app updates.", + system_app_flags(), + ), + SystemBridgeCapability::AppRegistryListenListedAppsChanged => CapabilityDefinition::new( + capability, + "Observe listed apps changes", + "Allows the system app to receive events when installed/listed Sage apps change.", + system_app_flags(), + ), + SystemBridgeCapability::CapabilityDefinitionsRead => CapabilityDefinition::new( + capability, + "Read capability definitions", + "Allows the system app to read Sage capability definitions.", + system_app_flags(), + ), + SystemBridgeCapability::AppPermissionsRead => CapabilityDefinition::new( + capability, + "Read app permissions", + "Allows the system app to read app permissions for review.", + system_app_flags(), + ), + SystemBridgeCapability::AppPermissionsApply => CapabilityDefinition::new( + capability, + "Apply app permissions", + "Allows the system app to apply reviewed app permission changes.", + system_app_flags(), + ), + SystemBridgeCapability::AppInstallPreview => CapabilityDefinition::new( + capability, + "Preview app installs", + "Allows the system app to preview URL and ZIP app installations.", + system_app_flags(), + ), + SystemBridgeCapability::AppInstallApply => CapabilityDefinition::new( + capability, + "Install apps", + "Allows the system app to install Sage apps after review.", + system_app_flags(), + ), + SystemBridgeCapability::FileSystemSelectFile => CapabilityDefinition::new( + capability, + "Select file", + "Allows the system app to ask the user to select a local file.", + system_app_flags(), + ), + SystemBridgeCapability::BridgeApprovalList => CapabilityDefinition::new( + capability, + "List bridge approvals", + "Allows the system app to list pending bridge approvals.", + system_app_flags(), + ), + SystemBridgeCapability::BridgeApprovalResolve => CapabilityDefinition::new( + capability, + "Resolve bridge approval", + "Allows the system app to resolve a pending bridge approval.", + system_app_flags(), + ), + SystemBridgeCapability::BridgeApprovalListenApprovalsChanged => CapabilityDefinition::new( + capability, + "Listen for bridge approval changes", + "Allows the system app to listen for changes in pending bridge approvals.", + system_app_flags(), + ), + SystemBridgeCapability::DonationGetDetails => CapabilityDefinition::new( + capability, + "Get details for donation", + "Allows the system app to retrieve details to send donation.", + system_app_flags(), + ), + SystemBridgeCapability::SandboxGetState => CapabilityDefinition::new( + capability, + "Read sandbox state", + "Allows the system app to read Sage app sandbox test state.", + system_app_flags(), + ), + SystemBridgeCapability::SandboxRerunTests => CapabilityDefinition::new( + capability, + "Re-run sandbox tests", + "Allows the system app to re-run Sage app sandbox tests.", + system_app_flags(), + ), + SystemBridgeCapability::SandboxListenStateChanged => CapabilityDefinition::new( + capability, + "Observe sandbox state changes", + "Allows the system app to receive events when sandbox test state changes.", + system_app_flags(), + ), + SystemBridgeCapability::WalletListWallets => CapabilityDefinition::new( + capability, + "List wallets", + "Allows the system app to list wallets available in Sage.", + system_app_flags(), + ), + } +} + +pub(crate) fn user_registry() -> BTreeMap { + UserBridgeCapability::ALL + .iter() + .copied() + .map(|capability| (capability, get_user_capability_definition(capability))) + .collect() +} + +fn system_app_flags() -> CapabilityFlags { + CapabilityFlags::new(false, false, true, false, true) +} diff --git a/crates/sage-apps/src/capabilities/list.rs b/crates/sage-apps/src/capabilities/list.rs new file mode 100644 index 000000000..d830f012b --- /dev/null +++ b/crates/sage-apps/src/capabilities/list.rs @@ -0,0 +1,226 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::get_user_capability_definition; + +macro_rules! define_bridge_capabilities { + ( + $visibility:vis enum $name:ident { + $( + $variant:ident => $key:expr + ),* $(,)? + } + ) => { + #[derive( + Debug, + Clone, + Copy, + Serialize, + Deserialize, + Type, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + )] + $visibility enum $name { + $( + #[serde(rename = $key)] + #[specta(rename = $key)] + $variant, + )* + } + + impl $name { + pub const ALL: &'static [Self] = &[ + $(Self::$variant),* + ]; + + pub fn key(self) -> &'static str { + match self { + $(Self::$variant => $key),* + } + } + + pub fn from_key(key: &str) -> Option { + match key { + $($key => Some(Self::$variant),)* + _ => None, + } + } + } + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BridgeCapability { + User(UserBridgeCapability), + System(SystemBridgeCapability), +} + +define_bridge_capabilities! { + pub enum UserBridgeCapability { + BridgeSend => "bridge.send", + + AppGetInfo => "app.get_info", + + AppLifecycleReadyToStop => "app.lifecycle.ready_to_stop", + AppLifecycleSetBeforeStopListener => "app.lifecycle.set_before_stop_listener", + + AppGetCapabilities => "app.get_capabilities", + AppRequestCapabilityGrant => "app.request_capability_grant", + AppRequestNetworkWhitelistGrant => "app.request_network_whitelist_grant", + + WalletGetKey => "wallet.get_key", + WalletGetSecretKey => "wallet.get_secret_key", + WalletSendXch => "wallet.send_xch", + WalletSendXchAutoSubmit => "wallet.send_xch_auto_submit", + WalletGetSyncStatus => "wallet.get_sync_status", + WalletGetVersion => "wallet.get_version", + WalletGetXchUsdPrice => "wallet.get_xch_usd_price", + WalletCheckAddress => "wallet.check_address", + WalletGetDerivations => "wallet.get_derivations", + WalletGetSpendableCoinCount => "wallet.get_spendable_coin_count", + WalletGetCoinsByIds => "wallet.get_coins_by_ids", + WalletGetCoins => "wallet.get_coins", + WalletGetPendingTransactions => "wallet.get_pending_transactions", + WalletGetTransaction => "wallet.get_transaction", + WalletGetTransactions => "wallet.get_transactions", + + EnvironmentThemeGetCurrent => "environment.theme.get_current", + EnvironmentThemeCssVars => "environment.theme.css_vars", + EnvironmentThemeListenChanged => "environment.theme.listen_changed", + EnvironmentGetNetwork => "environment.get_network", + + StoragePersistentWebview => "storage.persistent_webview", + } +} + +define_bridge_capabilities! { + pub enum SystemBridgeCapability { + RuntimeManagerListRuntimes => "runtime_manager.list_runtimes", + RuntimeManagerFocusTaskbarRuntime => "runtime_manager.focus_taskbar_runtime", + RuntimeManagerHideRuntime => "runtime_manager.hide_runtime", + RuntimeManagerKillRuntime => "runtime_manager.kill_runtime", + RuntimeManagerGetActiveTaskbarRuntime => "runtime_manager.get_active_taskbar_runtime", + RuntimeManagerListenRuntimesChanged => "runtime_manager.listen_runtimes_changed", + RuntimeManagerListenActiveTaskbarRuntimeChanged => "runtime_manager.listen_active_runtime_changed", + RuntimeManagerHideSelf => "runtime_manager.hide_self", + RuntimeManagerCloseSelf => "runtime_manager.close_self", + + CapabilityDefinitionsRead => "capability_definitions.read", + + AppPermissionsRead => "app_permissions.read", + AppPermissionsApply => "app_permissions.apply", + + AppInstallPreview => "app_install.preview", + AppInstallApply => "app_install.apply", + + AppUpdateRead => "app_update.read", + AppUpdateApply => "app_update.apply", + + AppRegistryListenListedAppsChanged => "app_registry.listen_listed_apps_changed", + + FileSystemSelectFile => "file_system.select_file", + + BridgeApprovalList => "bridge_approval.list", + BridgeApprovalResolve => "bridge_approval.resolve", + BridgeApprovalListenApprovalsChanged => "bridge_approval.listen_changed", + + DonationGetDetails => "donation.get_details", + + SandboxGetState => "sandbox.get_state", + SandboxRerunTests => "sandbox.rerun_tests", + SandboxListenStateChanged => "sandbox.listen_state_changed", + + WalletListWallets => "wallet.list_wallets", + } +} + +pub trait SharedCapabilitiesExt { + fn shared(self) -> Vec; +} + +impl SharedCapabilitiesExt for I +where + I: IntoIterator, +{ + fn shared(self) -> Vec { + self.into_iter() + .filter(|cap| { + get_user_capability_definition(*cap) + .flags() + .shared_with_app() + }) + .collect::>() + .into_iter() + .collect() + } +} + +impl From for BridgeCapability { + fn from(value: UserBridgeCapability) -> Self { + Self::User(value) + } +} + +impl From for BridgeCapability { + fn from(value: SystemBridgeCapability) -> Self { + Self::System(value) + } +} + +#[cfg(test)] +mod tests { + use crate::{SharedCapabilitiesExt, UserBridgeCapability, user_registry}; + + fn first_shared_capability() -> UserBridgeCapability { + user_registry() + .values() + .find(|definition| definition.flags().shared_with_app()) + .unwrap_or_else(|| { + panic!("test requires at least one capability with shared_with_app = true") + }) + .capability() + } + + fn first_non_shared_capability() -> UserBridgeCapability { + user_registry() + .values() + .find(|definition| !definition.flags().shared_with_app()) + .unwrap_or_else(|| { + panic!("test requires at least one capability with shared_with_app = false") + }) + .capability() + } + + #[test] + fn resolve_shared_capabilities_filters_out_non_shared_capabilities() { + let shared = first_shared_capability(); + let non_shared = first_non_shared_capability(); + + let shared_capabilities = [shared, non_shared].shared(); + + assert!( + shared_capabilities.contains(&shared), + "shared capability should remain visible to app" + ); + assert!( + !shared_capabilities.contains(&non_shared), + "non-shared capability should not be visible to app" + ); + } + + #[test] + fn resolve_shared_capabilities_preserves_ordered_unique_shared_subset() { + let shared = first_shared_capability(); + let non_shared = first_non_shared_capability(); + + let shared_capabilities = [non_shared, shared, shared].shared(); + + assert_eq!(shared_capabilities, vec![shared]); + } +} diff --git a/crates/sage-apps/src/capabilities/types.rs b/crates/sage-apps/src/capabilities/types.rs new file mode 100644 index 000000000..cc6d08bcf --- /dev/null +++ b/crates/sage-apps/src/capabilities/types.rs @@ -0,0 +1,115 @@ +use crate::{SystemBridgeCapability, UserBridgeCapability, get_user_capability_definition}; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Copy)] +pub(crate) struct CapabilityFlags { + externally_observable: bool, + accesses_sensitive_secret: bool, + requestable_by_app: bool, + user_grantable: bool, + shared_with_app: bool, +} + +impl CapabilityFlags { + pub const EMPTY: Self = Self { + externally_observable: false, + accesses_sensitive_secret: false, + requestable_by_app: false, + user_grantable: false, + shared_with_app: false, + }; + + #[allow(clippy::fn_params_excessive_bools)] + pub const fn new( + externally_observable: bool, + accesses_sensitive_secret: bool, + requestable_by_app: bool, + user_grantable: bool, + shared_with_app: bool, + ) -> Self { + Self { + externally_observable, + accesses_sensitive_secret, + requestable_by_app, + user_grantable, + shared_with_app, + } + } + + pub fn union(self, other: Self) -> Self { + Self { + externally_observable: self.externally_observable || other.externally_observable, + accesses_sensitive_secret: self.accesses_sensitive_secret + || other.accesses_sensitive_secret, + requestable_by_app: self.requestable_by_app || other.requestable_by_app, + user_grantable: self.user_grantable || other.user_grantable, + shared_with_app: self.shared_with_app || other.shared_with_app, + } + } + + pub fn from_capabilities(capabilities: &[UserBridgeCapability]) -> Self { + capabilities.iter().fold(Self::EMPTY, |flags, capability| { + let def = get_user_capability_definition(*capability); + flags.union(def.flags) + }) + } + + pub fn externally_observable(self) -> bool { + self.externally_observable + } + pub fn accesses_sensitive_secret(self) -> bool { + self.accesses_sensitive_secret + } + pub fn requestable_by_app(self) -> bool { + self.requestable_by_app + } + pub fn user_grantable(self) -> bool { + self.user_grantable + } + pub fn shared_with_app(self) -> bool { + self.shared_with_app + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct CapabilityDefinition { + capability: C, + label: &'static str, + description: &'static str, + flags: CapabilityFlags, +} + +impl CapabilityDefinition { + pub const fn new( + capability: C, + label: &'static str, + description: &'static str, + flags: CapabilityFlags, + ) -> Self { + Self { + capability, + label, + description, + flags, + } + } + + pub fn capability(&self) -> C { + self.capability + } + + pub fn label(&self) -> &'static str { + self.label + } + + pub fn description(&self) -> &'static str { + self.description + } + + pub fn flags(&self) -> CapabilityFlags { + self.flags + } +} + +pub type UserCapabilityDefinition = CapabilityDefinition; +pub type SystemCapabilityDefinition = CapabilityDefinition; diff --git a/crates/sage-apps/src/db.rs b/crates/sage-apps/src/db.rs new file mode 100644 index 000000000..ba8a6bb89 --- /dev/null +++ b/crates/sage-apps/src/db.rs @@ -0,0 +1,9 @@ +mod app; +mod connection; +mod settings; +mod storage; +mod transaction; + +pub use connection::*; + +pub(crate) use transaction::*; diff --git a/crates/sage-apps/src/db/app.rs b/crates/sage-apps/src/db/app.rs new file mode 100644 index 000000000..d7e107e59 --- /dev/null +++ b/crates/sage-apps/src/db/app.rs @@ -0,0 +1,465 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use sqlx::{Row, SqliteConnection, sqlite::SqliteRow}; + +use crate::{ + AppsDb, AppsDbTx, CorruptedInstalledSageApp, ListedSageApp, MANIFEST_FILE_NAME, SageAppCommon, + SageAppIconView, SageAppIdentity, SageAppPackageManifest, SageAppSnapshot, SageAppStorage, + SageAppUrl, SageAppWalletScope, SageGrantedPermissions, UserSageApp, UserSageAppPendingUpdate, + UserSageAppSource, parse_manifest_header_v0_from_value, unix_timestamp_ms, +}; + +impl AppsDb { + pub async fn app_exists(&self, app_id: &str) -> Result { + let row = sqlx::query( + r" + SELECT 1 + FROM sage_apps + WHERE app_id = ? + LIMIT 1 + ", + ) + .bind(app_id) + .fetch_optional(&self.pool) + .await + .with_context(|| format!("failed to check if app exists {app_id}"))?; + + Ok(row.is_some()) + } + + pub async fn get_user_app(&self, app_id: &str) -> Result> { + let mut conn = self + .pool() + .acquire() + .await + .context("failed to acquire apps db connection")? + .detach(); + + load_user_app_optional_from_conn(&mut conn, app_id).await + } + + pub async fn list_installed_apps(&self) -> Result> { + let mut conn = self + .pool() + .acquire() + .await + .context("failed to acquire apps db connection")? + .detach(); + + let app_ids = list_user_app_ids_from_conn(&mut conn).await?; + let mut listed = Vec::with_capacity(app_ids.len()); + + for app_id in app_ids { + match load_user_app_from_conn(&mut conn, &app_id).await { + Ok(app) => listed.push(ListedSageApp::User(app)), + Err(err) => { + listed.push(load_corrupted_user_app_from_conn(&mut conn, &app_id, err).await?); + } + } + } + + Ok(listed) + } +} + +impl AppsDbTx { + pub(crate) async fn load_user_app(&mut self, app_id: &str) -> Result { + load_user_app_from_conn(&mut self.conn, app_id).await + } + + pub(crate) async fn insert_user_app( + &mut self, + app: &UserSageApp, + storage_id: i64, + origin_row_id: i64, + ) -> Result<()> { + let common = app.common(); + let now = unix_timestamp_ms(); + + sqlx::query( + r" + INSERT INTO sage_apps ( + app_id, + storage_id, + origin_row_id, + app_dir, + source_json, + granted_permissions_json, + wallet_scope_json, + active_snapshot_manifest_hash, + active_snapshot_dir, + pending_update_app_url, + pending_update_manifest_hash, + pending_update_manifest_json, + created_at_ms, + updated_at_ms + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ", + ) + .bind(common.id()) + .bind(storage_id) + .bind(origin_row_id) + .bind(common.app_dir()) + .bind(serde_json::to_string(app.source())?) + .bind(serde_json::to_string(common.granted_permissions())?) + .bind(serde_json::to_string(common.wallet_scope())?) + .bind(common.active_snapshot().manifest_hash()) + .bind(common.active_snapshot().snapshot_dir()) + .bind(app.pending_update().map(|p| p.app_url().to_string())) + .bind(app.pending_update().map(|p| p.manifest_hash().to_string())) + .bind( + app.pending_update() + .map(|p| serde_json::to_string(p.manifest())) + .transpose()?, + ) + .bind(now) + .bind(now) + .execute(&mut self.conn) + .await + .with_context(|| format!("failed to insert app {}", common.id()))?; + + Ok(()) + } + + pub(crate) async fn persist_user_app(&mut self, app: &UserSageApp) -> Result<()> { + let common = app.common(); + let now = unix_timestamp_ms(); + + sqlx::query( + r" + UPDATE sage_app_origins + SET may_contain_secrets = ?, updated_at_ms = ? + WHERE id = ( + SELECT origin_row_id + FROM sage_apps + WHERE app_id = ? + ) + ", + ) + .bind(i32::from( + common.origin_webview_storage_may_contain_secrets(), + )) + .bind(now) + .bind(common.id()) + .execute(&mut self.conn) + .await + .with_context(|| format!("failed to persist origin taint for app {}", common.id()))?; + + sqlx::query( + r" + UPDATE sage_apps + SET + app_dir = ?, + source_json = ?, + granted_permissions_json = ?, + wallet_scope_json = ?, + active_snapshot_manifest_hash = ?, + active_snapshot_dir = ?, + pending_update_app_url = ?, + pending_update_manifest_hash = ?, + pending_update_manifest_json = ?, + updated_at_ms = ? + WHERE app_id = ? + ", + ) + .bind(common.app_dir()) + .bind(serde_json::to_string(app.source())?) + .bind(serde_json::to_string(common.granted_permissions())?) + .bind(serde_json::to_string(common.wallet_scope())?) + .bind(common.active_snapshot().manifest_hash()) + .bind(common.active_snapshot().snapshot_dir()) + .bind(app.pending_update().map(|p| p.app_url().to_string())) + .bind(app.pending_update().map(|p| p.manifest_hash().to_string())) + .bind( + app.pending_update() + .map(|p| serde_json::to_string(p.manifest())) + .transpose()?, + ) + .bind(now) + .bind(common.id()) + .execute(&mut self.conn) + .await + .with_context(|| format!("failed to persist app {}", common.id()))?; + + Ok(()) + } + + pub(crate) async fn delete_user_app(&mut self, app_id: &str) -> anyhow::Result<()> { + sqlx::query("DELETE FROM sage_apps WHERE app_id = ?") + .bind(app_id) + .execute(&mut self.conn) + .await + .with_context(|| format!("failed to unregister app {app_id}"))?; + Ok(()) + } + + pub(crate) async fn update_app_assignment( + &mut self, + app_id: &str, + storage_id: i64, + origin_row_id: i64, + ) -> Result<()> { + let now = unix_timestamp_ms(); + + sqlx::query( + r" + UPDATE sage_apps + SET + storage_id = ?, + origin_row_id = ?, + updated_at_ms = ? + WHERE app_id = ? + ", + ) + .bind(storage_id) + .bind(origin_row_id) + .bind(now) + .bind(app_id) + .execute(&mut self.conn) + .await + .with_context(|| format!("failed to update app assignment {app_id}"))?; + + Ok(()) + } + + pub(crate) async fn register_origin( + &mut self, + origin_id: &str, + storage_id: i64, + ) -> Result { + let now = unix_timestamp_ms(); + + let result = sqlx::query( + r" + INSERT INTO sage_app_origins ( + origin_id, + storage_id, + may_contain_secrets, + created_at_ms, + updated_at_ms + ) + VALUES (?, ?, 0, ?, ?) + ", + ) + .bind(origin_id) + .bind(storage_id) + .bind(now) + .bind(now) + .execute(&mut self.conn) + .await + .with_context(|| format!("failed to insert origin {origin_id}"))?; + + Ok(result.last_insert_rowid()) + } +} + +async fn list_user_app_ids_from_conn(conn: &mut SqliteConnection) -> Result> { + let rows = sqlx::query( + r" + SELECT app_id + FROM sage_apps + ORDER BY app_id ASC + ", + ) + .fetch_all(conn) + .await + .context("failed to list installed app ids")?; + + rows.into_iter() + .map(|row| row.try_get("app_id")) + .collect::, _>>() + .context("failed to read installed app ids") +} + +async fn load_user_app_optional_from_conn( + conn: &mut SqliteConnection, + app_id: &str, +) -> Result> { + let row = sqlx::query(load_user_app_sql()) + .bind(app_id) + .fetch_optional(conn) + .await + .with_context(|| format!("failed to load app {app_id}"))?; + + row.as_ref().map(row_to_user_app).transpose() +} + +async fn load_user_app_from_conn(conn: &mut SqliteConnection, app_id: &str) -> Result { + let row = sqlx::query(load_user_app_sql()) + .bind(app_id) + .fetch_one(conn) + .await + .with_context(|| format!("failed to load app {app_id}"))?; + + row_to_user_app(&row) +} + +fn load_user_app_sql() -> &'static str { + r" + SELECT + apps.app_id, + apps.app_dir, + apps.source_json, + apps.granted_permissions_json, + apps.wallet_scope_json, + apps.active_snapshot_manifest_hash, + apps.active_snapshot_dir, + apps.pending_update_app_url, + apps.pending_update_manifest_hash, + apps.pending_update_manifest_json, + origins.origin_id, + origins.may_contain_secrets, + storages.storage_json + FROM sage_apps apps + INNER JOIN sage_app_origins origins + ON origins.id = apps.origin_row_id + INNER JOIN sage_app_storages storages + ON storages.id = apps.storage_id + WHERE apps.app_id = ? + " +} + +fn row_to_user_app(row: &SqliteRow) -> Result { + let app_id: String = row.try_get("app_id")?; + let app_dir: String = row.try_get("app_dir")?; + let origin_id: String = row.try_get("origin_id")?; + let may_contain_secrets: i64 = row.try_get("may_contain_secrets")?; + + let storage: SageAppStorage = serde_json::from_str(&row.try_get::("storage_json")?) + .context("failed to deserialize app storage")?; + + let source: UserSageAppSource = serde_json::from_str(&row.try_get::("source_json")?) + .context("failed to deserialize app source")?; + + let granted_permissions: SageGrantedPermissions = + serde_json::from_str(&row.try_get::("granted_permissions_json")?) + .context("failed to deserialize granted permissions")?; + + let wallet_scope: SageAppWalletScope = + serde_json::from_str(&row.try_get::("wallet_scope_json")?) + .context("failed to deserialize wallet scope")?; + + let active_snapshot = snapshot_from_row(row)?; + let pending_update = pending_update_from_row(row)?; + + let common = SageAppCommon::from_persisted_parts( + SageAppIdentity::new(app_id, origin_id, app_dir)?, + granted_permissions, + storage, + may_contain_secrets != 0, + active_snapshot, + wallet_scope, + )?; + + Ok(UserSageApp::load_persisted(common, source, pending_update)) +} + +async fn load_corrupted_user_app_from_conn( + conn: &mut SqliteConnection, + app_id: &str, + error: anyhow::Error, +) -> Result { + let row = sqlx::query( + r" + SELECT + app_id, + app_dir, + source_json, + active_snapshot_manifest_hash, + active_snapshot_dir + FROM sage_apps + WHERE app_id = ? + ", + ) + .bind(app_id) + .fetch_one(conn) + .await + .with_context(|| format!("failed to load corrupted app fallback {app_id}"))?; + + let app_id: String = row.try_get("app_id")?; + + let app_dir: String = row + .try_get::, _>("app_dir")? + .unwrap_or_default(); + + let source = row + .try_get::, _>("source_json")? + .and_then(|json| serde_json::from_str::(&json).ok()); + + let snapshot_dir = row.try_get::, _>("active_snapshot_dir")?; + + let manifest_header = snapshot_dir + .as_deref() + .and_then(|dir| { + let path = Path::new(dir).join(MANIFEST_FILE_NAME); + std::fs::read_to_string(path).ok() + }) + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|manifest| parse_manifest_header_v0_from_value(manifest).ok()); + + let icon = manifest_header + .as_ref() + .and_then(|header| header.icon.as_deref()) + .and_then(|icon_path| { + snapshot_dir + .as_deref() + .map(|dir| Path::new(dir).join(icon_path)) + }) + .and_then(|path| SageAppIconView::from_file_path(&path)); + + Ok(ListedSageApp::Corrupted( + CorruptedInstalledSageApp::new(app_id, app_dir, error.to_string()) + .with_manifest_header(manifest_header) + .with_source(source) + .with_icon(icon), + )) +} + +fn read_snapshot_manifest(snapshot_dir: &str) -> Result { + let manifest_path = Path::new(snapshot_dir).join(MANIFEST_FILE_NAME); + + let text = std::fs::read_to_string(&manifest_path).with_context(|| { + format!( + "failed to read snapshot manifest {}", + manifest_path.display() + ) + })?; + + serde_json::from_str(&text).with_context(|| { + format!( + "failed to parse snapshot manifest {}", + manifest_path.display() + ) + }) +} + +fn snapshot_from_row(row: &SqliteRow) -> Result { + let manifest_hash: String = row.try_get("active_snapshot_manifest_hash")?; + let snapshot_dir: String = row.try_get("active_snapshot_dir")?; + let manifest = read_snapshot_manifest(&snapshot_dir)?; + + SageAppSnapshot::new(manifest_hash, snapshot_dir, manifest) +} + +fn pending_update_from_row(row: &SqliteRow) -> Result> { + let Some(app_url) = row.try_get::, _>("pending_update_app_url")? else { + return Ok(None); + }; + + let manifest_hash: String = row + .try_get::, _>("pending_update_manifest_hash")? + .context("pending update app url exists without manifest hash")?; + + let manifest_json: String = row + .try_get::, _>("pending_update_manifest_json")? + .context("pending update app url exists without manifest")?; + + Ok(Some(UserSageAppPendingUpdate::new( + SageAppUrl::parse(&app_url)?, + manifest_hash, + serde_json::from_str(&manifest_json) + .context("failed to deserialize pending update manifest")?, + ))) +} diff --git a/crates/sage-apps/src/db/connection.rs b/crates/sage-apps/src/db/connection.rs new file mode 100644 index 000000000..2e42c6a4d --- /dev/null +++ b/crates/sage-apps/src/db/connection.rs @@ -0,0 +1,58 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use sqlx::{ + SqlitePool, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, +}; + +const DB_FILE: &str = "sage-apps.sqlite3"; + +static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("src/db/migrations"); + +#[derive(Debug, Clone)] +pub struct AppsDb { + pub(super) pool: SqlitePool, +} + +impl AppsDb { + pub async fn initialize(base_path: &Path) -> Result { + fs::create_dir_all(base_path).with_context(|| { + format!("failed to create apps db directory {}", base_path.display()) + })?; + + let db_path = database_path(base_path); + + let options = SqliteConnectOptions::new() + .filename(&db_path) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .foreign_keys(true); + + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(options) + .await + .with_context(|| format!("failed to open apps database {}", db_path.display()))?; + + MIGRATOR.run(&pool).await.with_context(|| { + format!( + "failed to run apps database migrations {}", + db_path.display() + ) + })?; + + Ok(Self { pool }) + } + + pub fn pool(&self) -> &SqlitePool { + &self.pool + } +} + +fn database_path(base_path: &Path) -> PathBuf { + base_path.join(DB_FILE) +} diff --git a/crates/sage-apps/src/db/migrations/0001_app_resource_registry.sql b/crates/sage-apps/src/db/migrations/0001_app_resource_registry.sql new file mode 100644 index 000000000..db5f2dcd9 --- /dev/null +++ b/crates/sage-apps/src/db/migrations/0001_app_resource_registry.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS sage_app_storages ( + id INTEGER PRIMARY KEY, + storage_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sage_app_origins ( + id INTEGER PRIMARY KEY, + origin_id TEXT NOT NULL, + storage_id INTEGER NOT NULL, + may_contain_secrets INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + + FOREIGN KEY(storage_id) REFERENCES sage_app_storages(id) +); + +CREATE TABLE IF NOT EXISTS sage_apps ( + app_id TEXT PRIMARY KEY, + storage_id INTEGER NOT NULL, + origin_row_id INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + + FOREIGN KEY(storage_id) REFERENCES sage_app_storages(id), + FOREIGN KEY(origin_row_id) REFERENCES sage_app_origins(id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sage_app_origins_origin_id + ON sage_app_origins(origin_id); + +CREATE INDEX IF NOT EXISTS idx_sage_app_origins_storage_id + ON sage_app_origins(storage_id); diff --git a/crates/sage-apps/src/db/migrations/0002_store_installed_app_state.sql b/crates/sage-apps/src/db/migrations/0002_store_installed_app_state.sql new file mode 100644 index 000000000..bfa51f352 --- /dev/null +++ b/crates/sage-apps/src/db/migrations/0002_store_installed_app_state.sql @@ -0,0 +1,6 @@ +ALTER TABLE sage_apps ADD COLUMN app_dir TEXT; +ALTER TABLE sage_apps ADD COLUMN source_json TEXT; +ALTER TABLE sage_apps ADD COLUMN granted_permissions_json TEXT; +ALTER TABLE sage_apps ADD COLUMN active_snapshot_json TEXT; +ALTER TABLE sage_apps ADD COLUMN wallet_scope_json TEXT; +ALTER TABLE sage_apps ADD COLUMN pending_update_json TEXT; diff --git a/crates/sage-apps/src/db/migrations/0003_split_snapshot_and_pending_update.sql b/crates/sage-apps/src/db/migrations/0003_split_snapshot_and_pending_update.sql new file mode 100644 index 000000000..c80f0a21e --- /dev/null +++ b/crates/sage-apps/src/db/migrations/0003_split_snapshot_and_pending_update.sql @@ -0,0 +1,9 @@ +ALTER TABLE sage_apps ADD COLUMN active_snapshot_manifest_hash TEXT; +ALTER TABLE sage_apps ADD COLUMN active_snapshot_dir TEXT; + +ALTER TABLE sage_apps ADD COLUMN pending_update_app_url TEXT; +ALTER TABLE sage_apps ADD COLUMN pending_update_manifest_hash TEXT; +ALTER TABLE sage_apps ADD COLUMN pending_update_manifest_json TEXT; + +ALTER TABLE sage_apps DROP COLUMN active_snapshot_json; +ALTER TABLE sage_apps DROP COLUMN pending_update_json; diff --git a/crates/sage-apps/src/db/migrations/0004_app_settings.sql b/crates/sage-apps/src/db/migrations/0004_app_settings.sql new file mode 100644 index 000000000..088500c4c --- /dev/null +++ b/crates/sage-apps/src/db/migrations/0004_app_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS sage_app_settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +); diff --git a/crates/sage-apps/src/db/settings.rs b/crates/sage-apps/src/db/settings.rs new file mode 100644 index 000000000..3cf8d6078 --- /dev/null +++ b/crates/sage-apps/src/db/settings.rs @@ -0,0 +1,51 @@ +use anyhow::{Context, Result}; +use sqlx::Row; + +use crate::{AppsDb, unix_timestamp_ms}; + +const AUTO_UPDATE_ENABLED_KEY: &str = "auto_update_enabled"; + +impl AppsDb { + pub async fn get_auto_update_enabled(&self) -> Result { + let row = sqlx::query( + r" + SELECT value_json + FROM sage_app_settings + WHERE key = ? + ", + ) + .bind(AUTO_UPDATE_ENABLED_KEY) + .fetch_optional(&self.pool) + .await + .context("failed to read auto update setting")?; + + let Some(row) = row else { + return Ok(false); + }; + + serde_json::from_str(&row.try_get::("value_json")?) + .context("failed to deserialize auto update setting") + } + + pub async fn set_auto_update_enabled(&self, enabled: bool) -> Result { + let now = unix_timestamp_ms(); + + sqlx::query( + r" + INSERT INTO sage_app_settings (key, value_json, updated_at_ms) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + updated_at_ms = excluded.updated_at_ms + ", + ) + .bind(AUTO_UPDATE_ENABLED_KEY) + .bind(serde_json::to_string(&enabled)?) + .bind(now) + .execute(&self.pool) + .await + .context("failed to persist auto update setting")?; + + Ok(enabled) + } +} diff --git a/crates/sage-apps/src/db/storage.rs b/crates/sage-apps/src/db/storage.rs new file mode 100644 index 000000000..d3f889116 --- /dev/null +++ b/crates/sage-apps/src/db/storage.rs @@ -0,0 +1,204 @@ +use anyhow::{Context, Result}; +use sqlx::Row; + +use crate::{AppsDb, SageAppStorage, unix_timestamp_ms}; + +#[derive(Debug, Clone)] +pub struct AbandonedStorageCleanupTarget { + pub storage_id: i64, + pub storage: SageAppStorage, + pub origin_ids: Vec, +} + +impl AppsDb { + pub async fn register_storage(&self, storage: &SageAppStorage) -> Result { + let storage_json = serde_json::to_string(storage).context("failed to serialize storage")?; + let now = unix_timestamp_ms(); + + let result = sqlx::query( + r" + INSERT INTO sage_app_storages ( + storage_json, + created_at_ms, + updated_at_ms + ) + VALUES (?, ?, ?) + ", + ) + .bind(storage_json) + .bind(now) + .bind(now) + .execute(&self.pool) + .await + .context("failed to insert storage")?; + + Ok(result.last_insert_rowid()) + } + + pub async fn get_app_storage(&self, app_id: &str) -> Result> { + let row = sqlx::query( + r" + SELECT storage_json + FROM sage_apps apps + INNER JOIN sage_app_storages storages + ON storages.id = apps.storage_id + WHERE apps.app_id = ? + ", + ) + .bind(app_id) + .fetch_optional(&self.pool) + .await + .with_context(|| format!("failed to get storage for app {app_id}"))?; + + let Some(row) = row else { + return Ok(None); + }; + + let storage_json: String = row.try_get("storage_json")?; + + serde_json::from_str::(&storage_json) + .with_context(|| format!("failed to deserialize storage for app {app_id}")) + .map(Some) + } + + pub async fn list_abandoned_storage_cleanup_targets( + &self, + ) -> Result> { + let rows = sqlx::query( + r" + SELECT + storages.id AS storage_id, + storages.storage_json AS storage_json, + origins.origin_id AS origin_id + FROM sage_app_storages storages + LEFT JOIN sage_app_origins origins + ON origins.storage_id = storages.id + WHERE storages.id NOT IN ( + SELECT storage_id FROM sage_apps + ) + ORDER BY storages.id ASC, origins.id ASC + ", + ) + .fetch_all(&self.pool) + .await + .context("failed to list abandoned storage cleanup targets")?; + + let mut grouped = std::collections::BTreeMap::)>::new(); + + for row in rows { + let storage_id: i64 = row.try_get("storage_id")?; + let storage_json: String = row.try_get("storage_json")?; + + let storage = serde_json::from_str::(&storage_json) + .with_context(|| format!("failed to deserialize abandoned storage {storage_id}"))?; + + let origin_id: Option = row.try_get("origin_id")?; + + let (_, origin_ids) = grouped + .entry(storage_id) + .or_insert_with(|| (storage, Vec::new())); + + if let Some(origin_id) = origin_id { + origin_ids.push(origin_id); + } + } + + Ok(grouped + .into_iter() + .map( + |(storage_id, (storage, origin_ids))| AbandonedStorageCleanupTarget { + storage_id, + storage, + origin_ids, + }, + ) + .collect()) + } + + pub async fn delete_origins_for_abandoned_storage(&self, storage_id: i64) -> Result { + let result = sqlx::query( + r" + DELETE FROM sage_app_origins + WHERE storage_id = ? + AND storage_id NOT IN ( + SELECT storage_id FROM sage_apps + ) + ", + ) + .bind(storage_id) + .execute(&self.pool) + .await + .with_context(|| { + format!("failed to delete abandoned origins for storage row {storage_id}") + })?; + + Ok(result.rows_affected()) + } + + pub async fn delete_abandoned_storage(&self, storage_id: i64) -> Result<()> { + sqlx::query( + r" + DELETE FROM sage_app_storages + WHERE id = ? + AND id NOT IN ( + SELECT storage_id FROM sage_apps + ) + ", + ) + .bind(storage_id) + .execute(&self.pool) + .await + .with_context(|| format!("failed to delete abandoned storage row {storage_id}"))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[tokio::test] + async fn abandoned_unmanaged_storage_is_returned_for_origin_cleanup() { + let dir = tempdir().unwrap(); + let db = AppsDb::initialize(dir.path()).await.unwrap(); + + let storage_id = db + .register_storage(&SageAppStorage::Unmanaged) + .await + .unwrap(); + + let mut tx = db.begin_immediate().await.unwrap(); + + tx.register_origin("origin-1", storage_id).await.unwrap(); + + tx.commit().await.unwrap(); + + let targets = db.list_abandoned_storage_cleanup_targets().await.unwrap(); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].storage_id, storage_id); + assert_eq!(targets[0].storage, SageAppStorage::Unmanaged); + assert_eq!(targets[0].origin_ids, vec!["origin-1".to_string()]); + } + + #[tokio::test] + async fn abandoned_unmanaged_storage_without_origins_is_returned() { + let dir = tempdir().unwrap(); + let db = AppsDb::initialize(dir.path()).await.unwrap(); + + let storage_id = db + .register_storage(&SageAppStorage::Unmanaged) + .await + .unwrap(); + + let targets = db.list_abandoned_storage_cleanup_targets().await.unwrap(); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].storage_id, storage_id); + assert_eq!(targets[0].storage, SageAppStorage::Unmanaged); + assert!(targets[0].origin_ids.is_empty()); + } +} diff --git a/crates/sage-apps/src/db/transaction.rs b/crates/sage-apps/src/db/transaction.rs new file mode 100644 index 000000000..1680366c8 --- /dev/null +++ b/crates/sage-apps/src/db/transaction.rs @@ -0,0 +1,59 @@ +use anyhow::{Context, Result}; +use sqlx::SqliteConnection; + +use crate::AppsDb; + +pub(crate) struct AppsDbTx { + pub(super) conn: SqliteConnection, + finished: bool, +} + +impl AppsDb { + pub(crate) async fn begin_immediate(&self) -> Result { + let mut conn = self + .pool() + .acquire() + .await + .context("failed to acquire apps db connection")? + .detach(); + + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut conn) + .await + .context("failed to begin immediate apps db transaction")?; + + Ok(AppsDbTx { + conn, + finished: false, + }) + } +} + +impl AppsDbTx { + pub(crate) async fn commit(mut self) -> Result<()> { + sqlx::query("COMMIT") + .execute(&mut self.conn) + .await + .context("failed to commit apps db transaction")?; + + self.finished = true; + Ok(()) + } + + pub(crate) async fn rollback(&mut self) { + if self.finished { + return; + } + + let _ = sqlx::query("ROLLBACK").execute(&mut self.conn).await; + self.finished = true; + } +} + +impl Drop for AppsDbTx { + fn drop(&mut self) { + if !self.finished { + tracing::error!("AppsDbTx dropped without commit or rollback"); + } + } +} diff --git a/crates/sage-apps/src/environment.rs b/crates/sage-apps/src/environment.rs new file mode 100644 index 000000000..ea6a9b204 --- /dev/null +++ b/crates/sage-apps/src/environment.rs @@ -0,0 +1,3 @@ +mod commands; + +pub use commands::*; diff --git a/crates/sage-apps/src/environment/commands.rs b/crates/sage-apps/src/environment/commands.rs new file mode 100644 index 000000000..09663067e --- /dev/null +++ b/crates/sage-apps/src/environment/commands.rs @@ -0,0 +1,30 @@ +use tauri::{AppHandle, State}; + +use crate::{ + AppsHostState, EnvironmentThemeChangedEvent, EnvironmentThemeView, + emit_user_runtime_event_to_listeners, +}; + +#[tauri::command] +#[specta::specta] +pub async fn apps_set_environment_theme( + app_handle: AppHandle, + apps_state: State<'_, AppsHostState>, + theme: EnvironmentThemeView, +) -> Result<(), String> { + { + let mut current = apps_state.environment.theme.current.lock().await; + *current = Some(theme.clone()); + } + + emit_user_runtime_event_to_listeners( + &app_handle, + &apps_state, + EnvironmentThemeChangedEvent { + theme: theme.clone(), + }, + ) + .await; + + Ok(()) +} diff --git a/crates/sage-apps/src/host.rs b/crates/sage-apps/src/host.rs new file mode 100644 index 000000000..f08287833 --- /dev/null +++ b/crates/sage-apps/src/host.rs @@ -0,0 +1,136 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::sync::Arc; + +use parking_lot::RwLock; +use sage::Sage; +use sage_api::ErrorKind; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tokio::sync::Mutex; + +use crate::{AppRuntimeState, AppsDb, BridgeState, EnvironmentThemeView, SandboxStateStore}; + +pub type AppState = Arc>; + +#[derive(Debug)] +pub struct AppsHostState { + pub app_operation_locks: RwLock>>>, + pub app_update_locks: RwLock>, + pub runtime: AppRuntimeState, + pub bridge: BridgeState, + pub sandbox: SandboxStateStore, + pub environment: AppsEnvironmentState, + pub db: AppsDb, +} + +impl AppsHostState { + pub fn new(db: AppsDb) -> Self { + Self { + app_operation_locks: RwLock::default(), + app_update_locks: RwLock::default(), + runtime: AppRuntimeState::default(), + bridge: BridgeState::default(), + sandbox: SandboxStateStore::default(), + environment: AppsEnvironmentState::default(), + db, + } + } + + pub fn operation_lock_for_app(&self, app_id: &str) -> Arc> { + if let Some(lock) = self.app_operation_locks.read().get(app_id) { + return lock.clone(); + } + + self.app_operation_locks + .write() + .entry(app_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + pub fn try_begin_app_update(&self, app_id: &str) -> anyhow::Result> { + let mut locks = self.app_update_locks.write(); + if !locks.insert(app_id.to_string()) { + anyhow::bail!("app update already in progress for {app_id}"); + } + Ok(AppUpdateLockGuard { + state: self, + app_id: app_id.to_string(), + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SageAppsError { + pub kind: ErrorKind, + pub reason: String, +} + +#[derive(Debug, Default)] +pub struct AppsEnvironmentState { + pub theme: AppsEnvironmentThemeState, +} + +#[derive(Debug, Default)] +pub struct AppsEnvironmentThemeState { + pub current: Mutex>, +} + +#[derive(Debug)] +pub struct AppUpdateLockGuard<'a> { + state: &'a AppsHostState, + app_id: String, +} + +impl From for SageAppsError { + fn from(error: sage::Error) -> Self { + Self { + kind: error.kind(), + reason: error.to_string(), + } + } +} + +impl From for SageAppsError { + fn from(error: reqwest::Error) -> Self { + Self { + kind: ErrorKind::Internal, + reason: error.to_string(), + } + } +} + +impl From for SageAppsError { + fn from(error: std::io::Error) -> Self { + Self { + kind: ErrorKind::Internal, + reason: error.to_string(), + } + } +} + +impl From for SageAppsError { + fn from(error: anyhow::Error) -> Self { + Self { + kind: ErrorKind::Internal, + reason: error.to_string(), + } + } +} + +impl Drop for AppUpdateLockGuard<'_> { + fn drop(&mut self) { + self.state.app_update_locks.write().remove(&self.app_id); + } +} + +impl fmt::Display for SageAppsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.reason) + } +} + +impl std::error::Error for SageAppsError {} + +pub type Result = std::result::Result; diff --git a/crates/sage-apps/src/lib.rs b/crates/sage-apps/src/lib.rs new file mode 100644 index 000000000..a29d9bc4b --- /dev/null +++ b/crates/sage-apps/src/lib.rs @@ -0,0 +1,64 @@ +mod bridge; +mod build; +mod capabilities; +mod db; +mod environment; +mod host; +mod lifecycle; +mod runtime; +mod sandbox; +mod security; +mod settings; +mod storage; +mod system_apps; +mod types; +mod utils; + +// State +pub use db::AppsDb; +pub use host::AppsHostState; + +// Commands +pub use bridge::{apps_invoke_bridge, apps_invoke_system_bridge}; +pub use environment::apps_set_environment_theme; +pub use lifecycle::{ + apps_apply_app_update, apps_check_app_update, apps_clear_runtime_browsing_data, + apps_list_installed_apps, apps_uninstall_app, +}; +pub use runtime::{ + apps_clear_active_taskbar_runtime, apps_dev_reload_runtime, apps_enter_workspace, + apps_focus_taskbar_runtime, apps_kill_taskbar_runtime, apps_leave_workspace, + apps_list_runtimes, apps_start_system_app, apps_start_user_app, +}; +pub use sandbox::{apps_get_app_launch_gate, apps_get_sandbox_state, apps_rerun_sandbox_tests}; +pub use settings::{apps_get_auto_update_enabled, apps_set_auto_update_enabled}; + +// Bridge protocol +pub use security::{handle_system_app_protocol_request, handle_user_app_protocol_request}; + +// SDK types +pub use bridge::{export_system_bridge_typescript, export_user_bridge_typescript}; + +// Operations +pub use lifecycle::{process_pending_storage_cleanup, start_background_app_update_checker}; +pub use runtime::process_sage_network_change; +pub use sandbox::ensure_initial_sandbox_run; +pub use utils::set_builtin_apps_root; + +// Docs +pub use build::generate_docs; + +// Internal +pub(crate) use bridge::*; +pub(crate) use capabilities::*; +pub(crate) use db::*; +pub(crate) use host::*; +pub(crate) use lifecycle::*; +pub(crate) use runtime::*; +pub(crate) use sandbox::*; +pub(crate) use security::*; +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "windows"))] +pub(crate) use storage::*; +pub(crate) use system_apps::*; +pub(crate) use types::*; +pub(crate) use utils::*; diff --git a/crates/sage-apps/src/lifecycle.rs b/crates/sage-apps/src/lifecycle.rs new file mode 100644 index 000000000..5c82400c6 --- /dev/null +++ b/crates/sage-apps/src/lifecycle.rs @@ -0,0 +1,22 @@ +mod install; +mod manifest; +mod mutation; +mod package; +mod registry; +mod scope; +mod snapshot; +mod storage; +mod uninstall; +mod update; + +pub use install::*; +pub use manifest::*; +pub use package::*; +pub use registry::*; +pub use snapshot::*; +pub use storage::*; +pub use uninstall::*; +pub use update::*; + +pub(crate) use mutation::*; +pub(crate) use scope::*; diff --git a/crates/sage-apps/src/lifecycle/install.rs b/crates/sage-apps/src/lifecycle/install.rs new file mode 100644 index 000000000..7965f86c5 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/install.rs @@ -0,0 +1,457 @@ +mod commands; +mod url; +mod zip; + +pub use commands::*; + +pub(crate) use zip::*; + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::Result as AnyResult; +use async_trait::async_trait; +use tauri::{AppHandle, Manager, State}; +use uuid::Uuid; + +use crate::{ + AppsHostState, RUNTIME_ID_PREFIX, SANDBOX_TEST_ID_PREFIX, SageAppCommon, SageAppIdentity, + SageAppPackageManifest, SageAppSnapshot, SageAppStorage, SageAppWalletScope, + SageGrantedPermissionsInput, UserSageApp, UserSageAppSource, allocate_new_storage, apps_root, + builtin_system_app_spec, emit_listed_apps_changed, fresh_snapshot_dir, write_snapshot_manifest, +}; + +#[async_trait] +pub trait AppInstallSource { + type PreparedArtifact: Send + Sync; + + async fn prepare(&self) -> AnyResult; + + fn manifest<'a>(&self, prepared: &'a Self::PreparedArtifact) -> &'a SageAppPackageManifest; + + fn source(&self, prepared: &Self::PreparedArtifact) -> UserSageAppSource; + + fn resolve_target( + &self, + root: &Path, + base_path: &Path, + prepared: &Self::PreparedArtifact, + ) -> AnyResult<(String, PathBuf)>; + + async fn create_snapshot( + &self, + snapshot_dir: &Path, + prepared: &Self::PreparedArtifact, + ) -> AnyResult; +} + +#[derive(Debug)] +struct ResolvedInstallTarget { + app_id: String, + app_dir: PathBuf, + origin_id: String, + storage: SageAppStorage, +} + +pub async fn install_app_from_source( + app: &AppHandle, + host_state: &State<'_, AppsHostState>, + base_path: &Path, + granted_permissions_input: SageGrantedPermissionsInput, + wallet_scope: SageAppWalletScope, + source: S, +) -> AnyResult +where + S: AppInstallSource + Send + Sync, +{ + let install_result = install_app_from_source_inner( + app, + host_state, + base_path, + granted_permissions_input, + wallet_scope, + source, + ) + .await; + + let host_state: State<'_, AppsHostState> = app.state(); + emit_listed_apps_changed(app, &host_state).await; + + install_result +} + +async fn install_app_from_source_inner( + app: &AppHandle, + host_state: &State<'_, AppsHostState>, + base_path: &Path, + granted_permissions_input: SageGrantedPermissionsInput, + wallet_scope: SageAppWalletScope, + source: S, +) -> AnyResult +where + S: AppInstallSource + Send + Sync, +{ + let root = apps_root(base_path); + fs::create_dir_all(&root)?; + + let prepared_artifact = source.prepare().await?; + + let (app_id, app_dir) = source.resolve_target(&root, base_path, &prepared_artifact)?; + ensure_installable_app_id(&app_id)?; + + if host_state.db.app_exists(&app_id).await? { + anyhow::bail!("App is already installed"); + } + + let registered_storage = allocate_new_storage(app, host_state, base_path).await?; + let origin_id = fresh_origin_id(&app_id); + + let target = ResolvedInstallTarget { + app_id: app_id.clone(), + app_dir, + origin_id: origin_id.clone(), + storage: registered_storage.storage.clone(), + }; + + let installed = materialize_installed_app( + source, + prepared_artifact, + target, + granted_permissions_input, + wallet_scope, + ) + .await?; + + let mut tx = host_state.db.begin_immediate().await?; + + let origin_row_id = tx + .register_origin(&origin_id, registered_storage.storage_id) + .await?; + + tx.insert_user_app(&installed, registered_storage.storage_id, origin_row_id) + .await?; + + let reread = tx.load_user_app(&app_id).await?; + + let expected = serde_json::to_value(&installed)?; + let actual = serde_json::to_value(&reread)?; + + if expected != actual { + tx.rollback().await; + anyhow::bail!("installed app DB round-trip mismatch"); + } + + tx.commit().await?; + + Ok(installed) +} + +async fn materialize_installed_app( + source: S, + prepared_artifact: S::PreparedArtifact, + target: ResolvedInstallTarget, + granted_permissions_input: SageGrantedPermissionsInput, + wallet_scope: SageAppWalletScope, +) -> AnyResult +where + S: AppInstallSource + Send + Sync, +{ + let manifest = source.manifest(&prepared_artifact); + + create_app_dir(&target.app_dir)?; + + let snapshot_dir = fresh_snapshot_dir(&target.app_dir); + fs::create_dir_all(&snapshot_dir)?; + + let snapshot = source + .create_snapshot(&snapshot_dir, &prepared_artifact) + .await?; + + write_snapshot_manifest(&snapshot)?; + + let granted_permissions = granted_permissions_input.resolve(manifest.permissions())?; + + let common = SageAppCommon::new( + SageAppIdentity::new( + target.app_id, + target.origin_id, + target.app_dir.to_string_lossy().to_string(), + )?, + granted_permissions, + target.storage, + snapshot, + wallet_scope, + )?; + + Ok(UserSageApp::new_installed( + common, + source.source(&prepared_artifact), + )) +} + +#[cfg(test)] +pub(crate) async fn install_app_from_source_for_test( + base_path: &Path, + granted_permissions_input: SageGrantedPermissionsInput, + source: S, +) -> AnyResult +where + S: AppInstallSource + Send + Sync, +{ + let root = apps_root(base_path); + fs::create_dir_all(&root)?; + + let prepared_artifact = source.prepare().await?; + + let (app_id, app_dir) = source.resolve_target(&root, base_path, &prepared_artifact)?; + + materialize_installed_app( + source, + prepared_artifact, + ResolvedInstallTarget { + app_id: app_id.clone(), + app_dir, + origin_id: fresh_origin_id(&app_id), + storage: SageAppStorage::Unmanaged, + }, + granted_permissions_input, + SageAppWalletScope::AllWallets, + ) + .await +} + +pub fn fresh_origin_id(app_id: &str) -> String { + format!("{}.{}", Uuid::new_v4(), app_id) +} + +/// Rejects app IDs reserved for Sage's internal apps and runtimes. +fn ensure_installable_app_id(app_id: &str) -> AnyResult<()> { + if app_id.starts_with(SANDBOX_TEST_ID_PREFIX) || app_id.starts_with(RUNTIME_ID_PREFIX) { + anyhow::bail!("app id uses a reserved prefix: {app_id}"); + } + + if builtin_system_app_spec(app_id).is_some() { + anyhow::bail!("app id collides with a builtin system app: {app_id}"); + } + + Ok(()) +} + +pub fn create_app_dir(app_dir: &Path) -> AnyResult<()> { + if app_dir.exists() { + anyhow::bail!("app directory already exists, cannot create"); + } + + fs::create_dir_all(app_dir)?; + + Ok(()) +} + +#[cfg(test)] +pub(crate) struct FakeInstallSource { + pub manifest: SageAppPackageManifest, + pub app_id: String, + pub source: UserSageAppSource, +} + +#[cfg(test)] +pub(crate) struct FakePreparedArtifact { + manifest: SageAppPackageManifest, +} + +#[async_trait] +#[cfg(test)] +impl AppInstallSource for FakeInstallSource { + type PreparedArtifact = FakePreparedArtifact; + + async fn prepare(&self) -> AnyResult { + Ok(FakePreparedArtifact { + manifest: self.manifest.clone(), + }) + } + + fn manifest<'a>(&self, prepared: &'a Self::PreparedArtifact) -> &'a SageAppPackageManifest { + &prepared.manifest + } + + fn source(&self, _prepared: &Self::PreparedArtifact) -> UserSageAppSource { + self.source.clone() + } + + fn resolve_target( + &self, + root: &Path, + _base_path: &Path, + _prepared: &Self::PreparedArtifact, + ) -> AnyResult<(String, PathBuf)> { + let app_dir = root.join(&self.app_id); + Ok((self.app_id.clone(), app_dir)) + } + + async fn create_snapshot( + &self, + snapshot_dir: &Path, + prepared: &Self::PreparedArtifact, + ) -> AnyResult { + fs::create_dir_all(snapshot_dir)?; + fs::write(snapshot_dir.join("index.html"), "x")?; + + Ok(SageAppSnapshot::new( + "fake-hash", + snapshot_dir.to_string_lossy().to_string(), + prepared.manifest.clone(), + )?) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use tempfile::tempdir; + + use super::*; + use crate::{ + SageAppManifestFile, SageAppPackageManifestParts, SageGrantedPermissionsInput, + SageNetworkWhitelistEntry, SageRequestedCapabilities, SageRequestedNetworkPermissions, + SageRequestedPermissions, UserBridgeCapability, + }; + + fn sample_manifest() -> SageAppPackageManifest { + let (manifest_version, sage_version) = SageAppPackageManifestParts::v0_defaults(); + + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: "Test App".into(), + icon: None, + sage_version, + version: "1.0.0".into(), + permissions: SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [SageNetworkWhitelistEntry::new_unchecked( + "https", + "api.example.com", + )], + [], + [], + ) + .unwrap(), + SageRequestedCapabilities::new( + [UserBridgeCapability::StoragePersistentWebview], + [UserBridgeCapability::WalletSendXch], + ), + ) + .unwrap(), + files: vec![ + SageAppManifestFile::new("index.html".to_string(), "a".repeat(64), 123).unwrap(), + ], + entry: Some("index.html".into()), + author: None, + donation: None, + }) + .unwrap() + } + + #[test] + fn fresh_origin_id_uses_uuid_host_prefix_and_app_id_suffix() { + let origin = fresh_origin_id("url-abc123"); + + assert!(origin.ends_with(".url-abc123")); + assert_ne!(origin, "url-abc123"); + + let prefix = origin.strip_suffix(".url-abc123").unwrap(); + Uuid::parse_str(prefix).unwrap(); + } + + #[test] + fn ensure_installable_app_id_rejects_reserved_ids() { + for app_id in [ + "__sage_test_storage_isolation_persistent", + "__sage_runtime_origin_cleanup", + "task-manager", + "app-update", + "bridge-approval", + ] { + assert!( + ensure_installable_app_id(app_id).is_err(), + "expected app id {app_id:?} to be rejected" + ); + } + } + + #[test] + fn ensure_installable_app_id_accepts_generated_ids() { + for app_id in [ + "my-app-8c8532e1-14d8-4d5f-b652-4ff29fc35a19", + "url-my-app-0123456789abcdef", + "task-manager-8c8532e1-14d8-4d5f-b652-4ff29fc35a19", + ] { + ensure_installable_app_id(app_id) + .unwrap_or_else(|err| panic!("expected app id {app_id:?} to be accepted: {err}")); + } + } + + #[tokio::test] + async fn materialize_installed_app_builds_installed_app() { + let dir = tempdir().unwrap(); + let app_id = "fake-app".to_string(); + let app_dir = apps_root(dir.path()).join(&app_id); + let origin_id = fresh_origin_id(&app_id); + + let granted = SageGrantedPermissionsInput::new( + [UserBridgeCapability::StoragePersistentWebview], + [SageNetworkWhitelistEntry::new_unchecked( + "https", + "api.example.com", + )], + BTreeMap::new(), + ); + + let source = FakeInstallSource { + manifest: sample_manifest(), + app_id: app_id.clone(), + source: UserSageAppSource::Zip, + }; + + let prepared_artifact = source.prepare().await.unwrap(); + + let installed = materialize_installed_app( + source, + prepared_artifact, + ResolvedInstallTarget { + app_id: app_id.clone(), + app_dir, + origin_id: origin_id.clone(), + storage: SageAppStorage::Unmanaged, + }, + granted, + SageAppWalletScope::AllWallets, + ) + .await + .unwrap(); + + let common = installed.common(); + + assert_eq!(common.id(), app_id); + assert_eq!(common.origin_id(), origin_id); + assert_eq!(common.name(), "Test App"); + assert_eq!(common.entry_file(), "index.html"); + assert_eq!(common.icon_file(), None); + assert_eq!(common.storage(), &SageAppStorage::Unmanaged); + assert_eq!(installed.source(), &UserSageAppSource::Zip); + } + + #[test] + fn create_app_dir_rejects_existing_directory() { + let dir = tempdir().unwrap(); + let app_dir = dir.path().join("fake-app"); + + fs::create_dir_all(&app_dir).unwrap(); + + let err = create_app_dir(&app_dir).unwrap_err(); + + assert!(err.to_string().contains("app directory already exists")); + } +} diff --git a/crates/sage-apps/src/lifecycle/install/commands.rs b/crates/sage-apps/src/lifecycle/install/commands.rs new file mode 100644 index 000000000..7902abafe --- /dev/null +++ b/crates/sage-apps/src/lifecycle/install/commands.rs @@ -0,0 +1,33 @@ +use std::{fs, io}; + +use tauri::{State, command}; + +use crate::{ + AppState, AppsHostState, ListedSageAppView, Result, apps_root, list_installed_apps_internal, +}; + +#[command] +#[specta::specta] +pub async fn apps_list_installed_apps( + state: State<'_, AppState>, + apps_state: State<'_, AppsHostState>, +) -> Result> { + let base_path = { + let state = state.lock().await; + state.path.clone() + }; + + let root = apps_root(&base_path); + + fs::create_dir_all(&root).map_err(|err| { + io::Error::other(format!( + "failed to create apps directory {}: {err}", + root.display() + )) + })?; + + list_installed_apps_internal(&apps_state.db) + .await + .map(|apps| apps.iter().map(Into::into).collect()) + .map_err(|err| io::Error::other(format!("failed to list installed apps: {err}")).into()) +} diff --git a/crates/sage-apps/src/lifecycle/install/url.rs b/crates/sage-apps/src/lifecycle/install/url.rs new file mode 100644 index 000000000..de9764622 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/install/url.rs @@ -0,0 +1,113 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result as AnyResult; +use async_trait::async_trait; + +use super::AppInstallSource; +use crate::{ + SageAppPackageManifest, SageAppSnapshot, SageAppUrl, SageAppUrlPreview, UserSageAppSource, + bytes_sha256_hex, download_url_snapshot, fetch_url_manifest_preview, +}; + +#[derive(Debug, Clone)] +pub struct PreparedUrlInstall { + pub preview: SageAppUrlPreview, +} + +#[async_trait] +impl AppInstallSource for SageAppUrl { + type PreparedArtifact = PreparedUrlInstall; + + async fn prepare(&self) -> AnyResult { + let (manifest, manifest_hash) = fetch_url_manifest_preview(&self.manifest_url()).await?; + + Ok(PreparedUrlInstall { + preview: SageAppUrlPreview::new(self, manifest, manifest_hash).await?, + }) + } + + fn manifest<'a>(&self, prepared: &'a Self::PreparedArtifact) -> &'a SageAppPackageManifest { + prepared + .preview + .require_full_manifest() + .expect("URL install requires full manifest") + } + + fn source(&self, prepared: &Self::PreparedArtifact) -> UserSageAppSource { + UserSageAppSource::Url { + app_url: prepared.preview.app_url().clone(), + } + } + + fn resolve_target( + &self, + root: &Path, + _base_path: &Path, + prepared: &Self::PreparedArtifact, + ) -> AnyResult<(String, PathBuf)> { + Ok(resolve_url_install_target(root, prepared.preview.app_url())) + } + + async fn create_snapshot( + &self, + snapshot_dir: &Path, + prepared: &Self::PreparedArtifact, + ) -> AnyResult { + download_url_snapshot( + snapshot_dir, + prepared.preview.app_url(), + prepared.preview.require_full_manifest()?, + prepared.preview.manifest_hash(), + ) + .await + } +} + +pub fn generate_url_app_id(app_url: &SageAppUrl) -> String { + let hash = bytes_sha256_hex(app_url.manifest_url().as_bytes()); + format!("url-{}-{}", app_url.slug(), &hash[..16]) +} + +pub fn resolve_url_install_target(root: &Path, app_url: &SageAppUrl) -> (String, PathBuf) { + let app_id = generate_url_app_id(app_url); + let app_dir = root.join(&app_id); + + (app_id, app_dir) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn generate_url_app_id_is_stable_for_same_app_url() { + let a = generate_url_app_id(&SageAppUrl::parse("https://example.com/app").unwrap()); + let b = generate_url_app_id(&SageAppUrl::parse("https://example.com/app").unwrap()); + + assert_eq!(a, b); + assert!(a.starts_with("url-example-com-")); + } + + #[test] + fn generate_url_app_id_differs_for_different_manifest_urls() { + let a = generate_url_app_id(&SageAppUrl::parse("https://example.com/app-a").unwrap()); + let b = generate_url_app_id(&SageAppUrl::parse("https://example.com/app-b").unwrap()); + + assert_ne!(a, b); + } + + #[test] + fn resolve_url_install_target_returns_stable_app_id_and_dir() { + let dir = tempdir().unwrap(); + let root = dir.path().join("apps"); + std::fs::create_dir_all(&root).unwrap(); + + let app_url = SageAppUrl::parse("https://example.com/app").unwrap(); + let (app_id, app_dir) = resolve_url_install_target(&root, &app_url); + + assert_eq!(app_dir, root.join(&app_id)); + assert_eq!(app_id, generate_url_app_id(&app_url)); + } +} diff --git a/crates/sage-apps/src/lifecycle/install/zip.rs b/crates/sage-apps/src/lifecycle/install/zip.rs new file mode 100644 index 000000000..fa05149b0 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/install/zip.rs @@ -0,0 +1,171 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::Result as AnyResult; +use async_trait::async_trait; +use uuid::Uuid; + +use super::AppInstallSource; +use crate::{ + SageAppPackageManifest, SageAppSnapshot, UserSageAppSource, detect_package_root, + prepare_zip_snapshot, read_manifest, slugify_app_name, unzip_to_dir, +}; + +#[derive(Debug, Clone)] +pub struct ZipInstallSource { + pub zip_path: String, + pub unpack_dir: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct PreparedZipInstall { + pub package_root: PathBuf, + pub manifest: SageAppPackageManifest, +} + +impl ZipInstallSource { + pub fn new(root: &Path, zip_path: String) -> Self { + Self { + zip_path, + unpack_dir: root.join(format!(".tmp-{}", Uuid::new_v4())), + } + } +} + +#[async_trait] +impl AppInstallSource for ZipInstallSource { + type PreparedArtifact = PreparedZipInstall; + + async fn prepare(&self) -> AnyResult { + if self.unpack_dir.exists() { + fs::remove_dir_all(&self.unpack_dir)?; + } + + unzip_to_dir(Path::new(&self.zip_path), &self.unpack_dir)?; + + let package_root = detect_package_root(&self.unpack_dir)?; + let manifest = read_manifest(&package_root)?; + + Ok(PreparedZipInstall { + package_root, + manifest, + }) + } + + fn manifest<'a>(&self, prepared: &'a Self::PreparedArtifact) -> &'a SageAppPackageManifest { + &prepared.manifest + } + + fn source(&self, _prepared: &Self::PreparedArtifact) -> UserSageAppSource { + UserSageAppSource::Zip + } + + fn resolve_target( + &self, + root: &Path, + _base_path: &Path, + prepared: &Self::PreparedArtifact, + ) -> AnyResult<(String, PathBuf)> { + let app_id = generate_zip_app_id(prepared.manifest.name()); + let app_dir = root.join(&app_id); + + Ok((app_id, app_dir)) + } + + async fn create_snapshot( + &self, + snapshot_dir: &Path, + prepared: &Self::PreparedArtifact, + ) -> AnyResult { + prepare_zip_snapshot(&prepared.package_root, snapshot_dir, &prepared.manifest) + } +} + +pub fn generate_zip_app_id(name: &str) -> String { + format!("{}-{}", slugify_app_name(name), Uuid::new_v4()) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + use crate::{SageAppManifestFile, SageAppPackageManifestParts, SageRequestedPermissions}; + + fn sample_manifest(name: &str) -> SageAppPackageManifest { + let (manifest_version, sage_version) = SageAppPackageManifestParts::v0_defaults(); + + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: name.to_string(), + icon: None, + sage_version, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![SageAppManifestFile::new("index.html", "a".repeat(64), 1).unwrap()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + #[test] + fn generate_zip_app_id_uses_slug_and_uuid() { + let app_id = generate_zip_app_id("My Test App!"); + + assert!(app_id.starts_with("my-test-app-")); + assert!(app_id.len() > "my-test-app-".len()); + } + + #[test] + fn zip_resolve_target_always_creates_new_install_target() { + let dir = tempdir().unwrap(); + + let source = ZipInstallSource { + zip_path: "unused.zip".into(), + unpack_dir: dir.path().join(".tmp-unused"), + }; + + let prepared = PreparedZipInstall { + package_root: dir.path().to_path_buf(), + manifest: sample_manifest("Test App"), + }; + + let (app_id, app_dir) = source + .resolve_target(dir.path(), dir.path(), &prepared) + .unwrap(); + + assert!(app_id.starts_with("test-app-")); + assert_eq!(app_dir, dir.path().join(&app_id)); + } + + #[test] + fn zip_resolve_target_does_not_reuse_existing_app_with_same_name() { + let dir = tempdir().unwrap(); + + let source = ZipInstallSource { + zip_path: "unused.zip".into(), + unpack_dir: dir.path().join(".tmp-unused"), + }; + + let prepared = PreparedZipInstall { + package_root: dir.path().to_path_buf(), + manifest: sample_manifest("Test App"), + }; + + let (first_app_id, _) = source + .resolve_target(dir.path(), dir.path(), &prepared) + .unwrap(); + + let (second_app_id, _) = source + .resolve_target(dir.path(), dir.path(), &prepared) + .unwrap(); + + assert_ne!(first_app_id, second_app_id); + assert!(first_app_id.starts_with("test-app-")); + assert!(second_app_id.starts_with("test-app-")); + } +} diff --git a/crates/sage-apps/src/lifecycle/manifest.rs b/crates/sage-apps/src/lifecycle/manifest.rs new file mode 100644 index 000000000..f7b380b84 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/manifest.rs @@ -0,0 +1,91 @@ +use anyhow::{Context, Result as AnyResult}; + +use crate::{ + MANIFEST_FILE_NAME, SageAppManifestUrl, SageAppPackageManifest, SageAppPackageManifestPreview, + bytes_sha256_hex, download_bytes_with_limit, parse_manifest_header_v0_from_value, +}; + +const MAX_URL_MANIFEST_BYTES: u64 = 1024 * 1024; + +pub async fn fetch_url_manifest( + manifest_url: &SageAppManifestUrl, +) -> AnyResult<(SageAppPackageManifest, String)> { + let (preview, hash) = fetch_url_manifest_preview(manifest_url).await?; + + match preview { + SageAppPackageManifestPreview::Full { manifest } => Ok((manifest, hash)), + SageAppPackageManifestPreview::Partial { parse_error, .. } => Err(anyhow::anyhow!( + "failed to parse manifest json: {parse_error}" + )), + } +} + +pub async fn fetch_url_manifest_preview( + manifest_url: &SageAppManifestUrl, +) -> AnyResult<(SageAppPackageManifestPreview, String)> { + let manifest_url = manifest_url.as_str(); + + let bytes = download_bytes_with_limit(manifest_url, MAX_URL_MANIFEST_BYTES) + .await + .with_context(|| format!("failed to download manifest from {manifest_url}"))?; + + let manifest_hash = bytes_sha256_hex(&bytes); + + let manifest_text = std::str::from_utf8(&bytes) + .with_context(|| format!("manifest is not valid UTF-8: {manifest_url}"))?; + + Ok(( + parse_manifest_preview(manifest_text, manifest_url)?, + manifest_hash, + )) +} + +pub fn parse_manifest_preview( + manifest_text: &str, + source_label: &str, +) -> AnyResult { + let mut deserializer = serde_json::Deserializer::from_str(manifest_text); + + match serde_path_to_error::deserialize::<_, SageAppPackageManifest>(&mut deserializer) { + Ok(manifest) => Ok(SageAppPackageManifestPreview::Full { manifest }), + + Err(full_err) => { + let parse_error = format!("at {}: {}", full_err.path(), full_err.inner()); + + let value: serde_json::Value = serde_json::from_str(manifest_text) + .with_context(|| format!("failed to parse manifest JSON from {source_label}"))?; + + let manifest_header = parse_manifest_header_v0_from_value(value) + .with_context(|| { + format!( + "failed to parse fallback manifest header from {source_label}; full parse failed {parse_error}" + ) + })?; + + Ok(SageAppPackageManifestPreview::Partial { + manifest_header, + parse_error, + }) + } + } +} + +pub fn read_manifest(package_root: &std::path::Path) -> AnyResult { + let manifest_path = package_root.join(MANIFEST_FILE_NAME); + let manifest_text = std::fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + + let mut deserializer = serde_json::Deserializer::from_str(&manifest_text); + let manifest: SageAppPackageManifest = serde_path_to_error::deserialize(&mut deserializer) + .map_err(|err| { + anyhow::anyhow!( + "failed to parse manifest {} at {}: {}", + manifest_path.display(), + err.path(), + err.inner() + ) + })?; + manifest.validate_package_files(package_root)?; + + Ok(manifest) +} diff --git a/crates/sage-apps/src/lifecycle/mutation.rs b/crates/sage-apps/src/lifecycle/mutation.rs new file mode 100644 index 000000000..7ae7369d0 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/mutation.rs @@ -0,0 +1,5 @@ +mod draft; +mod manager; + +pub(crate) use draft::*; +pub(crate) use manager::*; diff --git a/crates/sage-apps/src/lifecycle/mutation/draft.rs b/crates/sage-apps/src/lifecycle/mutation/draft.rs new file mode 100644 index 000000000..49a7b17ba --- /dev/null +++ b/crates/sage-apps/src/lifecycle/mutation/draft.rs @@ -0,0 +1,46 @@ +use crate::{SageApp, SageAppStorage, SageGrantedPermissions}; + +#[derive(Debug)] +pub(crate) struct AppMutationDraft { + app: SageApp, +} + +impl AppMutationDraft { + pub(crate) fn new(app: SageApp) -> Self { + Self { app } + } + + pub(crate) fn app(&self) -> &SageApp { + &self.app + } + + pub(crate) fn app_mut(&mut self) -> &mut SageApp { + &mut self.app + } + + pub(crate) fn replace_storage_and_origin( + &mut self, + storage: SageAppStorage, + origin_id: impl Into, + origin_tainted: bool, + ) -> anyhow::Result<()> { + self.app + .common_mut() + .replace_storage_and_origin(storage, origin_id, origin_tainted) + } + + pub(crate) fn mark_origin_webview_storage_may_contain_secrets(&mut self) -> anyhow::Result<()> { + self.app + .common_mut() + .mark_origin_webview_storage_may_contain_secrets() + } + + pub(crate) fn update_permissions( + &mut self, + granted_permissions: &SageGrantedPermissions, + ) -> anyhow::Result<()> { + self.app + .common_mut() + .update_permissions(granted_permissions) + } +} diff --git a/crates/sage-apps/src/lifecycle/mutation/manager.rs b/crates/sage-apps/src/lifecycle/mutation/manager.rs new file mode 100644 index 000000000..8ecf28fc0 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/mutation/manager.rs @@ -0,0 +1,171 @@ +use std::pin::Pin; + +use anyhow::Result; +use tauri::{AppHandle, State}; + +use crate::{AppMutationDraft, AppsDbTx, AppsHostState, SageApp, SharedSageApp}; + +pub(crate) struct AppMutationManager<'a> { + #[allow(dead_code)] + app_handle: &'a AppHandle, + apps_state: &'a State<'a, AppsHostState>, +} + +pub(crate) struct AppMutationContext { + draft: AppMutationDraft, + tx: AppsDbTx, +} + +impl AppMutationContext { + pub(crate) fn draft(&self) -> &AppMutationDraft { + &self.draft + } + + pub(crate) fn draft_mut(&mut self) -> &mut AppMutationDraft { + &mut self.draft + } + + pub(crate) fn tx(&mut self) -> &mut AppsDbTx { + &mut self.tx + } + + fn into_parts(self) -> (AppMutationDraft, AppsDbTx) { + (self.draft, self.tx) + } +} + +impl<'a> AppMutationManager<'a> { + pub(crate) fn new(app_handle: &'a AppHandle, apps_state: &'a State<'a, AppsHostState>) -> Self { + Self { + app_handle, + apps_state, + } + } + + pub(crate) async fn mutate_shared_app( + &self, + app: &SharedSageApp, + f: impl for<'m> FnOnce( + &'m mut AppMutationContext, + ) -> Pin> + Send + 'm>> + + Send, + ) -> Result + where + T: Send, + { + let app_id = app.id(); + + let mut tx = self + .apps_state + .db + .begin_immediate() + .await + .map_err(|err| format!("failed to begin app mutation transaction: {err}"))?; + + let current = tx + .load_user_app(&app_id) + .await + .map_err(|err| format!("failed to load current app state: {err}"))? + .into_sage_app(); + + let draft = AppMutationDraft::new( + current + .clone_for_rollback() + .map_err(|err| format!("failed to clone app draft: {err}"))?, + ); + + let mut ctx = AppMutationContext { draft, tx }; + + let value = match f(&mut ctx).await { + Ok(value) => value, + Err(err) => { + ctx.tx.rollback().await; + return Err(err.to_string()); + } + }; + + let (draft, mut tx) = ctx.into_parts(); + + if let Err(err) = Self::validate(draft.app()) { + tx.rollback().await; + return Err(err); + } + + let Some(draft_user_app) = draft.app().as_user() else { + tx.rollback().await; + return Err("only user apps can be persisted by app mutation manager".to_string()); + }; + + if let Err(err) = tx.persist_user_app(draft_user_app).await { + tx.rollback().await; + return Err(format!("failed to persist app draft: {err}")); + } + + let reloaded = match tx.load_user_app(&app_id).await { + Ok(app) => app.into_sage_app(), + Err(err) => { + tx.rollback().await; + return Err(format!("failed to reload app draft: {err}")); + } + }; + + if let Err(err) = Self::assert_round_trip(draft.app(), &reloaded) { + tx.rollback().await; + return Err(err); + } + + if let Err(err) = tx.commit().await { + return Err(format!("failed to commit app mutation transaction: {err}")); + } + + app.replace_committed(reloaded); + + Ok(value) + } + + fn validate(app: &SageApp) -> Result<(), String> { + if app.is_system() { + return Ok(()); + } + + let common = app.common(); + + if common.has_external_access() && common.has_secret_access() { + return Err( + "app permissions cannot include both external access and sensitive secret access" + .to_string(), + ); + } + + if common.has_external_access() && common.origin_webview_storage_may_contain_secrets() { + return Err( + "app permissions cannot include external access while origin webview storage may contain secrets" + .to_string(), + ); + } + + Ok(()) + } + + fn assert_round_trip(expected: &SageApp, actual: &SageApp) -> Result<(), String> { + let expected = expected + .as_user() + .ok_or_else(|| "expected app is not a user app".to_string())?; + + let actual = actual + .as_user() + .ok_or_else(|| "loaded app is not a user app".to_string())?; + + let expected = serde_json::to_value(expected) + .map_err(|err| format!("failed to serialize expected app state: {err}"))?; + + let actual = serde_json::to_value(actual) + .map_err(|err| format!("failed to serialize loaded app state: {err}"))?; + + if expected != actual { + return Err("app DB round-trip mismatch".to_string()); + } + + Ok(()) + } +} diff --git a/crates/sage-apps/src/lifecycle/package.rs b/crates/sage-apps/src/lifecycle/package.rs new file mode 100644 index 000000000..3fe4e6fb1 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/package.rs @@ -0,0 +1,668 @@ +use std::{ + collections::BTreeSet, + fs, + io::{self, Read}, + path::{Component, Path, PathBuf}, +}; + +use anyhow::{Context, Result as AnyResult}; +use zip::ZipArchive; + +use crate::{MANIFEST_FILE_NAME, SageAppPackageManifest, SageAppSnapshot, bytes_sha256_hex}; + +const MAX_ZIP_ENTRIES: usize = 2_000; +const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES: u64 = 200 * 1024 * 1024; +const MAX_ZIP_SINGLE_FILE_BYTES: u64 = 50 * 1024 * 1024; +const MAX_ZIP_COMPRESSION_RATIO: u64 = 100; + +pub fn unzip_to_dir(zip_path: &Path, out_dir: &Path) -> AnyResult<()> { + unzip_to_dir_with_limits( + zip_path, + out_dir, + MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES, + MAX_ZIP_SINGLE_FILE_BYTES, + ) +} + +fn unzip_to_dir_with_limits( + zip_path: &Path, + out_dir: &Path, + max_total_uncompressed_bytes: u64, + max_single_file_bytes: u64, +) -> AnyResult<()> { + let file = fs::File::open(zip_path) + .with_context(|| format!("failed to open zip {}", zip_path.display()))?; + let mut archive = ZipArchive::new(file).context("failed to read zip archive")?; + + if archive.len() > MAX_ZIP_ENTRIES { + anyhow::bail!("zip archive contains too many entries"); + } + + let parent = out_dir + .parent() + .context("output dir has no parent directory")?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + + let out_name = out_dir.file_name().map_or_else( + || "package".to_string(), + |name| name.to_string_lossy().into_owned(), + ); + let staging_prefix = format!(".{out_name}.staging-"); + let staging = tempfile::Builder::new() + .prefix(&staging_prefix) + .tempdir_in(parent) + .with_context(|| format!("failed to create staging directory in {}", parent.display()))?; + + extract_archive_into( + &mut archive, + staging.path(), + max_total_uncompressed_bytes, + max_single_file_bytes, + )?; + + if out_dir.exists() { + fs::remove_dir_all(out_dir) + .with_context(|| format!("failed to remove output dir {}", out_dir.display()))?; + } + + fs::rename(staging.path(), out_dir).with_context(|| { + format!( + "failed to move extracted package into {}", + out_dir.display() + ) + })?; + + Ok(()) +} + +fn extract_archive_into( + archive: &mut ZipArchive, + staging: &Path, + max_total_uncompressed_bytes: u64, + max_single_file_bytes: u64, +) -> AnyResult<()> { + let root = staging + .canonicalize() + .with_context(|| format!("failed to canonicalize staging dir {}", staging.display()))?; + + let mut declared_total = 0u64; + let mut total_written = 0u64; + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .with_context(|| format!("failed to read zip entry #{index}"))?; + + let Some(enclosed_name) = entry.enclosed_name() else { + anyhow::bail!("zip entry has unsafe path: {}", entry.name()); + }; + + let uncompressed_size = entry.size(); + let compressed_size = entry.compressed_size(); + + // Header sizes are attacker-controlled, so these checks are only a + // cheap fast path. The authoritative limits are enforced against the + // actual decompressed byte count below. + if uncompressed_size > max_single_file_bytes { + anyhow::bail!("zip entry is too large: {}", entry.name()); + } + + if compressed_size > 0 && uncompressed_size / compressed_size > MAX_ZIP_COMPRESSION_RATIO { + anyhow::bail!("zip entry compression ratio is too high: {}", entry.name()); + } + + declared_total = declared_total + .checked_add(uncompressed_size) + .context("zip uncompressed size overflow")?; + + if declared_total > max_total_uncompressed_bytes { + anyhow::bail!("zip archive exceeds maximum extracted size"); + } + + let target = root.join(enclosed_name); + + if entry.is_dir() { + fs::create_dir_all(&target) + .with_context(|| format!("failed to create directory {}", target.display()))?; + continue; + } + + let parent = target.parent().context("zip target path has no parent")?; + + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + + let canonical_parent = parent.canonicalize().with_context(|| { + format!( + "failed to canonicalize zip target parent {}", + parent.display() + ) + })?; + + if !canonical_parent.starts_with(&root) { + anyhow::bail!("zip entry escapes extraction directory: {}", entry.name()); + } + + let mut out = fs::File::create(&target) + .with_context(|| format!("failed to create file {}", target.display()))?; + + let remaining_total = max_total_uncompressed_bytes.saturating_sub(total_written); + let stream_limit = remaining_total.min(max_single_file_bytes); + let entry_name = entry.name().to_string(); + let mut limited = (&mut entry).take(stream_limit + 1); + let written = io::copy(&mut limited, &mut out) + .with_context(|| format!("failed to extract zip entry {entry_name}"))?; + + if written > max_single_file_bytes { + anyhow::bail!("zip entry is too large: {entry_name}"); + } + + total_written = total_written + .checked_add(written) + .context("zip uncompressed size overflow")?; + + if total_written > max_total_uncompressed_bytes { + anyhow::bail!("zip archive exceeds maximum extracted size"); + } + } + + Ok(()) +} + +pub fn detect_package_root(unpack_dir: &Path) -> AnyResult { + let direct_manifest = unpack_dir.join(MANIFEST_FILE_NAME); + if direct_manifest.is_file() { + return Ok(unpack_dir.to_path_buf()); + } + + let mut dirs = fs::read_dir(unpack_dir)? + .filter_map(Result::ok) + .filter_map(|entry| { + entry + .file_type() + .ok() + .filter(fs::FileType::is_dir) + .map(|_| entry.path()) + }) + .collect::>(); + + dirs.sort(); + + for dir in dirs { + if dir.join(MANIFEST_FILE_NAME).is_file() { + return Ok(dir); + } + } + + anyhow::bail!("could not find {MANIFEST_FILE_NAME}") +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> AnyResult<()> { + fs::create_dir_all(dst) + .with_context(|| format!("failed to create directory {}", dst.display()))?; + + for entry in + fs::read_dir(src).with_context(|| format!("failed to read directory {}", src.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + + if file_type.is_dir() { + copy_dir_recursive(&from, &to)?; + } else if file_type.is_file() { + fs::copy(&from, &to).with_context(|| { + format!("failed to copy {} to {}", from.display(), to.display()) + })?; + } + } + + Ok(()) +} + +pub fn prepare_zip_snapshot( + package_root: &Path, + snapshot_dir: &Path, + manifest: &SageAppPackageManifest, +) -> AnyResult { + validate_package_has_no_undeclared_files(package_root, manifest)?; + + if snapshot_dir.exists() { + fs::remove_dir_all(snapshot_dir).with_context(|| { + format!( + "failed to remove existing snapshot dir {}", + snapshot_dir.display() + ) + })?; + } + + fs::create_dir_all(snapshot_dir) + .with_context(|| format!("failed to create snapshot dir {}", snapshot_dir.display()))?; + + copy_dir_recursive(package_root, snapshot_dir).with_context(|| { + format!( + "failed to copy unpacked package {} into snapshot {}", + package_root.display(), + snapshot_dir.display() + ) + })?; + + let manifest_hash = bytes_sha256_hex(&serde_json::to_vec(manifest)?); + + SageAppSnapshot::new( + manifest_hash, + snapshot_dir.to_string_lossy().to_string(), + manifest.clone(), + ) +} + +fn validate_package_has_no_undeclared_files( + package_root: &Path, + manifest: &SageAppPackageManifest, +) -> AnyResult<()> { + let declared = manifest + .files() + .iter() + .map(|file| file.path().to_string()) + .collect::>(); + + validate_dir_has_no_undeclared_files(package_root, package_root, &declared) +} + +fn validate_dir_has_no_undeclared_files( + package_root: &Path, + dir: &Path, + declared: &BTreeSet, +) -> AnyResult<()> { + for entry in + fs::read_dir(dir).with_context(|| format!("failed to read directory {}", dir.display()))? + { + let entry = entry?; + let file_type = entry.file_type()?; + let path = entry.path(); + + if file_type.is_dir() { + validate_dir_has_no_undeclared_files(package_root, &path, declared)?; + } else if file_type.is_file() { + let relative_path = package_relative_path(package_root, &path)?; + + if relative_path != MANIFEST_FILE_NAME && !declared.contains(&relative_path) { + anyhow::bail!("package contains undeclared file: {relative_path}"); + } + } else { + anyhow::bail!("package contains unsupported file type: {}", path.display()); + } + } + + Ok(()) +} + +fn package_relative_path(package_root: &Path, path: &Path) -> AnyResult { + let relative = path.strip_prefix(package_root).with_context(|| { + format!( + "failed to compute package relative path for {}", + path.display() + ) + })?; + + let mut parts = Vec::new(); + + for component in relative.components() { + match component { + Component::Normal(part) => { + let part = part.to_str().with_context(|| { + format!("package path is not valid UTF-8: {}", path.display()) + })?; + + parts.push(part); + } + _ => anyhow::bail!("package path has invalid component: {}", path.display()), + } + } + + Ok(parts.join("/")) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::tempdir; + use zip::write::SimpleFileOptions; + + use super::*; + use crate::{ + SageAppManifestFile, SageAppPackageManifest, SageAppPackageManifestParts, + SageRequestedCapabilities, SageRequestedNetworkPermissions, SageRequestedPermissions, + }; + + fn write_zip(path: &Path, entries: &[(&str, &[u8])]) { + let file = fs::File::create(path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default(); + + for (name, contents) in entries { + zip.start_file(name, options).unwrap(); + zip.write_all(contents).unwrap(); + } + + zip.finish().unwrap(); + } + + fn understate_uncompressed_sizes(path: &Path, declared_size: u32) { + const LOCAL_FILE_HEADER: [u8; 4] = [0x50, 0x4b, 0x03, 0x04]; + const CENTRAL_DIRECTORY_HEADER: [u8; 4] = [0x50, 0x4b, 0x01, 0x02]; + + let mut bytes = fs::read(path).unwrap(); + let mut local_headers = 0; + let mut central_headers = 0; + + for index in 0..bytes.len().saturating_sub(3) { + if bytes[index..index + 4] == LOCAL_FILE_HEADER { + bytes[index + 22..index + 26].copy_from_slice(&declared_size.to_le_bytes()); + local_headers += 1; + } else if bytes[index..index + 4] == CENTRAL_DIRECTORY_HEADER { + bytes[index + 24..index + 28].copy_from_slice(&declared_size.to_le_bytes()); + central_headers += 1; + } + } + + assert!(local_headers > 0); + assert_eq!(local_headers, central_headers); + fs::write(path, bytes).unwrap(); + } + + fn staging_dir_count(parent: &Path, out_name: &str) -> usize { + let prefix = format!(".{out_name}.staging-"); + + fs::read_dir(parent) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) + .count() + } + + fn sample_manifest_file(path: &str, bytes: &[u8]) -> SageAppManifestFile { + SageAppManifestFile::new(path, bytes_sha256_hex(bytes), bytes.len() as u64).unwrap() + } + + fn sample_manifest(files: Vec) -> SageAppPackageManifest { + let (manifest_version, sage_version) = SageAppPackageManifestParts::v0_defaults(); + + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: "Test App".to_string(), + icon: None, + sage_version, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::new( + SageRequestedNetworkPermissions::empty(), + SageRequestedCapabilities::empty(), + ) + .unwrap(), + files, + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + #[test] + fn unzip_to_dir_extracts_normal_nested_files() { + let dir = tempdir().unwrap(); + let zip_path = dir.path().join("app.zip"); + let out_dir = dir.path().join("out"); + + write_zip( + &zip_path, + &[ + ("sage-app.json", br#"{"name":"test"}"#), + ("nested/index.html", b""), + ], + ); + + unzip_to_dir(&zip_path, &out_dir).unwrap(); + + assert_eq!( + fs::read_to_string(out_dir.join("nested/index.html")).unwrap(), + "" + ); + assert_eq!(staging_dir_count(dir.path(), "out"), 0); + } + + #[test] + fn unzip_to_dir_preserves_existing_output_on_rejection() { + let dir = tempdir().unwrap(); + let zip_path = dir.path().join("evil.zip"); + let out_dir = dir.path().join("out"); + fs::create_dir(&out_dir).unwrap(); + fs::write(out_dir.join("existing.txt"), b"keep me").unwrap(); + + write_zip(&zip_path, &[("../evil.txt", b"owned")]); + + unzip_to_dir(&zip_path, &out_dir).unwrap_err(); + + assert_eq!( + fs::read_to_string(out_dir.join("existing.txt")).unwrap(), + "keep me" + ); + assert_eq!(staging_dir_count(dir.path(), "out"), 0); + } + + #[test] + fn unzip_to_dir_rejects_parent_directory_escape() { + let dir = tempdir().unwrap(); + let zip_path = dir.path().join("evil.zip"); + let out_dir = dir.path().join("out"); + + write_zip(&zip_path, &[("../evil.txt", b"owned")]); + + let err = unzip_to_dir(&zip_path, &out_dir) + .expect_err("zip entries escaping output dir must be rejected"); + + let message = err.to_string(); + assert!( + message.contains("unsafe path") || message.contains("escapes extraction directory"), + "unexpected error: {message}" + ); + + assert!(!dir.path().join("evil.txt").exists()); + assert!(!out_dir.exists()); + assert_eq!(staging_dir_count(dir.path(), "out"), 0); + } + + #[test] + fn unzip_to_dir_keeps_absolute_path_inside_output_dir() { + let dir = tempdir().unwrap(); + let zip_path = dir.path().join("absolute.zip"); + let out_dir = dir.path().join("out"); + + write_zip(&zip_path, &[("/tmp/sage-apps-test-file.txt", b"owned")]); + + unzip_to_dir(&zip_path, &out_dir).unwrap(); + + assert!(out_dir.join("tmp/sage-apps-test-file.txt").is_file()); + assert_eq!( + fs::read_to_string(out_dir.join("tmp/sage-apps-test-file.txt")).unwrap(), + "owned" + ); + } + + #[test] + fn unzip_to_dir_rejects_too_large_single_file() { + let dir = tempdir().unwrap(); + let zip_path = dir.path().join("large.zip"); + let out_dir = dir.path().join("out"); + + let file = fs::File::create(&zip_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("large.bin", SimpleFileOptions::default()) + .unwrap(); + + let chunk = vec![0u8; 1024 * 1024]; + for _ in 0..=MAX_ZIP_SINGLE_FILE_BYTES / chunk.len() as u64 { + zip.write_all(&chunk).unwrap(); + } + + zip.finish().unwrap(); + + let err = + unzip_to_dir(&zip_path, &out_dir).expect_err("oversized zip entry must be rejected"); + + assert!( + err.to_string().contains("too large"), + "unexpected error: {err}" + ); + assert!(!out_dir.exists()); + assert_eq!(staging_dir_count(dir.path(), "out"), 0); + } + + #[test] + fn unzip_to_dir_enforces_actual_single_file_size() { + let dir = tempdir().unwrap(); + let zip_path = dir.path().join("understated.zip"); + let out_dir = dir.path().join("out"); + let contents = vec![b'x'; 64]; + + write_zip(&zip_path, &[("large.bin", &contents)]); + understate_uncompressed_sizes(&zip_path, 1); + + let err = unzip_to_dir_with_limits(&zip_path, &out_dir, 256, 32) + .expect_err("actual decompressed size must enforce the per-file limit"); + + assert!( + err.to_string().contains("too large"), + "unexpected error: {err}" + ); + assert!(!out_dir.exists()); + assert_eq!(staging_dir_count(dir.path(), "out"), 0); + } + + #[test] + fn unzip_to_dir_enforces_actual_total_size() { + let dir = tempdir().unwrap(); + let zip_path = dir.path().join("understated-total.zip"); + let out_dir = dir.path().join("out"); + let first = vec![b'a'; 24]; + let second = vec![b'b'; 24]; + + write_zip(&zip_path, &[("first.bin", &first), ("second.bin", &second)]); + understate_uncompressed_sizes(&zip_path, 1); + + let err = unzip_to_dir_with_limits(&zip_path, &out_dir, 40, 32) + .expect_err("actual decompressed size must enforce the archive limit"); + + assert!( + err.to_string().contains("maximum extracted size"), + "unexpected error: {err}" + ); + assert!(!out_dir.exists()); + assert_eq!(staging_dir_count(dir.path(), "out"), 0); + } + + #[test] + fn prepare_zip_snapshot_rejects_undeclared_files() { + let dir = tempdir().unwrap(); + let package_root = dir.path().join("package"); + let snapshot_dir = dir.path().join("snapshot"); + fs::create_dir_all(&package_root).unwrap(); + fs::write(package_root.join("index.html"), b"").unwrap(); + fs::write(package_root.join("extra.js"), b"alert(1)").unwrap(); + + let manifest = sample_manifest(vec![sample_manifest_file("index.html", b"")]); + + let err = prepare_zip_snapshot(&package_root, &snapshot_dir, &manifest) + .expect_err("undeclared files must be rejected"); + + assert!( + err.to_string().contains("undeclared file: extra.js"), + "unexpected error: {err}" + ); + } + + #[test] + fn prepare_zip_snapshot_rejects_nested_undeclared_files() { + let dir = tempdir().unwrap(); + let package_root = dir.path().join("package"); + let snapshot_dir = dir.path().join("snapshot"); + fs::create_dir_all(package_root.join("assets")).unwrap(); + fs::write(package_root.join("index.html"), b"").unwrap(); + fs::write(package_root.join("assets/extra.js"), b"alert(1)").unwrap(); + + let manifest = sample_manifest(vec![sample_manifest_file("index.html", b"")]); + + let err = prepare_zip_snapshot(&package_root, &snapshot_dir, &manifest) + .expect_err("nested undeclared files must be rejected"); + + assert!( + err.to_string().contains("undeclared file: assets/extra.js"), + "unexpected error: {err}" + ); + } + + #[test] + fn prepare_zip_snapshot_accepts_declared_nested_files() { + let dir = tempdir().unwrap(); + let package_root = dir.path().join("package"); + let snapshot_dir = dir.path().join("snapshot"); + fs::create_dir_all(package_root.join("assets")).unwrap(); + fs::write(package_root.join("index.html"), b"").unwrap(); + fs::write(package_root.join("assets/app.js"), b"console.log('ok')").unwrap(); + + let manifest = sample_manifest(vec![ + sample_manifest_file("index.html", b""), + sample_manifest_file("assets/app.js", b"console.log('ok')"), + ]); + + prepare_zip_snapshot(&package_root, &snapshot_dir, &manifest) + .expect("declared nested files should be accepted"); + + assert!(snapshot_dir.join("assets/app.js").is_file()); + } + + #[cfg(unix)] + #[test] + fn prepare_zip_snapshot_rejects_symlinks() { + let dir = tempdir().unwrap(); + let package_root = dir.path().join("package"); + let snapshot_dir = dir.path().join("snapshot"); + fs::create_dir_all(&package_root).unwrap(); + fs::write(package_root.join("index.html"), b"").unwrap(); + std::os::unix::fs::symlink("index.html", package_root.join("index-link.html")).unwrap(); + + let manifest = sample_manifest(vec![sample_manifest_file("index.html", b"")]); + + let err = prepare_zip_snapshot(&package_root, &snapshot_dir, &manifest) + .expect_err("symlinks must be rejected"); + + assert!( + err.to_string().contains("unsupported file type"), + "unexpected error: {err}" + ); + } + + #[test] + fn prepare_zip_snapshot_allows_manifest_file() { + let dir = tempdir().unwrap(); + let package_root = dir.path().join("package"); + let snapshot_dir = dir.path().join("snapshot"); + fs::create_dir_all(&package_root).unwrap(); + fs::write(package_root.join("index.html"), b"").unwrap(); + + let manifest = sample_manifest(vec![sample_manifest_file("index.html", b"")]); + fs::write( + package_root.join(MANIFEST_FILE_NAME), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + + prepare_zip_snapshot(&package_root, &snapshot_dir, &manifest) + .expect("declared files plus manifest should be accepted"); + + assert!(snapshot_dir.join("index.html").is_file()); + assert!(snapshot_dir.join(MANIFEST_FILE_NAME).is_file()); + } +} diff --git a/crates/sage-apps/src/lifecycle/registry.rs b/crates/sage-apps/src/lifecycle/registry.rs new file mode 100644 index 000000000..f1d3a4078 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/registry.rs @@ -0,0 +1,135 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result as AnyResult; + +use crate::{AppsDb, ListedSageApp, SageApp, SystemAppUsage, list_builtin_system_apps}; + +pub fn apps_root(base_path: &Path) -> PathBuf { + base_path.join("apps") +} + +pub async fn list_installed_apps_internal(db: &AppsDb) -> AnyResult> { + let mut apps = db.list_installed_apps().await?; + + for app in list_builtin_system_apps()? { + if let SageApp::System(app) = app + && app.usage() == SystemAppUsage::Standalone + { + apps.push(ListedSageApp::System(app)); + } + } + + apps.sort_by_key(listed_app_sort_key); + + Ok(apps) +} + +fn listed_app_sort_key(app: &ListedSageApp) -> String { + match app { + ListedSageApp::User(app) => app.common().name().to_lowercase(), + ListedSageApp::System(app) => app.common().name().to_lowercase(), + ListedSageApp::Corrupted(app) => app.id().to_lowercase(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use tempfile::tempdir; + + use super::*; + use crate::{ + FakeInstallSource, ListedSageApp, SageAppManifestFile, SageAppPackageManifest, + SageAppPackageManifestParts, SageGrantedPermissionsInput, SageRequestedPermissions, + SharedSageApp, UserSageAppSource, install_app_from_source_for_test, + }; + + fn sample_manifest_file(path: &str, size: u64) -> SageAppManifestFile { + SageAppManifestFile::new(path, "a".repeat(64), size).unwrap() + } + + fn sample_manifest(name: &str) -> SageAppPackageManifest { + let (manifest_version, sage_version) = SageAppPackageManifestParts::v0_defaults(); + + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: name.to_string(), + icon: None, + sage_version, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![sample_manifest_file("index.html", 1)], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + async fn sample_app_named(base: &Path, db: &AppsDb, app_id: &str, name: &str) -> SharedSageApp { + let manifest = sample_manifest(name); + let granted = SageGrantedPermissionsInput::new([], [], BTreeMap::new()); + + let installed = install_app_from_source_for_test( + base, + granted, + FakeInstallSource { + manifest, + app_id: app_id.into(), + source: UserSageAppSource::url("https://example.com/app/").unwrap(), + }, + ) + .await + .unwrap(); + + let storage_id = db + .register_storage(installed.common().storage()) + .await + .unwrap(); + + let mut tx = db.begin_immediate().await.unwrap(); + + let origin_row_id = tx + .register_origin(installed.common().origin_id(), storage_id) + .await + .unwrap(); + + tx.insert_user_app(&installed, storage_id, origin_row_id) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + SharedSageApp::new(installed.into_sage_app()) + } + + fn without_system_apps(listed: Vec) -> Vec { + listed + .into_iter() + .filter(|entry| !matches!(entry, ListedSageApp::System(_))) + .collect() + } + + #[tokio::test] + async fn installed_apps_are_sorted_by_name() { + let base = tempdir().unwrap(); + let db = AppsDb::initialize(base.path()).await.unwrap(); + + let _alpha = sample_app_named(base.path(), &db, "a", "Alpha").await; + let _zeta = sample_app_named(base.path(), &db, "z", "Zeta").await; + + let listed = without_system_apps(list_installed_apps_internal(&db).await.unwrap()); + + let names: Vec<_> = listed + .into_iter() + .map(|entry| match entry { + ListedSageApp::User(app) => app.common().name().to_string(), + ListedSageApp::System(app) => app.common().name().to_string(), + ListedSageApp::Corrupted(app) => app.id().to_string(), + }) + .collect(); + + assert_eq!(names, vec!["Alpha".to_string(), "Zeta".to_string()]); + } +} diff --git a/crates/sage-apps/src/lifecycle/scope.rs b/crates/sage-apps/src/lifecycle/scope.rs new file mode 100644 index 000000000..919699953 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/scope.rs @@ -0,0 +1,36 @@ +use tauri::State; + +use crate::{AppState, SageAppWalletScope, SharedSageApp}; + +pub(crate) async fn ensure_app_is_enabled_for_scope( + app_state: &State<'_, AppState>, + app: &SharedSageApp, +) -> Result<(), String> { + if app_is_enabled_for_scope(app_state, app).await { + return Ok(()); + } + + Err("App is not enabled for the current scope".into()) +} + +async fn app_is_enabled_for_scope(app_state: &State<'_, AppState>, app: &SharedSageApp) -> bool { + let Some(fingerprint) = current_wallet_fingerprint(app_state).await else { + return false; + }; + + app.with(|sage_app| { + wallet_scope_allows_fingerprint(sage_app.common().wallet_scope(), fingerprint) + }) +} + +fn wallet_scope_allows_fingerprint(scope: &SageAppWalletScope, fingerprint: u32) -> bool { + match scope { + SageAppWalletScope::AllWallets => true, + SageAppWalletScope::SelectedWallets { fingerprints } => fingerprints.contains(&fingerprint), + } +} + +async fn current_wallet_fingerprint(app_state: &State<'_, AppState>) -> Option { + let state = app_state.lock().await; + state.config.global.fingerprint +} diff --git a/crates/sage-apps/src/lifecycle/snapshot.rs b/crates/sage-apps/src/lifecycle/snapshot.rs new file mode 100644 index 000000000..127eb43e1 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/snapshot.rs @@ -0,0 +1,212 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result as AnyResult, anyhow}; + +use crate::{ + MANIFEST_FILE_NAME, SageAppPackageManifest, SageAppSnapshot, SageAppUrl, bytes_sha256_hex, + security::get_with_ssrf_guard, +}; + +pub(crate) async fn download_bytes_with_limit(url: &str, max_bytes: u64) -> AnyResult> { + let response = get_with_ssrf_guard(url) + .await? + .error_for_status() + .with_context(|| format!("request failed for {url}"))?; + + if let Some(content_length) = response.content_length() { + ensure_within_max_response_size(url, content_length, max_bytes)?; + } + + let capacity = usize::try_from(max_bytes.min(1024 * 1024)).unwrap_or(1024 * 1024); + let mut bytes = Vec::with_capacity(capacity); + let mut response = response; + let mut received = 0u64; + + while let Some(chunk) = response + .chunk() + .await + .with_context(|| format!("failed to read response body from {url}"))? + { + let chunk_len = u64::try_from(chunk.len()).context("response chunk too large")?; + + received = received + .checked_add(chunk_len) + .context("response body size overflow")?; + + ensure_within_max_response_size(url, received, max_bytes)?; + + bytes.extend_from_slice(&chunk); + } + + Ok(bytes) +} + +async fn download_exact_bytes(url: &str, expected_size: u64) -> AnyResult> { + let bytes = download_bytes_with_limit(url, expected_size).await?; + + ensure_expected_size(url, &bytes, expected_size)?; + + Ok(bytes) +} + +fn ensure_within_max_response_size(url: &str, received: u64, max_bytes: u64) -> AnyResult<()> { + if received > max_bytes { + anyhow::bail!("response body from {url} exceeds maximum size {max_bytes}"); + } + + Ok(()) +} + +fn ensure_expected_size(url: &str, bytes: &[u8], expected_size: u64) -> AnyResult<()> { + if u64::try_from(bytes.len()).context("response body too large")? != expected_size { + anyhow::bail!("response body from {url} did not match expected size {expected_size}"); + } + + Ok(()) +} + +fn ensure_expected_hash(path: &str, bytes: &[u8], expected_hash: &str) -> AnyResult<()> { + let actual_hash = bytes_sha256_hex(bytes); + if actual_hash != expected_hash { + return Err(anyhow!( + "hash mismatch for {path}: expected {expected_hash}, got {actual_hash}" + )); + } + + Ok(()) +} + +fn write_file(path: &Path, bytes: &[u8]) -> AnyResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + + fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?; + + Ok(()) +} + +pub async fn download_url_snapshot( + snapshot_dir: &Path, + app_url: &SageAppUrl, + manifest: &SageAppPackageManifest, + manifest_hash: &str, +) -> AnyResult { + if snapshot_dir.exists() { + fs::remove_dir_all(snapshot_dir).with_context(|| { + format!( + "failed to remove existing snapshot dir {}", + snapshot_dir.display() + ) + })?; + } + + fs::create_dir_all(snapshot_dir) + .with_context(|| format!("failed to create snapshot dir {}", snapshot_dir.display()))?; + + for file in manifest.files() { + let url = app_url.join(file.path())?; + let bytes = download_exact_bytes(&url, file.size()).await?; + + ensure_expected_hash(file.path(), &bytes, file.sha256())?; + + let output_path = snapshot_dir.join(PathBuf::from(file.path())); + write_file(&output_path, &bytes)?; + } + + SageAppSnapshot::new( + manifest_hash.to_string(), + snapshot_dir.to_string_lossy().to_string(), + manifest.clone(), + ) +} + +pub(crate) fn write_snapshot_manifest(snapshot: &SageAppSnapshot) -> anyhow::Result<()> { + let manifest_path = Path::new(snapshot.snapshot_dir()).join(MANIFEST_FILE_NAME); + + fs::write( + &manifest_path, + serde_json::to_string_pretty(snapshot.manifest())?, + ) + .with_context(|| { + format!( + "failed to write snapshot manifest {}", + manifest_path.display() + ) + })?; + + Ok(()) +} + +pub(crate) fn fresh_snapshot_dir(app_dir: &Path) -> PathBuf { + app_dir + .join("snapshots") + .join(uuid::Uuid::new_v4().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ensure_expected_size_accepts_exact_size() { + ensure_expected_size("https://example.com/index.html", b"hello", 5).unwrap(); + } + + #[test] + fn ensure_within_max_response_size_accepts_limit() { + ensure_within_max_response_size("https://example.com/index.html", 5, 5).unwrap(); + } + + #[test] + fn ensure_within_max_response_size_rejects_over_limit() { + let err = ensure_within_max_response_size("https://example.com/index.html", 6, 5) + .expect_err("over-limit body must be rejected"); + + assert!( + err.to_string().contains("exceeds maximum size 5"), + "unexpected error: {err}" + ); + } + + #[test] + fn ensure_expected_size_rejects_larger_body() { + let err = ensure_expected_size("https://example.com/index.html", b"too-large", 3) + .expect_err("oversized body must be rejected"); + assert!( + err.to_string().contains("did not match expected size"), + "unexpected error: {err}" + ); + } + + #[test] + fn ensure_expected_size_rejects_smaller_body() { + let err = ensure_expected_size("https://example.com/index.html", b"tiny", 6) + .expect_err("undersized body must be rejected"); + assert!( + err.to_string().contains("did not match expected size"), + "unexpected error: {err}" + ); + } + + #[test] + fn ensure_expected_hash_accepts_matching_hash() { + let bytes = b"hello"; + let hash = bytes_sha256_hex(bytes); + ensure_expected_hash("index.html", bytes, &hash).unwrap(); + } + + #[test] + fn ensure_expected_hash_rejects_mismatch() { + let err = ensure_expected_hash("index.html", b"wrong", &bytes_sha256_hex(b"right")) + .expect_err("hash mismatch must be rejected"); + assert!( + err.to_string().contains("hash mismatch"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/sage-apps/src/lifecycle/storage.rs b/crates/sage-apps/src/lifecycle/storage.rs new file mode 100644 index 000000000..9ef89c91f --- /dev/null +++ b/crates/sage-apps/src/lifecycle/storage.rs @@ -0,0 +1,305 @@ +use std::path::Path; + +use anyhow::Result as AnyResult; +#[cfg(any(target_os = "macos", target_os = "ios"))] +use anyhow::anyhow; +use tauri::{AppHandle, Manager, State, command}; +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "windows"))] +use uuid::Uuid; +#[cfg(target_os = "windows")] +use {anyhow::Context, std::fs}; + +#[cfg(target_os = "windows")] +use crate::data_directory_for; +#[cfg(any(target_os = "macos", target_os = "ios"))] +use crate::parse_data_store_id; +use crate::{ + AppMutationManager, AppsHostState, CreateInstalledRuntimeArgs, OriginCleanupRuntimeTarget, + ResolvedStoppedApp, SageAppStorage, SharedSageApp, find_runtime_by_app_id_optional, + fresh_origin_id, resolve_stopped_app, run_origin_cleanup, start_user_app, +}; + +pub(crate) struct RegisteredSageAppStorage { + pub(crate) storage_id: i64, + pub(crate) storage: SageAppStorage, +} + +#[command] +#[specta::specta] +pub async fn apps_clear_runtime_browsing_data( + app_handle: AppHandle, + app_id: String, +) -> Result<(), String> { + let apps_state: State<'_, AppsHostState> = app_handle.state(); + + let was_running = find_runtime_by_app_id_optional(&apps_state, &app_id) + .await + .is_some(); + + let resolved_app = resolve_stopped_app(&app_handle, &app_id) + .await + .map_err(|e| e.to_string())?; + + rotate_stopped_app_storage_and_origin(&app_handle, &apps_state, &resolved_app).await?; + + drop(resolved_app); + + if was_running { + start_user_app( + &app_handle, + &apps_state, + CreateInstalledRuntimeArgs { + app_id, + focus: Some(true), + }, + ) + .await?; + } + + Ok(()) +} + +pub(crate) async fn allocate_new_storage( + app: &AppHandle, + host_state: &State<'_, AppsHostState>, + base_path: &Path, +) -> AnyResult { + #[cfg(any(target_os = "macos", target_os = "ios"))] + let storage = allocate_new_os_storage(app, base_path).await?; + + #[cfg(target_os = "windows")] + let storage = allocate_new_os_storage(app, base_path)?; + + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] + let storage = allocate_new_os_storage(app, base_path); + + let storage_id = host_state.db.register_storage(&storage).await?; + + Ok(RegisteredSageAppStorage { + storage_id, + storage, + }) +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub async fn allocate_new_os_storage( + app: &AppHandle, + _base_path: &Path, +) -> AnyResult { + loop { + let identifier = *Uuid::new_v4().as_bytes(); + let existing_ids = app + .fetch_data_store_identifiers() + .await + .map_err(|err| anyhow!("failed to fetch data store identifiers: {err}"))?; + + if existing_ids.iter().all(|existing| *existing != identifier) { + return Ok(SageAppStorage::AppleDataStore { + identifier_hex: hex::encode(identifier), + }); + } + } +} + +#[cfg(target_os = "windows")] +pub fn allocate_new_os_storage(_app: &AppHandle, base_path: &Path) -> AnyResult { + let profiles_root = base_path.join("profiles"); + fs::create_dir_all(&profiles_root).with_context(|| { + format!( + "failed to create profiles directory {}", + profiles_root.display() + ) + })?; + + loop { + let directory_name = format!("profile-{}", Uuid::new_v4()); + let candidate = profiles_root.join(&directory_name); + + if !candidate.exists() { + return Ok(SageAppStorage::WindowsProfile { directory_name }); + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] +pub fn allocate_new_os_storage(_app: &AppHandle, _base_path: &Path) -> SageAppStorage { + SageAppStorage::Unmanaged +} + +pub async fn process_pending_storage_cleanup(app: &AppHandle, _base_path: &Path) -> AnyResult<()> { + let host_state: State<'_, AppsHostState> = app.state(); + + let cleanup_targets = host_state + .db + .list_abandoned_storage_cleanup_targets() + .await?; + tracing::info!("found {} abandoned cleanup targets", cleanup_targets.len()); + + for target in cleanup_targets { + tracing::info!( + storage_id = target.storage_id, + origins = ?target.origin_ids, + storage = ?target.storage, + "cleaning abandoned storage" + ); + + match target.storage { + SageAppStorage::Unmanaged => { + for origin_id in &target.origin_ids { + run_origin_cleanup( + app, + &host_state, + OriginCleanupRuntimeTarget { + origin_id: origin_id.clone(), + storage: target.storage.clone(), + }, + ) + .await?; + } + } + + _ => { + #[cfg(any(target_os = "macos", target_os = "ios"))] + clear_app_storage_by_target(app, &target.storage) + .await + .map_err(anyhow::Error::msg)?; + + #[cfg(target_os = "windows")] + clear_app_storage_by_target(app, &target.storage).map_err(anyhow::Error::msg)?; + + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] + clear_app_storage_by_target(app, &target.storage); + } + } + tracing::info!(storage_id = target.storage_id, "origin cleanup completed"); + + host_state + .db + .delete_origins_for_abandoned_storage(target.storage_id) + .await?; + + host_state + .db + .delete_abandoned_storage(target.storage_id) + .await?; + } + + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub async fn clear_app_storage_by_target( + _app: &AppHandle, + target: &SageAppStorage, +) -> Result<(), String> { + match target { + SageAppStorage::AppleDataStore { identifier_hex } => { + let target_id = parse_data_store_id(identifier_hex)?; + let existing_ids = _app + .fetch_data_store_identifiers() + .await + .map_err(|e| format!("failed to fetch data store identifiers: {e}"))?; + + if existing_ids.contains(&target_id) { + _app.remove_data_store(target_id) + .await + .map_err(|e| format!("failed to remove data store: {e}"))?; + } + } + + SageAppStorage::WindowsProfile { .. } | SageAppStorage::Unmanaged => {} + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn clear_app_storage_by_target( + _app: &AppHandle, + target: &SageAppStorage, +) -> Result<(), String> { + match target { + #[cfg(target_os = "windows")] + SageAppStorage::WindowsProfile { directory_name } => { + let app_data_dir = _app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + + let profile_dir = app_data_dir.join(data_directory_for(directory_name)); + + match fs::remove_dir_all(&profile_dir) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "failed to remove profile dir {}: {err}", + profile_dir.display() + )); + } + } + } + + SageAppStorage::AppleDataStore { .. } | SageAppStorage::Unmanaged => {} + } + + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] +pub fn clear_app_storage_by_target(_app: &AppHandle, _target: &SageAppStorage) {} + +pub async fn rotate_stopped_app_storage_and_origin( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + resolved_app: &ResolvedStoppedApp, +) -> Result<(), String> { + let app = resolved_app.with_app(SharedSageApp::clone); + + rotate_app_storage_and_origin(app_handle, apps_state, &app).await +} + +pub(crate) async fn rotate_app_storage_and_origin( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: &SharedSageApp, +) -> Result<(), String> { + let app_id = app.id(); + + let base_path = app_handle + .path() + .app_data_dir() + .map_err(|err| format!("failed to resolve app data dir: {err}"))?; + + let next_storage = allocate_new_storage(app_handle, apps_state, &base_path) + .await + .map_err(|err| format!("failed to allocate rotated storage: {err}"))?; + + let next_origin_id = fresh_origin_id(&app_id); + + let manager = AppMutationManager::new(app_handle, apps_state); + + manager + .mutate_shared_app(app, |ctx| { + Box::pin(async move { + let origin_row_id = ctx + .tx() + .register_origin(&next_origin_id, next_storage.storage_id) + .await?; + + ctx.tx() + .update_app_assignment(&app_id, next_storage.storage_id, origin_row_id) + .await?; + + ctx.draft_mut().replace_storage_and_origin( + next_storage.storage.clone(), + next_origin_id, + false, + )?; + + Ok(()) + }) + }) + .await + .map_err(|err| format!("failed to rotate app storage/origin: {err}")) +} diff --git a/crates/sage-apps/src/lifecycle/uninstall.rs b/crates/sage-apps/src/lifecycle/uninstall.rs new file mode 100644 index 000000000..6322cced4 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/uninstall.rs @@ -0,0 +1,72 @@ +use std::{fs, io}; + +use tauri::{AppHandle, Manager, State, command}; + +use crate::{ + AppState, AppsHostState, ResolveStoppedError, Result, apps_root, emit_listed_apps_changed, + resolve_stopped_app, +}; + +#[command] +#[specta::specta] +pub async fn apps_uninstall_app( + app_handle: AppHandle, + state: State<'_, AppState>, + app_id: String, +) -> Result<()> { + let base_path = { + let state = state.lock().await; + state.path.clone() + }; + + let dir = apps_root(&base_path).join(&app_id); + + match resolve_stopped_app(&app_handle, &app_id).await { + Ok(_) | Err(ResolveStoppedError::AppDirMissing) => {} + + Err(ResolveStoppedError::CloseAttemptsHit) => { + tracing::error!("[uninstall_app] close attempts hit {app_id}"); + return Err(io::Error::other( + "failed to uninstall app because runtime could not be stopped", + ) + .into()); + } + } + + let host_state: State<'_, AppsHostState> = app_handle.state(); + + let mut tx = host_state.db.begin_immediate().await.map_err(|err| { + io::Error::other(format!( + "failed to begin uninstall transaction for {app_id}: {err}" + )) + })?; + + if let Err(err) = tx.delete_user_app(&app_id).await { + tx.rollback().await; + + return Err( + io::Error::other(format!("failed to delete app {app_id} from db: {err}")).into(), + ); + } + + if let Err(err) = tx.commit().await { + return Err(io::Error::other(format!( + "failed to commit uninstall transaction for {app_id}: {err}" + )) + .into()); + } + + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|err| { + io::Error::other(format!( + "failed to remove installed app {} at {}: {err}", + app_id, + dir.display() + )) + })?; + } + + emit_listed_apps_changed(&app_handle, &host_state).await; + + Ok(()) +} diff --git a/crates/sage-apps/src/lifecycle/update.rs b/crates/sage-apps/src/lifecycle/update.rs new file mode 100644 index 000000000..901b539e5 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update.rs @@ -0,0 +1,16 @@ +mod apply; +mod background; +mod check; +mod commands; +mod permissions; +mod scope; +mod types; + +pub use background::*; +pub use commands::*; + +pub(crate) use apply::*; +pub(crate) use check::*; +pub(crate) use permissions::*; +pub(crate) use scope::*; +pub(crate) use types::*; diff --git a/crates/sage-apps/src/lifecycle/update/apply.rs b/crates/sage-apps/src/lifecycle/update/apply.rs new file mode 100644 index 000000000..2d3ca9240 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/apply.rs @@ -0,0 +1,354 @@ +use std::collections::BTreeMap; +use std::{fs, io}; + +use tauri::{AppHandle, Manager, State}; + +use crate::{ + AppMutationManager, AppsHostState, CreateInstalledRuntimeArgs, ResolvedApp, Result, SageApp, + SageAppView, SageGrantedPermissionsInput, SharedSageApp, UserSageAppPendingUpdate, + download_url_snapshot, emit_pending_update_changed, find_active_taskbar_runtime, + find_runtime_by_app_id_optional, fresh_snapshot_dir, get_sage_window, resolve_app, + resolve_stopped_app, start_app_update_runtime, start_user_app, write_snapshot_manifest, +}; + +pub(crate) async fn apply_app_update_inner( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + additional_granted_permissions_input: Option, + expected_manifest_hash: Option<&str>, +) -> Result { + let _update_guard = apps_state + .try_begin_app_update(app_id) + .map_err(io::Error::other)?; + + let preflight = preflight_apply_app_update(app_handle, apps_state, app_id).await?; + + if preflight.should_review && additional_granted_permissions_input.is_none() { + open_update_runtime( + app_handle, + apps_state, + app_id, + None, + "failed to start app update review runtime", + ) + .await; + + let resolved = resolve_app(app_handle, app_id).await.map_err(|err| { + io::Error::other(format!("failed to read installed app {app_id}: {err}")) + })?; + + return Ok(resolved.with_app(|app| app.into())); + } + + ensure_pending_update_matches_review( + app_id, + preflight.pending.manifest_hash(), + expected_manifest_hash, + )?; + + let reopen_after_update = ReopenAfterUpdate::capture(app_handle, apps_state, app_id).await?; + + let app = execute_app_update( + app_handle, + app_id, + preflight.pending, + additional_granted_permissions_input, + ) + .await?; + + emit_pending_update_changed(app_handle, apps_state, &app).await; + + if reopen_after_update.should_reopen + && let Err(err) = start_user_app( + app_handle, + apps_state, + CreateInstalledRuntimeArgs { + app_id: app_id.to_string(), + focus: Some(reopen_after_update.should_focus), + }, + ) + .await + { + tracing::error!( + error = %err, + app_id = %app_id, + focus = reopen_after_update.should_focus, + "failed to reopen app runtime after update" + ); + } + + Ok(app.into()) +} + +pub(crate) async fn try_auto_apply_pending_update( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result { + let should_attempt_apply = { + let resolved = resolve_app(app_handle, app_id).await.map_err(|err| { + io::Error::other(format!("failed to read installed app {app_id}: {err}")) + })?; + + if let ResolvedApp::Running(_) = resolved { + return Ok(false); + } + + let has_pending_update = resolved.with_app(|app| { + app.with(|sage_app| { + sage_app + .as_user() + .and_then(|user_app| user_app.pending_update()) + .is_some() + }) + }); + + if !has_pending_update { + return Ok(false); + } + + let should_review = resolved.with_app(SharedSageApp::should_review_pending_update); + + !should_review + }; + + if !should_attempt_apply { + return Ok(false); + } + + apply_app_update_inner(app_handle, apps_state, app_id, None, None).await?; + + Ok(true) +} + +fn ensure_pending_update_matches_review( + app_id: &str, + pending_manifest_hash: &str, + expected_manifest_hash: Option<&str>, +) -> Result<()> { + if let Some(expected) = expected_manifest_hash + && pending_manifest_hash != expected + { + return Err(io::Error::other(format!( + "pending update for {app_id} changed since it was reviewed; re-check the update before applying" + )) + .into()); + } + + Ok(()) +} + +async fn execute_app_update( + app_handle: &AppHandle, + app_id: &str, + pending: UserSageAppPendingUpdate, + additional_granted_permissions_input: Option, +) -> Result { + let apps_state: State<'_, AppsHostState> = app_handle.state(); + + let resolved = resolve_stopped_app(app_handle, app_id) + .await + .map_err(|err| { + io::Error::other(format!( + "failed to resolve stopped app {app_id} for update: {err}" + )) + })?; + + let app = resolved.into_app(); + + let granted_permissions_input = app + .try_with(|sage_app| { + let base = SageGrantedPermissionsInput::from(( + sage_app.common().granted_permissions(), + pending.manifest().permissions(), + )); + + Ok::<_, anyhow::Error>(match additional_granted_permissions_input { + Some(additional) => base.with_additional(additional), + None => base, + }) + }) + .map_err(io::Error::other)?; + + let app_dir = app.with(SageApp::app_path); + + let old_snapshot_dir = + app.with(|app| app.common().active_snapshot().snapshot_dir().to_string()); + + let snapshot_dir = fresh_snapshot_dir(&app_dir); + + fs::create_dir_all(&snapshot_dir).map_err(|err| { + io::Error::other(format!( + "failed to create update snapshot directory {}: {err}", + snapshot_dir.display() + )) + })?; + + let snapshot = download_url_snapshot( + &snapshot_dir, + pending.app_url(), + pending.manifest(), + pending.manifest_hash(), + ) + .await + .map_err(|err| io::Error::other(format!("failed to download update snapshot: {err}")))?; + + write_snapshot_manifest(&snapshot).map_err(|err| { + io::Error::other(format!("failed to write update snapshot manifest: {err}")) + })?; + + let new_snapshot_dir = snapshot.snapshot_dir().to_string(); + + let granted_permissions = granted_permissions_input + .resolve(pending.manifest().permissions()) + .map_err(|err| io::Error::other(format!("invalid update permissions: {err}")))?; + + let manager = AppMutationManager::new(app_handle, &apps_state); + + manager + .mutate_shared_app(&app, move |ctx| { + Box::pin(async move { + ctx.draft_mut() + .app_mut() + .apply_update(&pending, granted_permissions, snapshot)?; + + ctx.draft_mut().app_mut().set_pending_update(None)?; + + Ok(()) + }) + }) + .await + .map_err(io::Error::other)?; + + if old_snapshot_dir != new_snapshot_dir { + let _ = fs::remove_dir_all(old_snapshot_dir); + } + + Ok(app) +} + +async fn open_update_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + issue: Option, + error_message: &'static str, +) { + let mut query = BTreeMap::new(); + query.insert("appId".to_string(), app_id.to_string()); + + if let Some(issue) = issue { + query.insert("issue".to_string(), issue); + } + + let _ = start_app_update_runtime(app_handle, apps_state, app_id.to_string(), query) + .await + .map_err(|err| { + tracing::error!( + error = %err, + app_id = %app_id, + "{error_message}" + ); + err + }); +} + +struct ApplyAppUpdatePreflight { + pending: UserSageAppPendingUpdate, + should_review: bool, +} + +async fn preflight_apply_app_update( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result { + let resolved = resolve_app(app_handle, app_id) + .await + .map_err(|err| io::Error::other(format!("failed to read installed app {app_id}: {err}")))?; + + let pending = resolved.with_app(|app| { + app.try_with(|sage_app| { + let user_app = sage_app + .as_user() + .ok_or_else(|| anyhow::anyhow!("system app cannot receive user update"))?; + + user_app + .pending_update() + .cloned() + .ok_or_else(|| anyhow::anyhow!("app {app_id} has no pending update")) + }) + }); + + let pending = match pending { + Ok(pending) => pending, + Err(err) => { + let app = resolved.clone_app_for_operation(); + emit_pending_update_changed(app_handle, apps_state, &app).await; + + return Err(io::Error::other(err).into()); + } + }; + + let should_review = resolved.with_app(SharedSageApp::should_review_pending_update); + + Ok(ApplyAppUpdatePreflight { + pending, + should_review, + }) +} + +#[derive(Debug, Clone, Copy)] +struct ReopenAfterUpdate { + should_reopen: bool, + should_focus: bool, +} + +impl ReopenAfterUpdate { + async fn capture( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + ) -> anyhow::Result { + let should_reopen = find_runtime_by_app_id_optional(apps_state, app_id) + .await + .is_some(); + + if !should_reopen { + return Ok(Self { + should_reopen: false, + should_focus: false, + }); + } + + let sage_window = get_sage_window(app_handle).map_err(|err| anyhow::anyhow!(err))?; + let active_taskbar_runtime = + find_active_taskbar_runtime(apps_state, sage_window.label()).await; + let should_focus = active_taskbar_runtime.is_some_and(|runtime| runtime.app_id() == app_id); + + Ok(Self { + should_reopen: true, + should_focus, + }) + } +} + +#[cfg(test)] +mod tests { + use super::ensure_pending_update_matches_review; + + #[test] + fn reviewed_manifest_hash_must_match_pending_update() { + ensure_pending_update_matches_review("app.test", "current", None).unwrap(); + ensure_pending_update_matches_review("app.test", "current", Some("current")).unwrap(); + + let err = ensure_pending_update_matches_review("app.test", "current", Some("reviewed")) + .expect_err("a stale review must not authorize a different pending update"); + + assert!( + err.to_string().contains("changed since it was reviewed"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/sage-apps/src/lifecycle/update/background.rs b/crates/sage-apps/src/lifecycle/update/background.rs new file mode 100644 index 000000000..88011f546 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/background.rs @@ -0,0 +1,93 @@ +use std::time::Duration; + +use futures::future::join_all; +use tauri::{AppHandle, Manager, State}; + +use crate::{ + AppsHostState, ListedSageApp, check_app_update_inner, list_installed_apps_internal, + try_auto_apply_pending_update, +}; + +pub fn start_background_app_update_checker(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(5000)).await; + tracing::info!("starting background app update checker"); + + let mut interval = tokio::time::interval(Duration::from_secs(60 * 10)); + + loop { + if let Err(err) = run_background_app_update_check(&app_handle).await { + tracing::error!( + error = %err, + "background app update check failed" + ); + } + + interval.tick().await; + } + }); +} + +async fn run_background_app_update_check(app_handle: &AppHandle) -> anyhow::Result<()> { + let host_state: State<'_, AppsHostState> = app_handle.state(); + + let installed_apps = list_installed_apps_internal(&host_state.db).await?; + + let app_ids = installed_apps + .into_iter() + .filter_map(|installed_app| match installed_app { + ListedSageApp::User(app) => Some(app.common().id().to_string()), + ListedSageApp::System(_) | ListedSageApp::Corrupted(_) => None, + }) + .collect::>(); + + let checks = app_ids.into_iter().map(|app_id| { + let app_handle = app_handle.clone(); + let host_state = host_state.clone(); + + async move { + let result = check_app_update_inner(&app_handle, &host_state, &app_id).await; + + if let Err(err) = &result { + tracing::warn!( + error = %err, + app_id = %app_id, + "failed to check app update in background" + ); + + return result; + } + + let auto_update_enabled = host_state + .db + .get_auto_update_enabled() + .await + .unwrap_or(false); + + if auto_update_enabled { + match try_auto_apply_pending_update(&app_handle, &host_state, &app_id).await { + Ok(true) => { + tracing::info!( + app_id = %app_id, + "auto-applied app update" + ); + } + Ok(false) => {} + Err(err) => { + tracing::warn!( + error = %err, + app_id = %app_id, + "failed to auto-apply app update" + ); + } + } + } + + result + } + }); + + let _results = join_all(checks).await; + + Ok(()) +} diff --git a/crates/sage-apps/src/lifecycle/update/check.rs b/crates/sage-apps/src/lifecycle/update/check.rs new file mode 100644 index 000000000..8e4615835 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/check.rs @@ -0,0 +1,182 @@ +use std::io; + +use tauri::{AppHandle, State}; + +use crate::{ + AppMutationManager, AppsHostState, ResolvedApp, Result, SageApp, SageAppSnapshot, + SageAppUrlPreview, SharedSageApp, UserSageAppPendingUpdate, UserSageAppSource, + emit_pending_update_changed, fetch_url_manifest, fetch_url_manifest_preview, resolve_app, +}; + +pub(crate) enum AppUpdatePreviewResult { + None, + AlreadyPending(SageAppUrlPreview), + New(SageAppUrlPreview), +} + +pub(crate) async fn check_app_update_inner( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result> { + let resolved = resolve_app(app_handle, app_id) + .await + .map_err(|err| io::Error::other(format!("failed to read installed app {app_id}: {err}")))?; + + let app = resolved.clone_app_for_operation(); + let mutation_manager = AppMutationManager::new(app_handle, apps_state); + + let preview = match preview_app_update(&resolved).await? { + AppUpdatePreviewResult::None => { + mutation_manager + .mutate_shared_app(&app, |ctx| { + Box::pin(async move { + ctx.draft_mut().app_mut().set_pending_update(None)?; + + Ok(()) + }) + }) + .await + .map_err(io::Error::other)?; + + emit_pending_update_changed(app_handle, apps_state, &app).await; + + return Ok(None); + } + + AppUpdatePreviewResult::AlreadyPending(preview) => { + emit_pending_update_changed(app_handle, apps_state, &app).await; + return Ok(Some(preview)); + } + + AppUpdatePreviewResult::New(preview) => preview, + }; + + let pending_update = match fetch_pending_update(&app).await { + Ok(pending_update) => pending_update, + + Err(err) => { + tracing::warn!( + error = %err, + app_id = %app_id, + "app update exists but pending update could not be prepared" + ); + + return Ok(Some(preview)); + } + }; + + mutation_manager + .mutate_shared_app(&app, |ctx| { + Box::pin(async move { + ctx.draft_mut() + .app_mut() + .set_pending_update(pending_update)?; + + Ok(()) + }) + }) + .await + .map_err(io::Error::other)?; + + tracing::info!(app_id = %app_id, "app update prepared successfully"); + + emit_pending_update_changed(app_handle, apps_state, &app).await; + + Ok(Some(preview)) +} + +async fn preview_app_update(app: &ResolvedApp) -> Result { + let shared_sage_app = app.clone_app_for_operation(); + + let deps = shared_sage_app.with(|app| match app { + SageApp::System(_) => None, + SageApp::User(user_app) => Some(( + user_app.source().clone(), + user_app.common().active_snapshot().clone(), + user_app.pending_update().cloned(), + )), + }); + + let Some((source, active_snapshot, existing_pending)) = deps else { + return Ok(AppUpdatePreviewResult::None); + }; + + let app_url = match source { + UserSageAppSource::Url { app_url } => app_url, + UserSageAppSource::Zip => return Ok(AppUpdatePreviewResult::None), + }; + + let (manifest_preview, manifest_hash) = fetch_url_manifest_preview(&app_url.manifest_url()) + .await + .map_err(|err| io::Error::other(format!("failed to fetch app manifest: {err}")))?; + + let preview = SageAppUrlPreview::new(&app_url, manifest_preview, manifest_hash) + .await + .map_err(|err| io::Error::other(format!("failed to preview app URL: {err}")))?; + + if preview.manifest_hash() == active_snapshot.manifest_hash() + && let Some(full_manifest) = preview.full_manifest() + && full_manifest == active_snapshot.manifest() + { + return Ok(AppUpdatePreviewResult::None); + } + + if let Some(existing_pending) = existing_pending + && let Some(full_manifest) = preview.full_manifest() + && existing_pending.manifest_hash() == preview.manifest_hash() + && existing_pending.manifest() == full_manifest + { + return Ok(AppUpdatePreviewResult::AlreadyPending(preview)); + } + + Ok(AppUpdatePreviewResult::New(preview)) +} + +async fn fetch_pending_update(app: &SharedSageApp) -> Result> { + struct FetchDeps { + source: UserSageAppSource, + active_snapshot: SageAppSnapshot, + } + + let deps = app.with(|app| match app { + SageApp::System(_) => None, + SageApp::User(user_app) => Some(FetchDeps { + source: user_app.source().clone(), + active_snapshot: user_app.common().active_snapshot().clone(), + }), + }); + + let Some(deps) = deps else { + return Ok(None); + }; + + let app_url = match deps.source { + UserSageAppSource::Url { app_url } => app_url.clone(), + UserSageAppSource::Zip => return Ok(None), + }; + + let (manifest, manifest_hash) = fetch_url_manifest(&app_url.manifest_url()) + .await + .map_err(|err| io::Error::other(format!("failed to fetch app manifest: {err}")))?; + + let preview = SageAppUrlPreview::from_full_manifest(&app_url, manifest, manifest_hash) + .await + .map_err(|err| io::Error::other(format!("failed to preview app URL: {err}")))?; + + let manifest = preview + .require_full_manifest() + .map_err(|err| io::Error::other(format!("update manifest is not installable: {err}")))?; + + if preview.manifest_hash() == deps.active_snapshot.manifest_hash() + && manifest == deps.active_snapshot.manifest() + { + return Ok(None); + } + + Ok(Some(UserSageAppPendingUpdate::new( + app_url, + preview.manifest_hash().to_string(), + manifest.clone(), + ))) +} diff --git a/crates/sage-apps/src/lifecycle/update/commands.rs b/crates/sage-apps/src/lifecycle/update/commands.rs new file mode 100644 index 000000000..316d9874a --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/commands.rs @@ -0,0 +1,26 @@ +use tauri::{AppHandle, State, command}; + +use crate::{ + AppsHostState, Result, SageAppUrlPreview, SageAppView, apply_app_update_inner, + check_app_update_inner, +}; + +#[command] +#[specta::specta] +pub async fn apps_check_app_update( + apps_state: State<'_, AppsHostState>, + app_handle: AppHandle, + app_id: String, +) -> Result> { + check_app_update_inner(&app_handle, &apps_state, &app_id).await +} + +#[command] +#[specta::specta] +pub async fn apps_apply_app_update( + app_handle: AppHandle, + apps_state: State<'_, AppsHostState>, + app_id: String, +) -> Result { + apply_app_update_inner(&app_handle, &apps_state, &app_id, None, None).await +} diff --git a/crates/sage-apps/src/lifecycle/update/permissions.rs b/crates/sage-apps/src/lifecycle/update/permissions.rs new file mode 100644 index 000000000..ffb89b039 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/permissions.rs @@ -0,0 +1,707 @@ +use std::path::Path; + +use anyhow::Context; +use tauri::{AppHandle, Manager, State}; + +use crate::{ + AppMutationManager, AppState, AppUpdateResult, AppsHostState, CreateInstalledRuntimeArgs, + GrantCapabilityOutcome, GrantNetworkWhitelistOutcome, GrantedCapabilitiesChangeEvent, + GrantedNetworkWhitelistChangeEvent, GrantedPermissionsChange, SageAppRuntimeVisibility, + SageGrantedPermissions, SageNetworkWhitelistEntry, SharedSageApp, UserBridgeCapability, + emit_user_runtime_event_to_app_id, find_runtime_by_app_id_optional, kill_taskbar_runtime, + reload_app_runtime, resolve_app, start_user_app, +}; + +pub async fn update_app_permissions_for_app( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: &SharedSageApp, + granted_permissions: &SageGrantedPermissions, +) -> anyhow::Result<()> { + apply_granted_permissions(app_handle, apps_state, app, granted_permissions).await?; + Ok(()) +} + +pub async fn grant_capability( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + _base_path: &Path, + app_id: &str, + capability: UserBridgeCapability, +) -> anyhow::Result { + let update = grant_capability_internal(app_handle, apps_state, app_id, capability).await?; + + Ok(GrantCapabilityOutcome::from_update(capability, &update)) +} + +pub async fn grant_network_whitelist_entry( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + _base_path: &Path, + app_id: &str, + network_id: Option<&str>, + entry: &SageNetworkWhitelistEntry, +) -> anyhow::Result { + let update = + grant_network_whitelist_entry_internal(app_handle, apps_state, app_id, network_id, entry) + .await?; + + Ok(GrantNetworkWhitelistOutcome::from_update( + network_id, entry, &update, + )) +} + +async fn grant_capability_internal( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + capability: UserBridgeCapability, +) -> anyhow::Result { + let app = resolve_app_for_permission_update(app_handle, app_id).await?; + + let granted_permissions = app.try_with(|sage_app| { + sage_app + .common() + .granted_permissions() + .with_capability_added(sage_app.common().requested_permissions(), capability) + })?; + + apply_granted_permissions(app_handle, apps_state, &app, &granted_permissions).await +} + +async fn grant_network_whitelist_entry_internal( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + network_id: Option<&str>, + entry: &SageNetworkWhitelistEntry, +) -> anyhow::Result { + let app = resolve_app_for_permission_update(app_handle, app_id).await?; + + let granted_permissions = app.try_with(|sage_app| match network_id { + Some(network_id) => sage_app + .common() + .granted_permissions() + .with_network_whitelist_entry_for_network_added( + sage_app.common().requested_permissions(), + network_id, + entry.clone(), + ), + None => sage_app + .common() + .granted_permissions() + .with_network_whitelist_entry_added( + sage_app.common().requested_permissions(), + entry.clone(), + ), + })?; + + apply_granted_permissions(app_handle, apps_state, &app, &granted_permissions).await +} + +async fn resolve_app_for_permission_update( + app_handle: &AppHandle, + app_id: &str, +) -> anyhow::Result { + let resolved_app = resolve_app(app_handle, app_id) + .await + .map_err(|_| anyhow::anyhow!("app not found"))?; + + Ok(resolved_app.clone_app_for_operation()) +} + +async fn apply_granted_permissions( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: &SharedSageApp, + granted_permissions: &SageGrantedPermissions, +) -> anyhow::Result { + let app_id = app.id(); + + let running_before = find_runtime_by_app_id_optional(apps_state, &app_id) + .await + .map(|runtime| { + runtime.with_runtime(|record| { + ( + record.visibility() == SageAppRuntimeVisibility::Visible, + record + .app() + .with(|app| app.common().has_persistent_webview_storage()), + ) + }) + }); + + let previous_persistent_storage = running_before.map_or_else( + || app.with(|sage_app| sage_app.common().has_persistent_webview_storage()), + |(_, persistent)| persistent, + ); + + let update_result = + mutate_granted_permissions(app_handle, apps_state, app, granted_permissions.clone()) + .await?; + + emit_granted_permissions_change(app_handle, &app_id, update_result.change()).await; + + let next_persistent_storage = + app.with(|sage_app| sage_app.common().has_persistent_webview_storage()); + + let persistent_storage_changed = previous_persistent_storage != next_persistent_storage; + + if persistent_storage_changed { + if let Some((was_visible, _)) = running_before { + kill_taskbar_runtime( + app_handle, + apps_state, + &app_id, + "persistent_webview_storage_permission_changed", + ) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to kill app runtime after persistent storage permission change: {err}" + ) + })?; + + start_user_app( + app_handle, + apps_state, + CreateInstalledRuntimeArgs { + app_id, + focus: Some(was_visible), + }, + ) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to reopen app runtime after persistent storage permission change: {err}" + ) + })?; + } + + return Ok(update_result); + } + + if network_change_affects_current_network(app_handle, update_result.change()).await { + reload_app_runtime(app_handle, apps_state, &app_id) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to reload app runtime after relevant network permission change: {err}" + ) + })?; + } + + Ok(update_result) +} + +async fn mutate_granted_permissions( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: &SharedSageApp, + granted_permissions: SageGrantedPermissions, +) -> anyhow::Result { + let manager = AppMutationManager::new(app_handle, apps_state); + + manager + .mutate_shared_app(app, move |ctx| { + Box::pin(async move { + let previous = ctx.draft().app().common().granted_permissions().clone(); + + ctx.draft_mut() + .update_permissions(&granted_permissions) + .context("failed to update app permissions")?; + + let new = ctx.draft().app().common().granted_permissions().clone(); + + Ok(AppUpdateResult::new(GrantedPermissionsChange::diff( + &previous, &new, + ))) + }) + }) + .await + .map_err(anyhow::Error::msg) +} + +async fn emit_granted_permissions_change( + app_handle: &AppHandle, + app_id: &str, + change: &GrantedPermissionsChange, +) { + let capability_change = change.capabilities(); + + if !capability_change.is_empty() { + let _ = emit_user_runtime_event_to_app_id( + app_handle, + app_id, + GrantedCapabilitiesChangeEvent::from_change(capability_change), + ) + .await; + } + + let network_change = change.network_whitelist(); + + if !network_change.is_empty() { + let _ = emit_user_runtime_event_to_app_id( + app_handle, + app_id, + GrantedNetworkWhitelistChangeEvent::from_change(network_change), + ) + .await; + } +} + +async fn network_change_affects_current_network( + app_handle: &AppHandle, + change: &GrantedPermissionsChange, +) -> bool { + if !change.network_whitelist().is_empty() { + return true; + } + + let app_state = app_handle.state::(); + let network_id = { + let state = app_state.lock().await; + state.config.network.default_network.clone() + }; + + !change + .network_whitelist_by_network() + .for_network(&network_id) + .is_empty() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use tempfile::tempdir; + + use super::*; + use crate::{ + FakeInstallSource, SageApp, SageAppManifestFile, SageAppPackageManifest, + SageAppPackageManifestParts, SageGrantedPermissionsInput, SageRequestedCapabilities, + SageRequestedNetworkPermissions, SageRequestedNetworkWhitelist, SageRequestedPermissions, + UserSageAppSource, install_app_from_source_for_test, + }; + + fn network_whitelist_entry(scheme: &str, host: &str) -> SageNetworkWhitelistEntry { + SageNetworkWhitelistEntry::new(scheme, host).unwrap() + } + + fn entries( + values: impl IntoIterator, + ) -> Vec { + values.into_iter().collect() + } + + fn caps(values: impl IntoIterator) -> Vec { + values.into_iter().collect() + } + + async fn sample_app(base: &Path, app_id: &str) -> SharedSageApp { + sample_app_with_requested_permissions(base, app_id, sample_requested_permissions()).await + } + + fn sample_requested_permissions() -> SageRequestedPermissions { + SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [network_whitelist_entry("https", "required.example.com")], + [network_whitelist_entry("wss", "optional.example.com")], + [], + ) + .unwrap(), + SageRequestedCapabilities::new( + [], + [ + UserBridgeCapability::WalletSendXch, + UserBridgeCapability::StoragePersistentWebview, + ], + ), + ) + .unwrap() + } + + async fn sample_app_with_requested_permissions( + base: &Path, + app_id: &str, + requested_permissions: SageRequestedPermissions, + ) -> SharedSageApp { + let (manifest_version, sage_version) = SageAppPackageManifestParts::v0_defaults(); + + let manifest = SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: "Test App".to_string(), + icon: None, + sage_version, + version: "1.0.0".to_string(), + permissions: requested_permissions, + files: vec![SageAppManifestFile::new("index.html", "a".repeat(64), 4).unwrap()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap(); + + let granted = SageGrantedPermissionsInput::new([], [], BTreeMap::new()); + + let installed = install_app_from_source_for_test( + base, + granted, + FakeInstallSource { + manifest, + app_id: app_id.into(), + source: UserSageAppSource::url("https://example.com/app/").unwrap(), + }, + ) + .await + .unwrap(); + + SharedSageApp::new(installed.into_sage_app()) + } + + fn preview_granted_permissions_update( + app: &SharedSageApp, + granted_permissions: &SageGrantedPermissions, + ) -> anyhow::Result<(AppUpdateResult, SageGrantedPermissions)> { + app.try_with(|sage_app| { + let previous = sage_app.common().granted_permissions().clone(); + + let mut common = sage_app.common().clone_durable(); + + common + .update_permissions(granted_permissions) + .context("failed to update app permissions")?; + + let new = common.granted_permissions().clone(); + + Ok(( + AppUpdateResult::new(GrantedPermissionsChange::diff(&previous, &new)), + new, + )) + }) + } + + #[tokio::test] + async fn update_app_permissions_includes_required_network_entries() { + let dir = tempdir().unwrap(); + let app = sample_app(dir.path(), "app-1").await; + + let granted = app + .try_with(|app| { + SageGrantedPermissions::new( + app.common().requested_permissions(), + [], + [], + BTreeMap::new(), + ) + }) + .unwrap(); + + let (update_result, normalized) = + preview_granted_permissions_update(&app, &granted).unwrap(); + + assert_eq!( + entries(update_result.change().network_whitelist().full.clone()), + [network_whitelist_entry("https", "required.example.com")] + ); + + assert_eq!( + entries(normalized.network().whitelist_iter().cloned()), + [network_whitelist_entry("https", "required.example.com")] + ); + } + + #[tokio::test] + async fn update_app_permissions_includes_required_network_specific_entries() { + let dir = tempdir().unwrap(); + let requested_permissions = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [], + [], + [( + "mainnet".to_string(), + SageRequestedNetworkWhitelist::new( + [network_whitelist_entry( + "https", + "mainnet-required.example.com", + )], + [network_whitelist_entry( + "wss", + "mainnet-optional.example.com", + )], + ), + )], + ) + .unwrap(), + SageRequestedCapabilities::empty(), + ) + .unwrap(); + let app = + sample_app_with_requested_permissions(dir.path(), "app-network", requested_permissions) + .await; + + let granted = app + .try_with(|app| { + SageGrantedPermissions::new( + app.common().requested_permissions(), + [], + [], + BTreeMap::new(), + ) + }) + .unwrap(); + + let (update_result, normalized) = + preview_granted_permissions_update(&app, &granted).unwrap(); + + assert_eq!( + update_result + .change() + .network_whitelist_by_network() + .for_network("mainnet") + .full, + [network_whitelist_entry( + "https", + "mainnet-required.example.com" + )] + ); + + assert_eq!( + normalized + .network() + .whitelist_by_network() + .get("mainnet") + .cloned() + .unwrap_or_default(), + [network_whitelist_entry( + "https", + "mainnet-required.example.com" + )] + .into_iter() + .collect() + ); + } + + #[tokio::test] + async fn grant_requested_capability_grants_optional_capability() { + let dir = tempdir().unwrap(); + let app = sample_app(dir.path(), "app-1").await; + + let granted = app + .try_with(|app| { + app.common().granted_permissions().with_capability_added( + app.common().requested_permissions(), + UserBridgeCapability::WalletSendXch, + ) + }) + .unwrap(); + + let (update_result, normalized) = + preview_granted_permissions_update(&app, &granted).unwrap(); + + let outcome = GrantCapabilityOutcome::from_update( + UserBridgeCapability::WalletSendXch, + &update_result, + ); + + match outcome { + GrantCapabilityOutcome::Granted { capability, change } => { + assert_eq!(capability, UserBridgeCapability::WalletSendXch); + assert_eq!(change.added, [UserBridgeCapability::WalletSendXch]); + assert!(change.removed.is_empty()); + assert_eq!(change.full, [UserBridgeCapability::WalletSendXch]); + } + GrantCapabilityOutcome::AlreadyGranted { .. } => { + panic!("expected capability to be newly granted"); + } + } + + assert_eq!( + caps(normalized.capabilities().copied()), + [UserBridgeCapability::WalletSendXch] + ); + } + + #[tokio::test] + async fn grant_requested_capability_returns_already_granted_when_present() { + let dir = tempdir().unwrap(); + let app = sample_app(dir.path(), "app-1").await; + + let granted = app + .try_with(|app| { + SageGrantedPermissions::new( + app.common().requested_permissions(), + [UserBridgeCapability::WalletSendXch], + [network_whitelist_entry("https", "required.example.com")], + BTreeMap::new(), + ) + }) + .unwrap(); + + let (_, normalized) = preview_granted_permissions_update(&app, &granted).unwrap(); + + let same_granted = app + .try_with(|app| { + normalized.with_capability_added( + app.common().requested_permissions(), + UserBridgeCapability::WalletSendXch, + ) + }) + .unwrap(); + + let previous_app = SharedSageApp::new({ + let mut sage_app = app.with(SageApp::clone_for_rollback).unwrap(); + sage_app + .common_mut() + .update_permissions(&normalized) + .unwrap(); + sage_app + }); + + let (update_result, _) = + preview_granted_permissions_update(&previous_app, &same_granted).unwrap(); + + let outcome = GrantCapabilityOutcome::from_update( + UserBridgeCapability::WalletSendXch, + &update_result, + ); + + match outcome { + GrantCapabilityOutcome::AlreadyGranted { + capability, + full_granted_capabilities, + } => { + assert_eq!(capability, UserBridgeCapability::WalletSendXch); + assert_eq!( + full_granted_capabilities, + [UserBridgeCapability::WalletSendXch] + ); + } + GrantCapabilityOutcome::Granted { .. } => { + panic!("expected already-granted outcome"); + } + } + } + + #[tokio::test] + async fn grant_requested_network_whitelist_entry_grants_optional_entry() { + let dir = tempdir().unwrap(); + let app = sample_app(dir.path(), "app-1").await; + + let entry = network_whitelist_entry("WSS", "OPTIONAL.EXAMPLE.COM"); + + let granted = app + .try_with(|app| { + app.common() + .granted_permissions() + .with_network_whitelist_entry_added( + app.common().requested_permissions(), + entry.clone(), + ) + }) + .unwrap(); + + let (update_result, normalized) = + preview_granted_permissions_update(&app, &granted).unwrap(); + + let outcome = GrantNetworkWhitelistOutcome::from_update(None, &entry, &update_result); + + match outcome { + GrantNetworkWhitelistOutcome::Granted { entry, change } => { + assert_eq!( + entry, + network_whitelist_entry("wss", "optional.example.com") + ); + assert_eq!( + change.added, + [ + network_whitelist_entry("https", "required.example.com"), + network_whitelist_entry("wss", "optional.example.com"), + ] + ); + assert!(change.removed.is_empty()); + assert_eq!( + change.full, + [ + network_whitelist_entry("https", "required.example.com"), + network_whitelist_entry("wss", "optional.example.com"), + ] + ); + } + GrantNetworkWhitelistOutcome::AlreadyGranted { .. } => { + panic!("expected network entry to be newly granted"); + } + } + + assert_eq!( + entries(normalized.network().whitelist_iter().cloned()), + [ + network_whitelist_entry("https", "required.example.com"), + network_whitelist_entry("wss", "optional.example.com"), + ] + ); + } + + #[tokio::test] + async fn grant_requested_network_whitelist_entry_returns_already_granted_when_present() { + let dir = tempdir().unwrap(); + let app = sample_app(dir.path(), "app-1").await; + + let granted = app + .try_with(|app| { + SageGrantedPermissions::new( + app.common().requested_permissions(), + [], + [network_whitelist_entry("https", "required.example.com")], + BTreeMap::new(), + ) + }) + .unwrap(); + + let (_, normalized) = preview_granted_permissions_update(&app, &granted).unwrap(); + + let entry = network_whitelist_entry("https", "required.example.com"); + + let same_granted = app + .try_with(|app| { + normalized.with_network_whitelist_entry_added( + app.common().requested_permissions(), + entry.clone(), + ) + }) + .unwrap(); + + let previous_app = SharedSageApp::new({ + let mut sage_app = app.with(SageApp::clone_for_rollback).unwrap(); + sage_app + .common_mut() + .update_permissions(&normalized) + .unwrap(); + sage_app + }); + + let (update_result, _) = + preview_granted_permissions_update(&previous_app, &same_granted).unwrap(); + + let outcome = GrantNetworkWhitelistOutcome::from_update(None, &entry, &update_result); + + match outcome { + GrantNetworkWhitelistOutcome::AlreadyGranted { + entry, + full_granted_network_whitelist, + } => { + assert_eq!( + entry, + network_whitelist_entry("https", "required.example.com") + ); + assert_eq!( + full_granted_network_whitelist, + [network_whitelist_entry("https", "required.example.com")] + ); + } + GrantNetworkWhitelistOutcome::Granted { .. } => { + panic!("expected already-granted outcome"); + } + } + } +} diff --git a/crates/sage-apps/src/lifecycle/update/scope.rs b/crates/sage-apps/src/lifecycle/update/scope.rs new file mode 100644 index 000000000..f21913c3a --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/scope.rs @@ -0,0 +1,30 @@ +use tauri::{AppHandle, Manager}; + +use crate::{AppMutationManager, SageAppWalletScope, SharedSageApp, emit_listed_apps_changed}; + +pub async fn update_app_wallet_scope_for_app( + app_handle: &AppHandle, + app: &SharedSageApp, + wallet_scope: SageAppWalletScope, +) -> anyhow::Result<()> { + let apps_state = app_handle.state::(); + let manager = AppMutationManager::new(app_handle, &apps_state); + + manager + .mutate_shared_app(app, move |ctx| { + Box::pin(async move { + ctx.draft_mut() + .app_mut() + .common_mut() + .update_wallet_scope(wallet_scope); + + Ok(()) + }) + }) + .await + .map_err(anyhow::Error::msg)?; + + emit_listed_apps_changed(app_handle, &apps_state).await; + + Ok(()) +} diff --git a/crates/sage-apps/src/lifecycle/update/types.rs b/crates/sage-apps/src/lifecycle/update/types.rs new file mode 100644 index 000000000..21e905ba7 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/types.rs @@ -0,0 +1,251 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{SageGrantedPermissions, SageNetworkWhitelistEntry, UserBridgeCapability}; + +#[derive(Debug)] +pub struct AppUpdateResult { + change: GrantedPermissionsChange, +} + +#[derive(Debug)] +pub struct GrantedPermissionsChange { + capabilities: GrantedCapabilitiesChange, + network_whitelist: GrantedNetworkWhitelistChange, + network_whitelist_by_network: GrantedNetworkWhitelistByNetworkChange, +} + +#[derive(Debug, Clone)] +pub struct GrantedCapabilitiesChange { + pub removed: Vec, + pub added: Vec, + pub full: Vec, +} + +#[derive(Debug, Clone)] +pub struct GrantedNetworkWhitelistChange { + pub removed: Vec, + pub added: Vec, + pub full: Vec, +} + +#[derive(Debug, Clone)] +pub struct GrantedNetworkWhitelistByNetworkChange { + pub removed: BTreeMap>, + pub added: BTreeMap>, + pub full: BTreeMap>, +} + +#[derive(Debug)] +pub enum GrantCapabilityOutcome { + AlreadyGranted { + capability: UserBridgeCapability, + full_granted_capabilities: Vec, + }, + Granted { + capability: UserBridgeCapability, + change: GrantedCapabilitiesChange, + }, +} + +impl GrantCapabilityOutcome { + pub fn from_update(capability: UserBridgeCapability, update_result: &AppUpdateResult) -> Self { + Self::from_change(capability, update_result.change().capabilities()) + } + fn from_change(capability: UserBridgeCapability, change: &GrantedCapabilitiesChange) -> Self { + if change.added.is_empty() && change.removed.is_empty() { + Self::AlreadyGranted { + capability, + full_granted_capabilities: change.full.clone(), + } + } else { + Self::Granted { + capability, + change: change.clone(), + } + } + } +} + +#[derive(Debug)] +pub enum GrantNetworkWhitelistOutcome { + AlreadyGranted { + entry: SageNetworkWhitelistEntry, + full_granted_network_whitelist: Vec, + }, + Granted { + entry: SageNetworkWhitelistEntry, + change: GrantedNetworkWhitelistChange, + }, +} + +impl GrantNetworkWhitelistOutcome { + pub fn from_update( + network_id: Option<&str>, + entry: &SageNetworkWhitelistEntry, + update_result: &AppUpdateResult, + ) -> Self { + let change = match network_id { + Some(network_id) => update_result + .change() + .network_whitelist_by_network() + .for_network(network_id), + None => update_result.change().network_whitelist().clone(), + }; + + Self::from_change(entry, &change) + } + + fn from_change( + entry: &SageNetworkWhitelistEntry, + change: &GrantedNetworkWhitelistChange, + ) -> Self { + if change.added.is_empty() && change.removed.is_empty() { + Self::AlreadyGranted { + entry: entry.clone(), + full_granted_network_whitelist: change.full.clone(), + } + } else { + Self::Granted { + entry: entry.clone(), + change: change.clone(), + } + } + } +} + +impl GrantedPermissionsChange { + pub fn diff(previous: &SageGrantedPermissions, next: &SageGrantedPermissions) -> Self { + Self { + capabilities: GrantedCapabilitiesChange::diff( + &previous.capabilities_vec(), + &next.capabilities_vec(), + ), + network_whitelist: GrantedNetworkWhitelistChange::diff( + &previous.network_whitelist_vec(), + &next.network_whitelist_vec(), + ), + network_whitelist_by_network: GrantedNetworkWhitelistByNetworkChange::diff( + previous.network().whitelist_by_network(), + next.network().whitelist_by_network(), + ), + } + } + + pub fn capabilities(&self) -> &GrantedCapabilitiesChange { + &self.capabilities + } + + pub fn network_whitelist(&self) -> &GrantedNetworkWhitelistChange { + &self.network_whitelist + } + + pub fn network_whitelist_by_network(&self) -> &GrantedNetworkWhitelistByNetworkChange { + &self.network_whitelist_by_network + } +} + +impl AppUpdateResult { + pub fn new(change: GrantedPermissionsChange) -> Self { + Self { change } + } + + pub fn change(&self) -> &GrantedPermissionsChange { + &self.change + } +} + +impl GrantedCapabilitiesChange { + pub fn diff(previous: &[UserBridgeCapability], next: &[UserBridgeCapability]) -> Self { + let previous_set: BTreeSet<_> = previous.iter().copied().collect(); + let next_set: BTreeSet<_> = next.iter().copied().collect(); + + Self { + removed: previous_set.difference(&next_set).copied().collect(), + added: next_set.difference(&previous_set).copied().collect(), + full: next.to_vec(), + } + } + + pub fn is_empty(&self) -> bool { + self.removed.is_empty() && self.added.is_empty() + } +} + +impl GrantedNetworkWhitelistChange { + pub fn diff( + previous: &[SageNetworkWhitelistEntry], + next: &[SageNetworkWhitelistEntry], + ) -> Self { + let previous_set: BTreeSet<_> = previous.iter().cloned().collect(); + let next_set: BTreeSet<_> = next.iter().cloned().collect(); + + Self { + removed: previous_set.difference(&next_set).cloned().collect(), + added: next_set.difference(&previous_set).cloned().collect(), + full: next.to_vec(), + } + } + + pub fn is_empty(&self) -> bool { + self.removed.is_empty() && self.added.is_empty() + } +} + +impl GrantedNetworkWhitelistByNetworkChange { + pub fn diff( + previous: &BTreeMap>, + next: &BTreeMap>, + ) -> Self { + let network_ids = previous + .keys() + .chain(next.keys()) + .cloned() + .collect::>(); + + let mut removed = BTreeMap::new(); + let mut added = BTreeMap::new(); + let mut full = BTreeMap::new(); + + for network_id in network_ids { + let previous_entries = previous.get(&network_id).cloned().unwrap_or_default(); + + let next_entries = next.get(&network_id).cloned().unwrap_or_default(); + + let removed_entries = previous_entries + .difference(&next_entries) + .cloned() + .collect::>(); + + let added_entries = next_entries + .difference(&previous_entries) + .cloned() + .collect::>(); + + if !removed_entries.is_empty() { + removed.insert(network_id.clone(), removed_entries); + } + + if !added_entries.is_empty() { + added.insert(network_id.clone(), added_entries); + } + + if !next_entries.is_empty() { + full.insert(network_id, next_entries.into_iter().collect::>()); + } + } + + Self { + removed, + added, + full, + } + } + + pub fn for_network(&self, network_id: &str) -> GrantedNetworkWhitelistChange { + GrantedNetworkWhitelistChange { + removed: self.removed.get(network_id).cloned().unwrap_or_default(), + added: self.added.get(network_id).cloned().unwrap_or_default(), + full: self.full.get(network_id).cloned().unwrap_or_default(), + } + } +} diff --git a/crates/sage-apps/src/runtime.rs b/crates/sage-apps/src/runtime.rs new file mode 100644 index 000000000..1cc5cad80 --- /dev/null +++ b/crates/sage-apps/src/runtime.rs @@ -0,0 +1,24 @@ +mod commands; +mod events; +mod manager; +mod resolve; +mod start; +mod state; +mod stop; +mod storage; +mod system_apps; +mod webview_locator; +mod workspace; + +pub use commands::*; +pub use manager::*; + +pub(crate) use events::*; +pub(crate) use resolve::*; +pub(crate) use start::*; +pub(crate) use state::*; +pub(crate) use stop::*; +pub(crate) use storage::*; +pub(crate) use system_apps::*; +pub(crate) use webview_locator::*; +pub(crate) use workspace::*; diff --git a/crates/sage-apps/src/runtime/commands.rs b/crates/sage-apps/src/runtime/commands.rs new file mode 100644 index 000000000..c0f1b4c65 --- /dev/null +++ b/crates/sage-apps/src/runtime/commands.rs @@ -0,0 +1,233 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use specta::Type; +use tauri::{AppHandle, State}; + +use crate::{ + AppsHostState, RuntimeTargetParams, SageAppRuntimeRecordView, SystemKillRuntimeResult, + clear_active_taskbar_runtime, enter_apps_workspace, focus_taskbar_runtime, + get_runtime_by_app_id, get_webview_in_sage_window, kill_taskbar_runtime, leave_apps_workspace, + list_runtimes, start_app_install_runtime, start_app_update_runtime, start_donation_runtime, + start_sandbox_tests_runtime, start_user_app, +}; + +#[derive(Debug, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum StartSystemAppArgs { + AppInstall(StartAppInstallArgs), + AppUpdate(StartAppUpdateArgs), + Donation(StartDonationArgs), + SandboxTests, +} + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StartAppInstallArgs { + pub source: StartAppInstallSource, +} + +#[derive(Debug, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum StartAppInstallSource { + SelectSource, + Url { app_url: String }, +} + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StartAppUpdateArgs { + pub mode: StartAppUpdateMode, + pub app_id: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum StartAppUpdateMode { + ReviewUpdate, + ReviewPermissions, +} + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct StartDonationArgs { + pub app_id: String, +} + +#[derive(Debug, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CreateInstalledRuntimeArgs { + pub app_id: String, + #[serde(default)] + pub focus: Option, +} + +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WindowTargetParams { + pub window_label: String, +} + +impl StartAppUpdateMode { + fn query_value(self) -> &'static str { + match self { + Self::ReviewUpdate => "review-update", + Self::ReviewPermissions => "review-permissions", + } + } +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_enter_workspace( + app_handle: AppHandle, + apps_state: State<'_, AppsHostState>, +) -> Result<(), String> { + enter_apps_workspace(&app_handle, &apps_state).await?; + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_leave_workspace( + app_handle: AppHandle, + apps_state: State<'_, AppsHostState>, +) -> Result<(), String> { + leave_apps_workspace(&app_handle, &apps_state).await?; + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_start_user_app( + app_handle: AppHandle, + apps_state: State<'_, AppsHostState>, + args: CreateInstalledRuntimeArgs, +) -> Result { + start_user_app(&app_handle, &apps_state, args).await +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_start_system_app( + app: AppHandle, + apps_state: State<'_, AppsHostState>, + args: StartSystemAppArgs, +) -> Result { + let runtime = match args { + StartSystemAppArgs::AppInstall(args) => { + let mut query = BTreeMap::new(); + + match args.source { + StartAppInstallSource::SelectSource => { + query.insert("mode".to_string(), "select-source".to_string()); + } + StartAppInstallSource::Url { app_url } => { + query.insert("mode".to_string(), "url".to_string()); + query.insert("appUrl".to_string(), app_url); + } + } + + start_app_install_runtime(&app, &apps_state, query).await? + } + StartSystemAppArgs::AppUpdate(args) => { + let mut query = BTreeMap::new(); + + query.insert("appId".to_string(), args.app_id.clone()); + query.insert("mode".to_string(), args.mode.query_value().to_string()); + + start_app_update_runtime(&app, &apps_state, args.app_id, query).await? + } + StartSystemAppArgs::Donation(args) => { + let mut query = BTreeMap::new(); + + query.insert("appId".to_string(), args.app_id.clone()); + + start_donation_runtime(&app, &apps_state, args.app_id, query).await? + } + StartSystemAppArgs::SandboxTests => start_sandbox_tests_runtime(&app, &apps_state).await?, + }; + + Ok(runtime.into()) +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_list_runtimes( + apps_state: State<'_, AppsHostState>, +) -> Result, String> { + list_runtimes(&apps_state) + .await + .map(|runtimes| runtimes.into_iter().map(Into::into).collect()) +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_focus_taskbar_runtime( + app: AppHandle, + apps_state: State<'_, AppsHostState>, + params: RuntimeTargetParams, +) -> Result { + focus_taskbar_runtime(&app, &apps_state, ¶ms.app_id) + .await + .map(Into::into) +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_clear_active_taskbar_runtime( + app: AppHandle, + apps_state: State<'_, AppsHostState>, + params: WindowTargetParams, +) -> Result<(), String> { + clear_active_taskbar_runtime(&app, &apps_state, ¶ms.window_label).await +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_kill_taskbar_runtime( + app: AppHandle, + apps_state: State<'_, AppsHostState>, + params: RuntimeTargetParams, +) -> Result { + kill_taskbar_runtime(&app, &apps_state, ¶ms.app_id, "user_kill") + .await + .map_err(|_| "Runtime not found".to_string())?; + + Ok(SystemKillRuntimeResult { + ok: true, + app_id: params.app_id, + }) +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_dev_reload_runtime( + app: AppHandle, + apps_state: State<'_, AppsHostState>, + params: RuntimeTargetParams, +) -> Result { + let runtime = get_runtime_by_app_id(&apps_state, ¶ms.app_id) + .await + .map_err(|_| "Runtime not found".to_string())?; + + let webview_label = runtime.with_runtime(|runtime| runtime.webview_label().to_string()); + + let webview = get_webview_in_sage_window(&app, &webview_label)?; + + webview + .eval( + r" + (() => { + const url = new URL(window.location.href); + url.searchParams.set('__sage_dev_reload', String(Date.now())); + window.location.replace(url.toString()); + })(); + ", + ) + .map_err(|err| format!("failed to reload runtime webview: {err}"))?; + + Ok(runtime.into()) +} diff --git a/crates/sage-apps/src/runtime/events.rs b/crates/sage-apps/src/runtime/events.rs new file mode 100644 index 000000000..019fc2be5 --- /dev/null +++ b/crates/sage-apps/src/runtime/events.rs @@ -0,0 +1,82 @@ +use tauri::{AppHandle, State}; + +use crate::{ + AppsHostState, BridgeApprovalsChangedEvent, PendingBridgeApproval, + RuntimeManagerActiveTaskbarRuntimeChangedEvent, RuntimeManagerRuntimesChangedEvent, + RustBridgeResponse, SageAppRuntimeRecordView, SharedRuntime, SharedSageApp, comms_debug, + emit_bridge_response_to_app, emit_system_runtime_event_to_listeners, list_pending_approvals, + list_runtimes, resolve_running_app, +}; + +pub(crate) async fn emit_bridge_approvals_changed( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + let approvals = list_pending_approvals(apps_state).await; + + comms_debug!("bridge_approvals:changed count={}", approvals.len(),); + + let event = BridgeApprovalsChangedEvent::new_from_list(approvals); + emit_system_runtime_event_to_listeners(app_handle, apps_state, event).await; +} + +pub(crate) async fn emit_timeout_for_pending_approval( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + pending: &PendingBridgeApproval, +) -> Result<(), String> { + let running_app = resolve_running_app(apps_state, &pending.app_id) + .await + .map_err(|err| format!("Failed to resolve app: {err}"))?; + + let app = running_app.with_app(SharedSageApp::clone); + + let response = RustBridgeResponse::error( + &pending.request.id, + "approval_timeout", + "Approval request timed out", + ); + + emit_bridge_response_to_app(app_handle, &app, &response).await +} + +pub(crate) async fn emit_runtime_manager_runtimes_changed( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + let Ok(runtimes) = list_runtimes(apps_state).await else { + return; + }; + + let runtime_records = runtimes + .iter() + .map(Into::into) + .collect::>(); + + let event = RuntimeManagerRuntimesChangedEvent::new(runtime_records); + + emit_system_runtime_event_to_listeners(app_handle, apps_state, event).await; +} + +pub(crate) async fn emit_active_taskbar_runtime_changed( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + host_window_label: &str, + runtime: Option<&SharedRuntime>, +) { + let (runtime_id, app_id) = match runtime { + Some(shared_runtime) => shared_runtime + .with_runtime(|record| (Some(record.runtime_id()), Some(record.app_id().clone()))), + None => (None, None), + }; + let () = emit_system_runtime_event_to_listeners( + app_handle, + apps_state, + RuntimeManagerActiveTaskbarRuntimeChangedEvent { + host_window_label: host_window_label.to_string(), + app_id, + runtime_id, + }, + ) + .await; +} diff --git a/crates/sage-apps/src/runtime/manager.rs b/crates/sage-apps/src/runtime/manager.rs new file mode 100644 index 000000000..1fa4a45c7 --- /dev/null +++ b/crates/sage-apps/src/runtime/manager.rs @@ -0,0 +1,453 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, LogicalPosition, LogicalSize, State}; + +use crate::{ + AppPresentation, AppsHostState, ResolvedRunningApp, SageAppRuntimeRecord, + SageAppRuntimeVisibility, SharedRuntime, emit_active_taskbar_runtime_changed, + emit_runtime_manager_runtimes_changed, ensure_apps_workspace_active, + find_active_taskbar_runtime, find_runtime_by_runtime_id_optional, get_sage_window, + get_webview_in_sage_window, kill_runtime_inner, list_runtimes, resolve_running_app, +}; + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeTargetParams { + pub app_id: String, +} + +struct RuntimeWindowIdentity { + runtime_id: String, + host_window_label: String, +} + +#[derive(Default)] +pub(crate) struct RuntimeChangeSet { + runtimes_changed: bool, + active_taskbar_changed: Vec, +} + +struct ModalVisibilityCandidate { + runtime: SharedRuntime, + eligible: bool, + priority: i32, + runtime_id: String, +} + +pub async fn process_sage_network_change( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) { + // Possible infinite loop is intentional here since failing to reload runtimes may cause a security issue + // If this gets stuck, Sage main thread freezes and the user will kill it manually. Problem solved + // It should never happen. But if it does, it won't be silent. + // Another option is to try to kill Sage itself from here + loop { + match reload_all_user_runtimes(app_handle, apps_state).await { + Ok(()) => break, + Err(err) => { + tracing::error!( + error = %err, + "failed to reload app runtimes after sync network change" + ); + match kill_all_user_runtimes(app_handle, apps_state).await { + Ok(()) => break, + Err(err) => { + tracing::error!( + error = %err, + "failed to kill app runtimes after sync network change" + ); + } + } + } + } + } +} + +pub(crate) async fn focus_taskbar_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result { + ensure_apps_workspace_active(apps_state).await?; + + let resolved_running_app = resolve_running_app(apps_state, app_id) + .await + .map_err(|e| format!("failed to resolve running app: {e}"))?; + + assert_taskbar_presentation(&resolved_running_app)?; + + let runtime = resolved_running_app.runtime(); + let runtime_window_identity = runtime_window_identity(&resolved_running_app); + + let mut changes = RuntimeChangeSet::default(); + + let current_active_taskbar_runtime = + find_active_taskbar_runtime(apps_state, &runtime_window_identity.host_window_label).await; + + show_runtime_inner(app_handle, apps_state, &runtime, &mut changes).await?; + + if let Some(current_taskbar_runtime) = current_active_taskbar_runtime { + let current_taskbar_runtime_id = current_taskbar_runtime.runtime_id(); + let is_same_taskbar_runtime = + current_taskbar_runtime_id == runtime_window_identity.runtime_id; + let current_taskbar_runtime = + find_runtime_by_runtime_id_optional(apps_state, ¤t_taskbar_runtime_id).await; + + if !is_same_taskbar_runtime && let Some(current_active_runtime) = current_taskbar_runtime { + hide_runtime_inner(app_handle, ¤t_active_runtime, &mut changes)?; + } + } + + sync_modal_runtime_visibility( + app_handle, + apps_state, + &runtime_window_identity.host_window_label, + &mut changes, + ) + .await?; + + changes.active_taskbar_changed(&runtime_window_identity.host_window_label); + changes.emit(app_handle, apps_state).await; + + Ok(runtime) +} + +pub(crate) async fn hide_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result { + let resolved_running_app = resolve_running_app(apps_state, app_id) + .await + .map_err(|e| format!("failed to resolve running app: {e}"))?; + + let runtime = resolved_running_app.runtime(); + let runtime_window_identity = runtime_window_identity(&resolved_running_app); + let host_window_label = runtime_window_identity.host_window_label; + + let active_taskbar_runtime = find_active_taskbar_runtime(apps_state, &host_window_label).await; + + let mut changes = RuntimeChangeSet::default(); + + hide_runtime_inner(app_handle, &runtime, &mut changes)?; + + sync_modal_runtime_visibility(app_handle, apps_state, &host_window_label, &mut changes).await?; + + if let Some(active_taskbar_runtime) = active_taskbar_runtime + && active_taskbar_runtime.app_id() == app_id + { + changes.active_taskbar_changed(&host_window_label); + } + + changes.emit(app_handle, apps_state).await; + + Ok(runtime) +} + +pub(crate) async fn hide_all_runtimes( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(), String> { + let mut changes = RuntimeChangeSet::default(); + + hide_all_runtimes_inner(app_handle, apps_state, &mut changes).await?; + + changes.emit(app_handle, apps_state).await; + + Ok(()) +} + +pub(crate) async fn hide_all_runtimes_inner( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + changes: &mut RuntimeChangeSet, +) -> Result<(), String> { + let sage_window = get_sage_window(app_handle)?; + + if find_active_taskbar_runtime(apps_state, sage_window.label()) + .await + .is_some() + { + changes.active_taskbar_changed(sage_window.label()); + } + + for runtime in list_runtimes(apps_state).await? { + hide_runtime_inner(app_handle, &runtime, changes)?; + } + + Ok(()) +} + +pub(crate) async fn clear_active_taskbar_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + window_label: &str, +) -> Result<(), String> { + let mut changes = RuntimeChangeSet::default(); + + if let Some(active_taskbar_runtime) = + find_active_taskbar_runtime(apps_state, window_label).await + { + hide_runtime_inner(app_handle, &active_taskbar_runtime, &mut changes)?; + changes.active_taskbar_changed(window_label); + } + + sync_modal_runtime_visibility(app_handle, apps_state, window_label, &mut changes).await?; + + changes.emit(app_handle, apps_state).await; + + Ok(()) +} + +pub(crate) async fn kill_taskbar_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + reason: &str, +) -> Result<(), String> { + let mut changes = RuntimeChangeSet::default(); + + kill_runtime_inner(app_handle, apps_state, app_id, reason, &mut changes) + .await + .map_err(|err| format!("Failed to kill taskbar runtime: {err:?}"))?; + + changes.emit(app_handle, apps_state).await; + + Ok(()) +} + +pub(crate) async fn reload_app_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result<(), String> { + let Ok(resolved_running_app) = resolve_running_app(apps_state, app_id).await else { + return Ok(()); + }; + + let webview_label = resolved_running_app + .runtime() + .with_runtime(|runtime| runtime.webview_label().to_string()); + + get_webview_in_sage_window(app_handle, &webview_label)? + .reload() + .map_err(|err| err.to_string()) +} + +async fn reload_all_user_runtimes( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(), String> { + for runtime in list_runtimes(apps_state).await? { + let (webview_label, should_reload) = runtime.with_runtime(|runtime| { + ( + runtime.webview_label().to_string(), + runtime.app().is_user_app() && !runtime.internal(), + ) + }); + + if should_reload { + get_webview_in_sage_window(app_handle, &webview_label)? + .reload() + .map_err(|err| err.to_string())?; + } + } + + Ok(()) +} + +async fn kill_all_user_runtimes( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(), String> { + let mut changes = RuntimeChangeSet::default(); + for runtime in list_runtimes(apps_state).await? { + let (app_id, should_kill) = runtime.with_runtime(|runtime| { + ( + runtime.app_id(), + runtime.app().is_user_app() && !runtime.internal(), + ) + }); + + if should_kill { + kill_runtime_inner(app_handle, apps_state, &app_id, "kill-all", &mut changes) + .await + .map_err(|err| err.to_string())?; + } + } + + changes.emit(app_handle, apps_state).await; + + Ok(()) +} + +pub(crate) async fn sync_modal_runtime_visibility( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + host_window_label: &str, + changes: &mut RuntimeChangeSet, +) -> Result<(), String> { + ensure_apps_workspace_active(apps_state).await?; + + let active_taskbar_runtime = find_active_taskbar_runtime(apps_state, host_window_label).await; + + let active_app_id = active_taskbar_runtime.map(|runtime| runtime.app_id()); + + let mut candidates = Vec::new(); + + for runtime in list_runtimes(apps_state).await? { + let Some(candidate) = runtime.with_runtime(|record| { + if record.host_window_label() != host_window_label { + return None; + } + + let AppPresentation::Modal(modal) = record.presentation() else { + return None; + }; + + let eligible = match active_app_id.as_deref() { + Some(active_app_id) => modal + .visible_over_app_ids() + .iter() + .any(|app_id| app_id == active_app_id), + + None => modal.visible_over_launchpad(), + }; + + Some((eligible, modal.priority(), record.runtime_id())) + }) else { + continue; + }; + + candidates.push(ModalVisibilityCandidate { + runtime, + eligible: candidate.0, + priority: candidate.1, + runtime_id: candidate.2, + }); + } + + let winner_runtime_id = candidates + .iter() + .filter(|candidate| candidate.eligible) + .max_by_key(|candidate| candidate.priority) + .map(|candidate| candidate.runtime_id.clone()); + + for candidate in candidates { + let should_show = Some(candidate.runtime_id.clone()) == winner_runtime_id; + + if should_show { + show_runtime_inner(app_handle, apps_state, &candidate.runtime, changes).await?; + } else { + hide_runtime_inner(app_handle, &candidate.runtime, changes)?; + } + } + + Ok(()) +} + +fn runtime_window_identity(resolved_running_app: &ResolvedRunningApp) -> RuntimeWindowIdentity { + resolved_running_app + .runtime() + .with_runtime(|record| RuntimeWindowIdentity { + runtime_id: record.runtime_id(), + host_window_label: record.host_window_label().to_string(), + }) +} + +fn assert_taskbar_presentation(resolved_running_app: &ResolvedRunningApp) -> Result<(), String> { + if !resolved_running_app.runtime().is_taskbar() { + return Err("Cannot focus non-taskbar runtime".to_string()); + } + + Ok(()) +} + +async fn show_runtime_inner( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + runtime: &SharedRuntime, + changes: &mut RuntimeChangeSet, +) -> Result<(), String> { + ensure_apps_workspace_active(apps_state).await?; + + if runtime.with_runtime(|runtime| runtime.visibility() == SageAppRuntimeVisibility::Visible) { + return Ok(()); + } + + let app_webview_label = runtime.with_runtime(SageAppRuntimeRecord::webview_label); + let webview = get_webview_in_sage_window(app_handle, &app_webview_label)?; + + webview + .show() + .map_err(|err| format!("failed to show webview: {err}"))?; + webview + .set_focus() + .map_err(|err| format!("failed to focus webview: {err}"))?; + + runtime.with_runtime_mut(SageAppRuntimeRecord::mark_visible); + changes.runtimes_changed(); + + Ok(()) +} + +fn hide_runtime_inner( + app_handle: &AppHandle, + runtime: &SharedRuntime, + changes: &mut RuntimeChangeSet, +) -> Result<(), String> { + if runtime.with_runtime(|runtime| runtime.visibility() == SageAppRuntimeVisibility::Hidden) { + return Ok(()); + } + + let app_webview_label = runtime.with_runtime(SageAppRuntimeRecord::webview_label); + let webview = get_webview_in_sage_window(app_handle, &app_webview_label)?; + + webview + .hide() + .map_err(|err| format!("failed to hide webview: {err}"))?; + webview + .set_position(LogicalPosition::new(0.0, 0.0)) + .map_err(|err| format!("failed to set webview position: {err}"))?; + webview + .set_size(LogicalSize::new(1.0, 1.0)) + .map_err(|err| format!("failed to set webview size: {err}"))?; + + runtime.with_runtime_mut(SageAppRuntimeRecord::mark_hidden); + changes.runtimes_changed(); + + Ok(()) +} + +impl RuntimeChangeSet { + pub(crate) fn runtimes_changed(&mut self) { + self.runtimes_changed = true; + } + + pub(crate) fn active_taskbar_changed(&mut self, window_label: impl Into) { + let window_label = window_label.into(); + + if !self.active_taskbar_changed.contains(&window_label) { + self.active_taskbar_changed.push(window_label); + } + } + + pub(crate) async fn emit(self, app_handle: &AppHandle, apps_state: &State<'_, AppsHostState>) { + if self.runtimes_changed { + emit_runtime_manager_runtimes_changed(app_handle, apps_state).await; + } + + for window_label in self.active_taskbar_changed { + let active = find_active_taskbar_runtime(apps_state, &window_label).await; + + emit_active_taskbar_runtime_changed( + app_handle, + apps_state, + &window_label, + active.as_ref(), + ) + .await; + } + } +} diff --git a/crates/sage-apps/src/runtime/resolve.rs b/crates/sage-apps/src/runtime/resolve.rs new file mode 100644 index 000000000..9e049156d --- /dev/null +++ b/crates/sage-apps/src/runtime/resolve.rs @@ -0,0 +1,211 @@ +use std::collections::BTreeMap; +use std::fmt::Display; + +use tauri::{AppHandle, Manager, State}; +use tokio::time::{Duration, sleep}; +use url::Url; + +use crate::{ + AppsHostState, GetRuntimeError, ResolvedApp, ResolvedRunningApp, ResolvedStoppedApp, SageApp, + SharedSageApp, build_builtin_system_app, build_builtin_test_app, close_runtime_internal, + find_runtime_by_app_id_optional, +}; + +const MAX_STOP_RESOLVE_ATTEMPTS: usize = 5; + +#[derive(Debug)] +pub enum ResolveError { + NotFound(String), + BuildFailed(String), +} + +#[derive(Debug, Copy, Clone)] +pub enum ResolveStoppedError { + AppDirMissing, + CloseAttemptsHit, +} + +impl Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + ResolveError::NotFound(msg) | ResolveError::BuildFailed(msg) => msg.clone(), + }; + write!(f, "{str}") + } +} + +impl Display for ResolveStoppedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + ResolveStoppedError::CloseAttemptsHit => "too many close attempts".to_string(), + ResolveStoppedError::AppDirMissing => "app dir missing".to_string(), + }; + write!(f, "{str}") + } +} + +pub fn app_id_from_webview_label(label: &str) -> Option<&str> { + if let Some(app_id) = label.strip_prefix("app-") { + return Some(app_id); + } + + if let Some(app_id) = label.strip_prefix("system-app-") { + return Some(app_id); + } + + None +} + +pub fn protocol_scheme_for_app(app: &SharedSageApp) -> &'static str { + if app.is_system_app() { + return "sage-system-app"; + } + + "sage-app" +} + +pub fn is_allowed_app_url(url: &Url, app: &SharedSageApp) -> bool { + url.scheme() == protocol_scheme_for_app(app) && url.host_str() == Some(&app.origin_id()) +} + +pub fn build_entry_src_for( + identity_app: &SharedSageApp, + content_app: &SharedSageApp, + query: BTreeMap, +) -> Url { + let scheme = protocol_scheme_for_app(identity_app); + let entry_file = content_app.with(SageApp::entry_file); + + let mut url = Url::parse(&format!( + "{scheme}://{}/{}", + identity_app.origin_id(), + entry_file + )) + .expect("failed to build app entry URL"); + + for (key, value) in query { + url.query_pairs_mut().append_pair(&key, &value); + } + + url +} + +pub fn build_entry_src(app: &SharedSageApp, query: BTreeMap) -> Url { + build_entry_src_for(app, app, query) +} + +pub(crate) async fn resolve_running_app( + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result { + let runtime = find_runtime_by_app_id_optional(apps_state, app_id) + .await + .ok_or(GetRuntimeError::NotFound)?; + + Ok(ResolvedRunningApp::new(runtime)) +} + +pub(crate) async fn resolve_stopped_app( + app: &AppHandle, + app_id: &str, +) -> Result { + let apps_state: State<'_, AppsHostState> = app.state(); + let mut delay = Duration::from_millis(25); + + for attempt in 1..=MAX_STOP_RESOLVE_ATTEMPTS { + let resolved_app = resolve_app(app, app_id).await.map_err(|e| match e { + ResolveError::NotFound(_) | ResolveError::BuildFailed(_) => { + ResolveStoppedError::AppDirMissing + } + })?; + match resolved_app { + ResolvedApp::Stopped(stopped) => { + return Ok(stopped); + } + + ResolvedApp::Running(running) => { + drop(running); + + close_runtime_internal(app, &apps_state, app_id).await; + + if attempt < MAX_STOP_RESOLVE_ATTEMPTS { + sleep(delay).await; + delay *= 2; + } + } + } + } + + Err(ResolveStoppedError::CloseAttemptsHit) +} + +pub(crate) async fn resolve_app( + app: &AppHandle, + app_id: &str, +) -> Result { + resolve_app_with_extra(app, app_id, |_| Ok(None)).await +} + +pub(crate) async fn resolve_app_with_extra( + app: &AppHandle, + app_id: &str, + extra: impl FnOnce(&str) -> Result, ResolveError>, +) -> Result { + let state: State<'_, AppsHostState> = app.state(); + let lock = state.inner().operation_lock_for_app(app_id); + + let guard = lock.lock_owned().await; + + if let Some(runtime) = find_runtime_by_app_id_optional(&state, app_id).await { + drop(guard); + return Ok(ResolvedApp::Running(ResolvedRunningApp::new(runtime))); + } + + if let Some(app) = extra(app_id)? { + return Ok(ResolvedApp::Stopped(ResolvedStoppedApp::new( + SharedSageApp::new(app), + guard, + ))); + } + + match state.db.get_user_app(app_id).await { + Ok(Some(app)) => { + return Ok(ResolvedApp::Stopped(ResolvedStoppedApp::new( + SharedSageApp::new(SageApp::User(app)), + guard, + ))); + } + Ok(None) => {} + Err(err) => { + return Err(ResolveError::BuildFailed(format!( + "failed to read installed app {app_id}: {err}" + ))); + } + } + + if let Some(app) = build_builtin_system_app(app_id).map_err(|err| { + ResolveError::BuildFailed(format!( + "failed to resolve builtin system app {app_id}: {err}" + )) + })? { + return Ok(ResolvedApp::Stopped(ResolvedStoppedApp::new( + SharedSageApp::new(app), + guard, + ))); + } + + if let Some(app) = build_builtin_test_app(app_id).map_err(|err| { + ResolveError::BuildFailed(format!( + "failed to resolve builtin sandbox app {app_id}: {err}" + )) + })? { + return Ok(ResolvedApp::Stopped(ResolvedStoppedApp::new( + SharedSageApp::new(app), + guard, + ))); + } + + Err(ResolveError::NotFound(format!( + "failed to resolve app {app_id}" + ))) +} diff --git a/crates/sage-apps/src/runtime/start.rs b/crates/sage-apps/src/runtime/start.rs new file mode 100644 index 000000000..b46664e10 --- /dev/null +++ b/crates/sage-apps/src/runtime/start.rs @@ -0,0 +1,427 @@ +use std::collections::BTreeMap; + +use specta::Type; +use tauri::webview::NewWindowResponse; +use tauri::{AppHandle, LogicalPosition, LogicalSize, State, WebviewBuilder, WebviewUrl, Wry}; + +#[cfg(target_os = "windows")] +use crate::data_directory_for; +#[cfg(any(target_os = "macos", target_os = "ios"))] +use crate::parse_data_store_id; +use crate::{ + AppMutationManager, AppPresentation, AppsHostState, CreateInstalledRuntimeArgs, + OriginCleanupRuntimeTarget, ResolvedApp, RuntimeChangeSet, SageAppRuntimeMode, + SageAppRuntimeRecord, SageAppRuntimeRecordView, SageAppRuntimeVisibility, SageAppStorage, + SharedRuntime, SharedSageApp, build_entry_src, close_runtime_internal, + emit_runtime_manager_runtimes_changed, focus_taskbar_runtime, get_sage_window, + get_webview_in_sage_window, is_allowed_app_url, remove_runtime_by_runtime_id, + remove_runtime_id_by_app_id, resolve_app, rotate_app_storage_and_origin, sandbox, + sync_modal_runtime_visibility, write_runtime, +}; + +#[derive(Debug, Type)] +#[serde(rename_all = "camelCase")] +pub struct CreateRuntimeArgs { + pub app_id: String, + pub presentation: AppPresentation, + pub mode: SageAppRuntimeMode, + pub debug_layout: bool, + pub query: BTreeMap, +} + +pub(crate) async fn start_user_app( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + args: CreateInstalledRuntimeArgs, +) -> Result { + let created_runtime = create_runtime( + app_handle, + apps_state, + CreateRuntimeArgs { + app_id: args.app_id.clone(), + presentation: AppPresentation::Taskbar, + mode: SageAppRuntimeMode::Inline, + debug_layout: false, + query: BTreeMap::new(), + }, + ) + .await + .map(Into::into); + + emit_runtime_manager_runtimes_changed(app_handle, apps_state).await; + + if args.focus.unwrap_or(true) { + focus_taskbar_runtime(app_handle, apps_state, &args.app_id).await?; + } + + created_runtime +} + +pub(crate) async fn start_system_app( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + args: CreateRuntimeArgs, +) -> Result { + let runtime = create_runtime(app_handle, apps_state, args).await?; + + let host_window_label = runtime.with_runtime(SageAppRuntimeRecord::host_window_label); + + let mut changes = RuntimeChangeSet::default(); + changes.runtimes_changed(); + + sync_modal_runtime_visibility(app_handle, apps_state, &host_window_label, &mut changes).await?; + + changes.emit(app_handle, apps_state).await; + + Ok(runtime) +} + +pub(crate) async fn start_sandbox_test( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + query: BTreeMap, +) -> Result<(), String> { + let args = CreateRuntimeArgs { + app_id: app_id.to_string(), + presentation: AppPresentation::Taskbar, + mode: SageAppRuntimeMode::Inline, + debug_layout: debug_test_apps_enabled(), + query, + }; + + create_runtime(app, apps_state, args).await.map(|_| ()) +} + +pub(crate) async fn start_origin_cleanup_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + target: OriginCleanupRuntimeTarget, + query: BTreeMap, +) -> Result { + let app_id = sandbox::BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID; + let mut app = sandbox::build_builtin_runtime_app(app_id) + .map_err(|err| format!("failed to build origin cleanup runtime app: {err}"))? + .ok_or_else(|| "missing origin cleanup runtime app".to_string())?; + + app.common_mut() + .replace_storage_and_origin(target.storage, target.origin_id, false) + .map_err(|err| format!("failed to retarget origin cleanup runtime: {err}"))?; + + close_runtime_internal(app_handle, apps_state, app_id).await; + + create_runtime_for_app( + app_handle, + apps_state, + SharedSageApp::new(app), + CreateRuntimeArgs { + app_id: app_id.to_string(), + presentation: AppPresentation::Taskbar, + mode: SageAppRuntimeMode::Inline, + debug_layout: false, + query, + }, + false, + None, + ) + .await +} + +async fn create_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + args: CreateRuntimeArgs, +) -> Result { + // Keep the per-app operation lock until the runtime and its webview are both + // ready, so another start cannot observe the same app as stopped. + let (app, operation_guard) = match resolve_app(app_handle, &args.app_id) + .await + .map_err(|e| e.to_string())? + { + ResolvedApp::Running(running) => return Ok(running.runtime()), + ResolvedApp::Stopped(stopped) => stopped.into_app_and_guard(), + }; + + create_runtime_for_app( + app_handle, + apps_state, + app, + args, + true, + Some(operation_guard), + ) + .await +} + +async fn create_runtime_for_app( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: SharedSageApp, + args: CreateRuntimeArgs, + apply_user_lifecycle: bool, + operation_guard: Option>, +) -> Result { + let is_internal = app.with(|app| app.common().is_sandbox_test()) + || app.id() == sandbox::BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID; + + if !is_internal { + check_gates(apps_state, &app).await?; + } + + if apply_user_lifecycle { + rotate_incognito_app_storage_and_origin_if_needed(app_handle, apps_state, &app).await?; + mark_origin_may_contain_secrets_if_needed(app_handle, apps_state, &app).await?; + } + + let sage_window = get_sage_window(app_handle)?; + let webview_label = app.webview_label(); + + let runtime = SageAppRuntimeRecord::new( + &app, + sage_window.label(), + &webview_label, + args.presentation, + args.mode, + SageAppRuntimeVisibility::Hidden, + is_internal, + ) + .map_err(|err| err.to_string())?; + + let shared_runtime = write_runtime(apps_state, runtime).await; + + let runtime_for_nav = shared_runtime.clone(); + let builder = WebviewBuilder::new( + webview_label.to_string(), + WebviewUrl::CustomProtocol(build_entry_src(&app, args.query.clone())), + ) + .transparent(true) + .on_navigation(move |url| { + runtime_for_nav.with_runtime(|runtime| is_allowed_app_url(url, &runtime.app())) + }) + .on_new_window(move |_url, _features| NewWindowResponse::Deny); + + let builder = build_initialization_script(builder); + + #[cfg(any(target_os = "macos", target_os = "ios"))] + let builder = build_storage(builder, &app)?; + + #[cfg(not(any(target_os = "macos", target_os = "ios")))] + let builder = build_storage(builder, &app); + + let (x, y, width, height) = if args.debug_layout { + debug_layout_for_app(&app.id()) + } else { + (0.0, 0.0, 1.0, 1.0) + }; + + let add_child_result = get_sage_window(app_handle)?.add_child( + builder, + LogicalPosition::new(x, y), + LogicalSize::new(width, height), + ); + + if let Err(e) = add_child_result { + let (runtime_id, app_id) = + shared_runtime.with_runtime(|runtime| (runtime.runtime_id(), runtime.app().id())); + drop(shared_runtime); + remove_runtime_by_runtime_id(apps_state, &runtime_id).await; + remove_runtime_id_by_app_id(apps_state, &app_id).await; + return Err(format!("failed to create child webview: {e}")); + } + + if !args.debug_layout { + get_webview_in_sage_window(app_handle, &webview_label)? + .hide() + .map_err(|err| format!("{err}"))?; + } + + drop(operation_guard); + + Ok(shared_runtime) +} + +fn fallback_debug_slot(app_id: &str) -> usize { + app_id.bytes().fold(0usize, |acc, b| { + acc.wrapping_mul(31).wrapping_add(b as usize) + }) % 12 +} + +fn debug_layout_for_app(app_id: &str) -> (f64, f64, f64, f64) { + let slot = match app_id { + "__sage_test_storage_isolation_persistent" => 0, + "__sage_test_storage_isolation_incognito" => 1, + "__sage_test_persistence_persistent" => 2, + "__sage_test_persistence_incognito" => 3, + "__sage_test_storage_clear_persistent" => 4, + "__sage_test_network_allow_a" => 5, + "__sage_test_network_allow_b" => 6, + _ => fallback_debug_slot(app_id), + }; + + let cols = 3usize; + let cell_w = 360.0; + let cell_h = 100.0; + let margin_x = 24.0; + let margin_y = 24.0; + let origin_x = 40.0; + let origin_y = 40.0; + + let col = u32::try_from(slot % cols).expect("debug layout column should fit u32"); + let row = u32::try_from(slot / cols).expect("debug layout row should fit u32"); + + let x = origin_x + f64::from(col) * (cell_w + margin_x); + let y = origin_y + f64::from(row) * (cell_h + margin_y); + + (x, y, cell_w, cell_h) +} + +async fn check_gates( + apps_state: &State<'_, AppsHostState>, + app: &SharedSageApp, +) -> Result<(), String> { + let baseline = apps_state.sandbox.baseline.lock().await.clone(); + let current_run = apps_state.sandbox.current_run.lock().await.clone(); + let effective = sandbox::build_effective_state(&baseline, current_run.as_ref()); + let gate = sandbox::evaluate_app_launch_gate(app, &effective); + + if !gate.allowed { + tracing::error!("App launch blocked by sandbox policy"); + return Err(gate + .message + .unwrap_or_else(|| "App launch blocked by sandbox policy".into())); + } + + Ok(()) +} + +async fn rotate_incognito_app_storage_and_origin_if_needed( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: &SharedSageApp, +) -> Result<(), String> { + if !app.is_user_app() + || app.with(|app| app.common().is_sandbox_test()) + || app.with(|app| app.common().has_persistent_webview_storage()) + { + return Ok(()); + } + + rotate_app_storage_and_origin(app_handle, apps_state, app).await +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +fn build_storage( + builder: WebviewBuilder, + app: &SharedSageApp, +) -> Result, String> { + if !app.with(|app| app.common().has_persistent_webview_storage()) { + return Ok(builder.incognito(true)); + } + + build_persistent_storage_target(builder, app) +} + +#[cfg(not(any(target_os = "macos", target_os = "ios")))] +fn build_storage(builder: WebviewBuilder, app: &SharedSageApp) -> WebviewBuilder { + if !app.with(|app| app.common().has_persistent_webview_storage()) { + return builder.incognito(true); + } + + build_persistent_storage_target(builder, app) +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +fn build_persistent_storage_target( + builder: WebviewBuilder, + app: &SharedSageApp, +) -> Result, String> { + let storage = app.with(|app| app.storage().clone()); + + match storage { + SageAppStorage::AppleDataStore { identifier_hex } => { + let identifier = parse_data_store_id(&identifier_hex)?; + Ok(builder.data_store_identifier(identifier)) + } + + SageAppStorage::WindowsProfile { .. } | SageAppStorage::Unmanaged => Ok(builder), + } +} + +#[cfg(not(any(target_os = "macos", target_os = "ios")))] +fn build_persistent_storage_target( + builder: WebviewBuilder, + app: &SharedSageApp, +) -> WebviewBuilder { + let storage = app.with(|app| app.storage().clone()); + + match storage { + #[cfg(target_os = "windows")] + SageAppStorage::WindowsProfile { directory_name } => { + builder.data_directory(data_directory_for(directory_name)) + } + + #[cfg(not(target_os = "windows"))] + SageAppStorage::WindowsProfile { .. } => builder, + + SageAppStorage::AppleDataStore { .. } | SageAppStorage::Unmanaged => builder, + } +} + +fn build_initialization_script(mut builder: WebviewBuilder) -> WebviewBuilder { + if !cfg!(debug_assertions) { + return builder; + } + + let enabled = std::env::var("SAGE_APPS_COMMS_DEBUG") + .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); + + if !enabled { + return builder; + } + + builder = builder.initialization_script( + r" +window.__SAGE_APPS_COMMS_DEBUG__ = true; +", + ); + + builder +} + +async fn mark_origin_may_contain_secrets_if_needed( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: &SharedSageApp, +) -> Result<(), String> { + if app.is_system_app() + || app.with(|app| app.common().is_sandbox_test()) + || !app.runtime_can_persist_secrets() + { + return Ok(()); + } + + if app.with(|app| app.common().origin_webview_storage_may_contain_secrets()) { + return Ok(()); + } + + let manager = AppMutationManager::new(app_handle, apps_state); + + manager + .mutate_shared_app(app, |ctx| { + Box::pin(async move { + ctx.draft_mut() + .mark_origin_webview_storage_may_contain_secrets()?; + + Ok(()) + }) + }) + .await + .map_err(|err| format!("failed to mark origin as containing secrets: {err}")) +} + +fn debug_test_apps_enabled() -> bool { + cfg!(debug_assertions) + && std::env::var("SAGE_DEBUG_TEST_APPS") + .map(|v| v == "1") + .unwrap_or(false) +} diff --git a/crates/sage-apps/src/runtime/state.rs b/crates/sage-apps/src/runtime/state.rs new file mode 100644 index 000000000..79dc4b12e --- /dev/null +++ b/crates/sage-apps/src/runtime/state.rs @@ -0,0 +1,12 @@ +mod read; +mod remove; +mod types; +mod view; +mod write; + +pub use view::*; + +pub(crate) use read::*; +pub(crate) use remove::*; +pub(crate) use types::*; +pub(crate) use write::*; diff --git a/crates/sage-apps/src/runtime/state/read.rs b/crates/sage-apps/src/runtime/state/read.rs new file mode 100644 index 000000000..4b33bdd14 --- /dev/null +++ b/crates/sage-apps/src/runtime/state/read.rs @@ -0,0 +1,118 @@ +use std::cmp::Reverse; +use std::fmt::Display; + +use tauri::State; + +use super::types::SharedRuntime; +use crate::{AppPresentation, AppsHostState, SageAppRuntimeVisibility}; + +pub enum GetRuntimeError { + NotFound, +} + +impl Display for GetRuntimeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + GetRuntimeError::NotFound => String::from("Runtime not found"), + }, + ) + } +} + +pub async fn find_runtime_by_app_id_optional( + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Option { + let runtime_id = find_runtime_id_by_app_id_optional(apps_state, app_id).await?; + find_runtime_by_runtime_id_optional(apps_state, &runtime_id).await +} + +pub(crate) async fn find_runtime_id_by_app_id_optional( + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Option { + let by_app_id = apps_state.runtime.runtime_id_by_app_id.lock().await; + by_app_id.get(app_id).cloned() +} + +pub(crate) async fn find_runtime_by_runtime_id_optional( + apps_state: &State<'_, AppsHostState>, + runtime_id: &str, +) -> Option { + let by_runtime_id = apps_state.runtime.runtime_by_runtime_id.lock().await; + by_runtime_id.get(runtime_id).cloned() +} + +pub(crate) async fn get_runtime_by_app_id( + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) -> Result { + find_runtime_by_app_id_optional(apps_state, app_id) + .await + .ok_or(GetRuntimeError::NotFound) +} + +pub(crate) async fn list_runtimes( + apps_state: &State<'_, AppsHostState>, +) -> Result, String> { + let mut runtimes = { + let by_runtime_id = apps_state.runtime.runtime_by_runtime_id.lock().await; + by_runtime_id.values().cloned().collect::>() + }; + + runtimes.retain(|runtime| !runtime.with_runtime(super::types::SageAppRuntimeRecord::internal)); + + runtimes.sort_by_key(|runtime| { + Reverse(runtime.with_runtime(super::types::SageAppRuntimeRecord::started_at)) + }); + + Ok(runtimes) +} + +pub(crate) async fn find_active_taskbar_runtime( + apps_state: &State<'_, AppsHostState>, + host_window_label: &str, +) -> Option { + let taskbar_runtimes = get_taskbar_runtimes(apps_state, host_window_label).await; + taskbar_runtimes + .iter() + .find(|runtime| { + runtime + .with_runtime(|runtime| runtime.visibility() == SageAppRuntimeVisibility::Visible) + }) + .cloned() +} + +pub(crate) async fn is_apps_workspace_active(apps_state: &State<'_, AppsHostState>) -> bool { + *apps_state.runtime.apps_workspace_active.read().await +} + +async fn get_taskbar_runtimes( + apps_state: &State<'_, AppsHostState>, + host_window_label: &str, +) -> Vec { + let runtimes: Vec = { + apps_state + .runtime + .runtime_by_runtime_id + .lock() + .await + .values() + .cloned() + .collect() + }; + + runtimes + .iter() + .filter(|runtime| { + runtime.with_runtime(|runtime| { + runtime.presentation() == AppPresentation::Taskbar + && runtime.host_window_label() == host_window_label + }) + }) + .cloned() + .collect() +} diff --git a/crates/sage-apps/src/runtime/state/remove.rs b/crates/sage-apps/src/runtime/state/remove.rs new file mode 100644 index 000000000..23892fe71 --- /dev/null +++ b/crates/sage-apps/src/runtime/state/remove.rs @@ -0,0 +1,47 @@ +use tauri::State; + +use crate::AppsHostState; + +pub(crate) async fn remove_runtime_id_by_app_id( + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) { + let mut runtime_id_by_app_id = apps_state.runtime.runtime_id_by_app_id.lock().await; + runtime_id_by_app_id.remove(app_id); +} + +pub(crate) async fn remove_runtime_by_runtime_id( + apps_state: &State<'_, AppsHostState>, + runtime_id: &str, +) { + let runtime = { + let mut by_runtime_id = apps_state.runtime.runtime_by_runtime_id.lock().await; + by_runtime_id.remove(runtime_id) + }; + + let Some(runtime) = runtime else { + return; + }; + + remove_runtime_id_by_app_id(apps_state, &runtime.app_id()).await; +} + +pub(crate) async fn remove_before_stop_listeners_by_app_id( + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) { + let mut listeners = apps_state + .runtime + .before_stop_listeners_by_app_id + .lock() + .await; + listeners.remove(app_id); +} + +pub(crate) async fn remove_pending_stop_ready( + apps_state: &State<'_, AppsHostState>, + request_id: &String, +) { + let mut pending = apps_state.runtime.pending_stop_ready.lock().await; + pending.remove(request_id); +} diff --git a/crates/sage-apps/src/runtime/state/types.rs b/crates/sage-apps/src/runtime/state/types.rs new file mode 100644 index 000000000..3dc7b3c6f --- /dev/null +++ b/crates/sage-apps/src/runtime/state/types.rs @@ -0,0 +1,272 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tokio::sync::{Mutex, oneshot}; + +use crate::{AppPresentation, SageApp, SharedSageApp, unix_timestamp_ms}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +pub enum SageAppRuntimeMode { + Inline, + Windowed, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +pub enum SageAppRuntimeVisibility { + Visible, + Hidden, +} + +#[derive(Debug)] +pub struct SageAppRuntimeRecord { + runtime_id: String, + app: SharedSageApp, + host_window_label: String, + webview_label: String, + presentation: AppPresentation, + mode: SageAppRuntimeMode, + visibility: SageAppRuntimeVisibility, + started_at: i64, + last_active_at: i64, + internal: bool, +} + +#[derive(Debug, Clone)] +pub struct SharedRuntime { + inner: Arc>, +} + +#[derive(Default)] +pub struct AppRuntimeState { + pub apps_workspace_active: tokio::sync::RwLock, + + pub runtime_by_runtime_id: Mutex>, + pub runtime_id_by_app_id: Mutex>, + + pub before_stop_listeners_by_app_id: Mutex>, + pub pending_stop_ready: Mutex>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SageLifecycleBeforeStopDetail { + pub request_id: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub app_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SetBeforeStopListenerParams { + active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadyToStopParams { + request_id: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeAckResult { + pub ok: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateRuntimeRecordError { + UserAppCannotUseModalPresentation, +} + +impl std::fmt::Display for CreateRuntimeRecordError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UserAppCannotUseModalPresentation => { + write!(f, "user app cannot use modal presentation") + } + } + } +} + +pub(in crate::runtime) fn runtime_id_for(app: &SharedSageApp) -> String { + let (app_id, is_system_app) = app.with(|app| (app.id().to_string(), app.is_system())); + if is_system_app { + return format!("system-runtime-{app_id}"); + } + + format!("runtime-{app_id}") +} + +impl SetBeforeStopListenerParams { + pub fn active(self) -> bool { + self.active + } +} + +impl ReadyToStopParams { + pub fn request_id(&self) -> &str { + &self.request_id + } +} + +impl SageAppRuntimeRecord { + pub(crate) fn new( + app: &SharedSageApp, + host_window_label: &str, + webview_label: &str, + presentation: AppPresentation, + mode: SageAppRuntimeMode, + visibility: SageAppRuntimeVisibility, + internal: bool, + ) -> Result { + if app.is_user_app() && matches!(presentation, AppPresentation::Modal(_)) { + return Err(CreateRuntimeRecordError::UserAppCannotUseModalPresentation); + } + + let now = unix_timestamp_ms(); + + Ok(Self { + runtime_id: runtime_id_for(app), + app: app.clone(), + host_window_label: host_window_label.to_string(), + webview_label: webview_label.to_string(), + presentation, + mode, + visibility, + started_at: now, + last_active_at: now, + internal, + }) + } + + pub(crate) fn mark_visible(&mut self) { + self.visibility = SageAppRuntimeVisibility::Visible; + self.last_active_at = unix_timestamp_ms(); + } + + pub(crate) fn mark_hidden(&mut self) { + self.visibility = SageAppRuntimeVisibility::Hidden; + self.last_active_at = unix_timestamp_ms(); + } + + pub(crate) fn runtime_id(&self) -> String { + self.runtime_id.to_string() + } + + pub(crate) fn app(&self) -> SharedSageApp { + self.app.clone() + } + + pub(crate) fn app_id(&self) -> String { + self.app.id() + } + + pub(crate) fn webview_label(&self) -> String { + self.webview_label.to_string() + } + + pub(crate) fn host_window_label(&self) -> String { + self.host_window_label.to_string() + } + + pub(crate) fn presentation(&self) -> AppPresentation { + self.presentation.clone() + } + + pub(crate) fn update_modal_presentation_list( + &mut self, + target_app_ids: Vec, + ) -> Result { + match &mut self.presentation { + AppPresentation::Modal(presentation) => Ok(presentation.update_app_ids(target_app_ids)), + AppPresentation::Taskbar => Err("Presentation mode is not modal".to_string()), + } + } + + pub(crate) fn mode(&self) -> SageAppRuntimeMode { + self.mode + } + + pub(crate) fn visibility(&self) -> SageAppRuntimeVisibility { + self.visibility + } + + pub(crate) fn started_at(&self) -> i64 { + self.started_at + } + + pub(crate) fn last_active_at(&self) -> i64 { + self.last_active_at + } + + pub(crate) fn internal(&self) -> bool { + self.internal + } +} + +impl std::fmt::Debug for AppRuntimeState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppRuntimeState").finish() + } +} + +impl SharedRuntime { + pub fn new(runtime: SageAppRuntimeRecord) -> Self { + Self { + inner: Arc::new(RwLock::new(runtime)), + } + } + + pub fn with_runtime(&self, f: impl FnOnce(&SageAppRuntimeRecord) -> T) -> T { + let runtime = self.inner.read(); + f(&runtime) + } + + pub fn with_runtime_mut(&self, f: impl FnOnce(&mut SageAppRuntimeRecord) -> T) -> T { + let mut runtime = self.inner.write(); + f(&mut runtime) + } + + pub fn app(&self) -> SharedSageApp { + self.with_runtime(|runtime| runtime.app.clone()) + } + + pub fn with_app(&self, f: impl FnOnce(&SharedSageApp) -> T) -> T { + let app = self.app(); + f(&app) + } + + pub fn with_app_inner(&self, f: impl FnOnce(&SageApp) -> T) -> T { + self.with_runtime(|runtime| runtime.app().with(f)) + } + + pub fn is_user_app(&self) -> bool { + self.with_app(SharedSageApp::is_user_app) + } + + pub fn is_system_app(&self) -> bool { + self.with_app(SharedSageApp::is_system_app) + } + + pub fn runtime_id(&self) -> String { + self.with_runtime(|runtime| runtime.runtime_id().to_string()) + } + + pub fn app_id(&self) -> String { + self.with_app_inner(|app| app.id().to_string()) + } + + pub fn is_taskbar(&self) -> bool { + self.with_runtime(|runtime| runtime.presentation() == AppPresentation::Taskbar) + } +} diff --git a/crates/sage-apps/src/runtime/state/view.rs b/crates/sage-apps/src/runtime/state/view.rs new file mode 100644 index 000000000..3c2db0ed9 --- /dev/null +++ b/crates/sage-apps/src/runtime/state/view.rs @@ -0,0 +1,44 @@ +use serde::Serialize; +use specta::Type; + +use crate::{ + AppPresentation, SageAppRuntimeMode, SageAppRuntimeVisibility, SageAppView, SharedRuntime, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SageAppRuntimeRecordView { + runtime_id: String, + app: SageAppView, + host_window_label: String, + webview_label: String, + presentation: AppPresentation, + mode: SageAppRuntimeMode, + visibility: SageAppRuntimeVisibility, + started_at: i64, + last_active_at: i64, + internal: bool, +} + +impl From<&SharedRuntime> for SageAppRuntimeRecordView { + fn from(value: &SharedRuntime) -> Self { + value.with_runtime(|runtime| Self { + runtime_id: runtime.runtime_id().clone(), + app: runtime.app().into(), + host_window_label: runtime.host_window_label().to_string(), + webview_label: runtime.webview_label().to_string(), + presentation: runtime.presentation(), + mode: runtime.mode(), + visibility: runtime.visibility(), + started_at: runtime.started_at(), + last_active_at: runtime.last_active_at(), + internal: runtime.internal(), + }) + } +} + +impl From for SageAppRuntimeRecordView { + fn from(value: SharedRuntime) -> Self { + Self::from(&value) + } +} diff --git a/crates/sage-apps/src/runtime/state/write.rs b/crates/sage-apps/src/runtime/state/write.rs new file mode 100644 index 000000000..636b9ee6a --- /dev/null +++ b/crates/sage-apps/src/runtime/state/write.rs @@ -0,0 +1,45 @@ +use tauri::State; + +use super::types::{SageAppRuntimeRecord, SharedRuntime}; +use crate::AppsHostState; + +pub(crate) async fn write_runtime( + apps_state: &State<'_, AppsHostState>, + runtime: SageAppRuntimeRecord, +) -> SharedRuntime { + let runtime_id = runtime.runtime_id().to_string(); + let app_id = runtime.app().id().to_string(); + + let runtime = SharedRuntime::new(runtime); + + { + let mut by_app_id = apps_state.runtime.runtime_id_by_app_id.lock().await; + by_app_id.insert(app_id, runtime_id.clone()); + } + + { + let mut by_runtime_id = apps_state.runtime.runtime_by_runtime_id.lock().await; + by_runtime_id.insert(runtime_id, runtime.clone()); + } + + runtime +} + +pub(crate) async fn write_pending_stop_ready( + apps_state: &State<'_, AppsHostState>, + request_id: &str, + tx: tokio::sync::oneshot::Sender<()>, +) { + let mut pending = apps_state.runtime.pending_stop_ready.lock().await; + pending.insert(request_id.to_string(), tx); +} + +pub(crate) async fn activate_apps_workspace(apps_state: &State<'_, AppsHostState>) { + let mut active = apps_state.runtime.apps_workspace_active.write().await; + *active = true; +} + +pub(crate) async fn deactivate_apps_workspace(apps_state: &State<'_, AppsHostState>) { + let mut active = apps_state.runtime.apps_workspace_active.write().await; + *active = false; +} diff --git a/crates/sage-apps/src/runtime/stop.rs b/crates/sage-apps/src/runtime/stop.rs new file mode 100644 index 000000000..a46a2c1e5 --- /dev/null +++ b/crates/sage-apps/src/runtime/stop.rs @@ -0,0 +1,163 @@ +use std::fmt::Display; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, State}; +use tokio::sync::oneshot; +use tokio::time::timeout; +use uuid::Uuid; + +use crate::{ + AppsHostState, BeforeStopEvent, GetRuntimeError, RuntimeChangeSet, SageAppRuntimeRecord, + SharedRuntime, emit_runtime_manager_runtimes_changed, emit_user_runtime_event_to_app_id, + find_active_taskbar_runtime, find_runtime_by_runtime_id_optional, + find_runtime_id_by_app_id_optional, find_webview_in_sage_window, get_runtime_by_app_id, + remove_before_stop_listeners_by_app_id, remove_pending_stop_ready, + remove_runtime_by_runtime_id, remove_runtime_id_by_app_id, sync_modal_runtime_visibility, + write_pending_stop_ready, +}; + +const BEFORE_STOP_TIMEOUT_MS: u64 = 5_000; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SystemKillRuntimeResult { + pub ok: bool, + pub app_id: String, +} + +#[derive(Debug, Clone)] +pub enum SystemKillRuntimeError { + NotFound, + RuntimeSync(String), +} + +impl Display for SystemKillRuntimeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SystemKillRuntimeError::NotFound => write!(f, "Runtime not found"), + SystemKillRuntimeError::RuntimeSync(err) => write!(f, "Runtime sync: {err}"), + } + } +} + +pub(crate) async fn kill_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + reason: &str, +) -> Result<(), SystemKillRuntimeError> { + let mut changes = RuntimeChangeSet::default(); + + kill_runtime_inner(app_handle, apps_state, app_id, reason, &mut changes).await?; + + changes.emit(app_handle, apps_state).await; + + Ok(()) +} + +pub(crate) async fn kill_runtime_inner( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + reason: &str, + changes: &mut RuntimeChangeSet, +) -> Result<(), SystemKillRuntimeError> { + let shared_runtime = + get_runtime_by_app_id(apps_state, app_id) + .await + .map_err(|err| match err { + GetRuntimeError::NotFound => SystemKillRuntimeError::NotFound, + })?; + + let host_window_label = shared_runtime.with_runtime(SageAppRuntimeRecord::host_window_label); + + let active_taskbar_runtime = find_active_taskbar_runtime(apps_state, &host_window_label).await; + + close_runtime_internal_with_reason(app_handle, apps_state, app_id, reason).await; + + changes.runtimes_changed(); + + if let Some(active_taskbar_runtime) = active_taskbar_runtime + && active_taskbar_runtime.app_id() == app_id + { + changes.active_taskbar_changed(&host_window_label); + } + + sync_modal_runtime_visibility(app_handle, apps_state, &host_window_label, changes) + .await + .map_err(SystemKillRuntimeError::RuntimeSync)?; + + Ok(()) +} + +pub(crate) async fn close_runtime_internal( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, +) { + close_runtime_internal_with_reason(app, apps_state, app_id, "host_close").await; +} + +pub(super) async fn close_runtime_internal_with_reason( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + _reason: &str, +) { + let Some(runtime_id) = find_runtime_id_by_app_id_optional(apps_state, app_id).await else { + return; + }; + let Some(runtime) = find_runtime_by_runtime_id_optional(apps_state, &runtime_id).await else { + remove_runtime_id_by_app_id(apps_state, app_id).await; + return; + }; + + let _ = wait_for_before_stop_ack(app, apps_state, &runtime).await; + + if let Some(webview) = find_webview_in_sage_window(app, &runtime.app().webview_label()) { + let _ = webview.close(); + } + + remove_runtime_by_runtime_id(apps_state, &runtime_id).await; + remove_runtime_id_by_app_id(apps_state, app_id).await; + remove_before_stop_listeners_by_app_id(apps_state, app_id).await; + emit_runtime_manager_runtimes_changed(app, apps_state).await; +} + +async fn wait_for_before_stop_ack( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + runtime: &SharedRuntime, +) -> Result<(), String> { + let has_listener = { + let listeners = apps_state + .runtime + .before_stop_listeners_by_app_id + .lock() + .await; + listeners.contains(&runtime.app_id()) + }; + + if !has_listener { + return Ok(()); + } + + let request_id = Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + + write_pending_stop_ready(apps_state, &request_id, tx).await; + + let _ = emit_user_runtime_event_to_app_id( + app_handle, + &runtime.app_id(), + BeforeStopEvent::new(&request_id), + ) + .await; + let _ = timeout(Duration::from_millis(BEFORE_STOP_TIMEOUT_MS), rx).await; + + remove_pending_stop_ready(apps_state, &request_id).await; + + Ok(()) +} diff --git a/crates/sage-apps/src/runtime/storage.rs b/crates/sage-apps/src/runtime/storage.rs new file mode 100644 index 000000000..626be8e4b --- /dev/null +++ b/crates/sage-apps/src/runtime/storage.rs @@ -0,0 +1,144 @@ +use std::collections::BTreeMap; +use std::sync::OnceLock; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; +use tokio::sync::{Mutex, oneshot}; +use tokio::time::timeout; + +use crate::{ + AppsHostState, BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID, SageAppStorage, close_runtime_internal, + start_origin_cleanup_runtime, +}; + +#[derive(Debug, Clone)] +pub struct OriginCleanupRuntimeTarget { + pub origin_id: String, + pub storage: SageAppStorage, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OriginCleanupBridgePayload { + pub kind: String, + pub cleanup_id: String, + pub ok: bool, + #[serde(default)] + pub errors: Vec, +} + +#[derive(Debug, Clone)] +pub struct OriginCleanupResult { + pub ok: bool, + pub errors: Vec, +} + +type PendingOriginCleanups = Mutex>>; + +fn pending_origin_cleanups() -> &'static PendingOriginCleanups { + static PENDING: OnceLock = OnceLock::new(); + PENDING.get_or_init(Default::default) +} + +async fn register_pending_origin_cleanup( + cleanup_id: String, +) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + + pending_origin_cleanups() + .lock() + .await + .insert(cleanup_id, tx); + + rx +} + +async fn remove_pending_origin_cleanup(cleanup_id: &str) { + pending_origin_cleanups().lock().await.remove(cleanup_id); +} + +async fn wait_for_origin_cleanup_result( + cleanup_id: &str, + rx: oneshot::Receiver, + timeout_ms: u64, +) -> Result { + match timeout(Duration::from_millis(timeout_ms), rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(format!( + "origin cleanup channel closed before result for {cleanup_id}" + )), + Err(_) => Err(format!( + "timed out waiting for origin cleanup result for {cleanup_id}" + )), + } +} + +pub(crate) async fn run_origin_cleanup( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + target: OriginCleanupRuntimeTarget, +) -> anyhow::Result<()> { + let cleanup_id = uuid::Uuid::new_v4().to_string(); + let rx = register_pending_origin_cleanup(cleanup_id.clone()).await; + + let mut query = BTreeMap::new(); + query.insert("cleanupId".to_string(), cleanup_id.clone()); + + let start_result = start_origin_cleanup_runtime(app, apps_state, target, query).await; + + if let Err(err) = start_result { + remove_pending_origin_cleanup(&cleanup_id).await; + anyhow::bail!("failed to start origin cleanup runtime: {err}"); + } + + let result = wait_for_origin_cleanup_result(&cleanup_id, rx, 10_000) + .await + .map_err(anyhow::Error::msg); + + close_runtime_internal(app, apps_state, BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID).await; + + let result = result?; + + if !result.ok { + anyhow::bail!("origin cleanup failed: {:?}", result.errors); + } + + Ok(()) +} + +pub(crate) async fn ingest_origin_cleanup_bridge_send_payload( + app_id: &str, + payload: &serde_json::Value, + _host_state: &AppsHostState, +) -> anyhow::Result<()> { + if app_id != BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID { + anyhow::bail!("origin cleanup payload came from unexpected app {app_id}"); + } + + let payload: OriginCleanupBridgePayload = serde_json::from_value(payload.clone())?; + + if payload.kind != "originCleanup.completed" { + return Ok(()); + } + + let Some(tx) = pending_origin_cleanups() + .lock() + .await + .remove(&payload.cleanup_id) + else { + tracing::warn!( + app_id = %app_id, + cleanup_id = %payload.cleanup_id, + "origin cleanup result received without pending waiter" + ); + return Ok(()); + }; + + let _ = tx.send(OriginCleanupResult { + ok: payload.ok, + errors: payload.errors, + }); + + Ok(()) +} diff --git a/crates/sage-apps/src/runtime/system_apps.rs b/crates/sage-apps/src/runtime/system_apps.rs new file mode 100644 index 000000000..7bbe875cc --- /dev/null +++ b/crates/sage-apps/src/runtime/system_apps.rs @@ -0,0 +1,159 @@ +use std::collections::BTreeMap; + +use tauri::{AppHandle, State}; + +use crate::{ + AppModalPresentation, AppPresentation, AppsHostState, CreateRuntimeArgs, RuntimeChangeSet, + SYSTEM_APP_APP_INSTALL_ID, SYSTEM_APP_APP_UPDATE_ID, SYSTEM_APP_BRIDGE_APPROVAL_ID, + SYSTEM_APP_DONATION_ID, SYSTEM_APP_SANDBOX_TESTS_ID, SageAppRuntimeMode, SageAppRuntimeRecord, + SharedRuntime, kill_runtime_inner, pending_approval_app_ids, start_system_app, + sync_modal_runtime_visibility, +}; + +pub(crate) async fn start_app_install_runtime( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + query: BTreeMap, +) -> Result { + start_system_app( + app, + apps_state, + CreateRuntimeArgs { + app_id: SYSTEM_APP_APP_INSTALL_ID.to_string(), + presentation: AppPresentation::Modal(AppModalPresentation::over_launchpad(40)), + mode: SageAppRuntimeMode::Inline, + debug_layout: false, + query, + }, + ) + .await +} + +pub(crate) async fn start_app_update_runtime( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + target_app_id: String, + query: BTreeMap, +) -> Result { + start_system_app( + app, + apps_state, + CreateRuntimeArgs { + app_id: SYSTEM_APP_APP_UPDATE_ID.to_string(), + presentation: AppPresentation::Modal(AppModalPresentation::over_app_and_launchpad( + target_app_id, + 50, + )), + mode: SageAppRuntimeMode::Inline, + debug_layout: false, + query, + }, + ) + .await +} + +pub(crate) async fn start_bridge_approval_runtime( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + target_app_ids: Vec, +) -> Result { + start_system_app( + app, + apps_state, + CreateRuntimeArgs { + app_id: SYSTEM_APP_BRIDGE_APPROVAL_ID.to_string(), + presentation: AppPresentation::Modal(AppModalPresentation::over_apps( + target_app_ids, + 100, + )), + mode: SageAppRuntimeMode::Inline, + debug_layout: false, + query: BTreeMap::new(), + }, + ) + .await +} + +pub(crate) async fn start_donation_runtime( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + target_app_id: String, + query: BTreeMap, +) -> Result { + start_system_app( + app, + apps_state, + CreateRuntimeArgs { + app_id: SYSTEM_APP_DONATION_ID.to_string(), + presentation: AppPresentation::Modal(AppModalPresentation::over_apps( + vec![target_app_id], + 45, + )), + mode: SageAppRuntimeMode::Inline, + debug_layout: false, + query, + }, + ) + .await +} + +pub(crate) async fn sync_bridge_approval_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(), String> { + let mut changes = RuntimeChangeSet::default(); + + let visible_over_app_ids = pending_approval_app_ids(apps_state).await; + if visible_over_app_ids.is_empty() { + let _ = kill_runtime_inner( + app_handle, + apps_state, + SYSTEM_APP_BRIDGE_APPROVAL_ID, + "no_pending_approvals", + &mut changes, + ) + .await; + + changes.emit(app_handle, apps_state).await; + + return Ok(()); + } + + let approval_runtime = + start_bridge_approval_runtime(app_handle, apps_state, visible_over_app_ids.clone()).await?; + + let presentation_changed = approval_runtime + .with_runtime_mut(|runtime| runtime.update_modal_presentation_list(visible_over_app_ids))?; + + let mut changes = RuntimeChangeSet::default(); + + if presentation_changed { + changes.runtimes_changed(); + } + + let host_window_label = approval_runtime.with_runtime(SageAppRuntimeRecord::host_window_label); + + sync_modal_runtime_visibility(app_handle, apps_state, &host_window_label, &mut changes).await?; + + changes.emit(app_handle, apps_state).await; + + Ok(()) +} + +pub(crate) async fn start_sandbox_tests_runtime( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result { + start_system_app( + app, + apps_state, + CreateRuntimeArgs { + app_id: SYSTEM_APP_SANDBOX_TESTS_ID.to_string(), + presentation: AppPresentation::Modal(AppModalPresentation::over_launchpad(60)), + mode: SageAppRuntimeMode::Inline, + debug_layout: false, + query: BTreeMap::new(), + }, + ) + .await +} diff --git a/crates/sage-apps/src/runtime/webview_locator.rs b/crates/sage-apps/src/runtime/webview_locator.rs new file mode 100644 index 000000000..c3ee5747e --- /dev/null +++ b/crates/sage-apps/src/runtime/webview_locator.rs @@ -0,0 +1,31 @@ +use tauri::{AppHandle, Manager}; + +const SAGE_WINDOW_LABEL: &str = "main"; +const SAGE_WEBVIEW_LABEL: &str = "main"; + +pub(crate) fn find_sage_window(app: &AppHandle) -> Option { + app.get_window(SAGE_WINDOW_LABEL) +} + +pub(crate) fn get_sage_window(app: &AppHandle) -> Result { + find_sage_window(app).ok_or_else(|| "missing sage window".to_string()) +} + +pub(crate) fn find_webview_in_sage_window( + app: &AppHandle, + webview_label: &str, +) -> Option { + find_sage_window(app)?.get_webview(webview_label) +} + +pub(crate) fn get_webview_in_sage_window( + app: &AppHandle, + webview_label: &str, +) -> Result { + find_webview_in_sage_window(app, webview_label) + .ok_or_else(|| format!("missing '{webview_label}' webview").to_string()) +} + +pub(crate) fn get_sage_webview(app: &AppHandle) -> Result { + get_webview_in_sage_window(app, SAGE_WEBVIEW_LABEL) +} diff --git a/crates/sage-apps/src/runtime/workspace.rs b/crates/sage-apps/src/runtime/workspace.rs new file mode 100644 index 000000000..f620d5f98 --- /dev/null +++ b/crates/sage-apps/src/runtime/workspace.rs @@ -0,0 +1,46 @@ +use tauri::{AppHandle, State}; + +use crate::{ + AppsHostState, RuntimeChangeSet, activate_apps_workspace, deactivate_apps_workspace, + get_sage_window, hide_all_runtimes, hide_all_runtimes_inner, is_apps_workspace_active, + sync_modal_runtime_visibility, +}; + +pub(crate) async fn ensure_apps_workspace_active( + apps_state: &State<'_, AppsHostState>, +) -> Result<(), String> { + if !is_apps_workspace_active(apps_state).await { + return Err("Apps workspace is not active".to_string()); + } + + Ok(()) +} + +pub(crate) async fn enter_apps_workspace( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(), String> { + activate_apps_workspace(apps_state).await; + + let sage_window = get_sage_window(app_handle)?; + let mut changes = RuntimeChangeSet::default(); + + hide_all_runtimes_inner(app_handle, apps_state, &mut changes).await?; + + sync_modal_runtime_visibility(app_handle, apps_state, sage_window.label(), &mut changes) + .await?; + + changes.emit(app_handle, apps_state).await; + + Ok(()) +} + +pub(crate) async fn leave_apps_workspace( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(), String> { + deactivate_apps_workspace(apps_state).await; + hide_all_runtimes(app_handle, apps_state).await?; + + Ok(()) +} diff --git a/crates/sage-apps/src/sandbox.rs b/crates/sage-apps/src/sandbox.rs new file mode 100644 index 000000000..f8bd230f4 --- /dev/null +++ b/crates/sage-apps/src/sandbox.rs @@ -0,0 +1,20 @@ +mod builtin_apps; +mod commands; +mod gate; +mod ingest; +mod probes; +mod runner; +mod runtime; +mod state_view; +mod store; +mod types; + +pub use builtin_apps::*; +pub use commands::*; +pub use gate::*; +pub use ingest::*; +pub use runner::*; +pub use store::*; +pub use types::*; + +pub(crate) use state_view::*; diff --git a/crates/sage-apps/src/sandbox/builtin_apps.rs b/crates/sage-apps/src/sandbox/builtin_apps.rs new file mode 100644 index 000000000..97c70ee48 --- /dev/null +++ b/crates/sage-apps/src/sandbox/builtin_apps.rs @@ -0,0 +1,256 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::{fs, path::PathBuf}; + +use anyhow::{Context, Result as AnyResult}; +#[cfg(any(target_os = "macos", target_os = "ios"))] +use sha2::{Digest, Sha256}; + +use crate::{ + AppBuildError, SageApp, SageAppCommon, SageAppIdentity, SageAppPackageManifest, + SageAppSnapshot, SageAppStorage, SageAppWalletScope, SageGrantedPermissions, UserSageApp, + UserSageAppSource, builtin_apps_root, +}; + +macro_rules! sandbox_test_id_prefix { + () => { + "__sage_test_" + }; +} + +macro_rules! runtime_id_prefix { + () => { + "__sage_runtime_" + }; +} + +pub const SANDBOX_TEST_ID_PREFIX: &str = sandbox_test_id_prefix!(); +pub const RUNTIME_ID_PREFIX: &str = runtime_id_prefix!(); + +pub const BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID: &str = + concat!(sandbox_test_id_prefix!(), "storage_isolation_persistent"); +pub const BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID: &str = + concat!(sandbox_test_id_prefix!(), "storage_isolation_incognito"); +pub const BUILTIN_PERSISTENCE_PERSISTENT_ID: &str = + concat!(sandbox_test_id_prefix!(), "persistence_persistent"); +pub const BUILTIN_PERSISTENCE_INCOGNITO_ID: &str = + concat!(sandbox_test_id_prefix!(), "persistence_incognito"); +pub const BUILTIN_STORAGE_CLEAR_PERSISTENT_ID: &str = + concat!(sandbox_test_id_prefix!(), "storage_clear_persistent"); +pub const BUILTIN_NETWORK_ALLOW_A_ID: &str = concat!(sandbox_test_id_prefix!(), "network_allow_a"); +pub const BUILTIN_NETWORK_ALLOW_B_ID: &str = concat!(sandbox_test_id_prefix!(), "network_allow_b"); + +pub const BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID: &str = concat!(runtime_id_prefix!(), "origin_cleanup"); + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinTestAppSpec { + pub app_id: &'static str, + pub dir_name: &'static str, +} + +const BUILTIN_TEST_APPS: &[BuiltinTestAppSpec] = &[ + BuiltinTestAppSpec { + app_id: BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID, + dir_name: "sage-storage-isolation-persistent", + }, + BuiltinTestAppSpec { + app_id: BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID, + dir_name: "sage-storage-isolation-incognito", + }, + BuiltinTestAppSpec { + app_id: BUILTIN_PERSISTENCE_PERSISTENT_ID, + dir_name: "storage-persistence-persistent", + }, + BuiltinTestAppSpec { + app_id: BUILTIN_PERSISTENCE_INCOGNITO_ID, + dir_name: "storage-persistence-incognito", + }, + BuiltinTestAppSpec { + app_id: BUILTIN_STORAGE_CLEAR_PERSISTENT_ID, + dir_name: "storage-clear-persistent", + }, + BuiltinTestAppSpec { + app_id: BUILTIN_NETWORK_ALLOW_A_ID, + dir_name: "network-allow-a", + }, + BuiltinTestAppSpec { + app_id: BUILTIN_NETWORK_ALLOW_B_ID, + dir_name: "network-allow-b", + }, +]; + +pub fn builtin_test_app_spec(app_id: &str) -> Option<&'static BuiltinTestAppSpec> { + BUILTIN_TEST_APPS.iter().find(|spec| spec.app_id == app_id) +} + +pub fn builtin_test_apps_root() -> PathBuf { + builtin_apps_root().join("sandbox-test") +} + +pub fn builtin_runtime_apps_root() -> PathBuf { + builtin_apps_root().join("runtime") +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +fn builtin_storage(app_id: &str) -> SageAppStorage { + let mut hasher = Sha256::new(); + hasher.update(format!("builtin-storage:{app_id}").as_bytes()); + let digest = hasher.finalize(); + + SageAppStorage::AppleDataStore { + identifier_hex: hex::encode(&digest[..16]), + } +} + +#[cfg(target_os = "windows")] +fn builtin_storage(app_id: &str) -> SageAppStorage { + SageAppStorage::WindowsProfile { + directory_name: format!("builtin-profile-{app_id}"), + } +} + +#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] +fn builtin_storage(_app_id: &str) -> SageAppStorage { + SageAppStorage::Unmanaged +} + +fn read_builtin_manifest(app_dir: &Path) -> AnyResult { + let manifest_path = app_dir.join("sage-manifest.json"); + + let manifest_text = fs::read_to_string(&manifest_path).with_context(|| { + format!( + "failed to read builtin app manifest {}", + manifest_path.display() + ) + })?; + + serde_json::from_str::(&manifest_text).with_context(|| { + format!( + "failed to parse builtin app manifest {}", + manifest_path.display() + ) + }) +} + +pub fn build_builtin_test_app(app_id: &str) -> Result, AppBuildError> { + let Some(spec) = builtin_test_app_spec(app_id) else { + return Ok(None); + }; + + let app_dir = builtin_test_apps_root().join(spec.dir_name); + + if !app_dir.is_dir() { + return Err(AppBuildError::AppDirMissing); + } + + let manifest = read_builtin_manifest(&app_dir) + .map_err(|err| AppBuildError::ManifestFailure(format!("{err:#}")))?; + + let granted_permissions = SageGrantedPermissions::new( + manifest.permissions(), + manifest.permissions().capabilities().user_grantable(), + manifest + .permissions() + .network() + .whitelist() + .required() + .cloned(), + BTreeMap::new(), + ) + .map_err(|_| AppBuildError::InternalError)?; + + let snapshot = SageAppSnapshot::new( + format!("builtin:{}", spec.app_id), + app_dir.to_string_lossy().to_string(), + manifest.clone(), + ) + .map_err(|_| AppBuildError::InternalError)?; + + let common = SageAppCommon::new( + SageAppIdentity::new( + spec.app_id.to_string(), + spec.app_id.to_string(), + app_dir.to_string_lossy().to_string(), + ) + .map_err(|_| AppBuildError::InternalError)?, + granted_permissions, + builtin_storage(spec.app_id), + snapshot, + SageAppWalletScope::AllWallets, + ) + .map_err(|_| AppBuildError::InternalError)?; + + let entry_file = app_dir.join(common.entry_file()); + if !entry_file.is_file() { + return Err(AppBuildError::EntryFileNotFound); + } + + let app = UserSageApp::new_installed(common, UserSageAppSource::Zip); + + Ok(Some(SageApp::User(app))) +} + +pub fn build_builtin_runtime_app(app_id: &str) -> Result, AppBuildError> { + if app_id != BUILTIN_ORIGIN_CLEANUP_RUNTIME_ID { + return Ok(None); + } + + let app_dir = builtin_runtime_apps_root().join("origin-cleanup"); + + if !app_dir.is_dir() { + return Err(AppBuildError::AppDirMissing); + } + + let manifest = read_builtin_manifest(&app_dir).map_err(|err| { + tracing::error!( + error = %err, + app_dir = %app_dir.display(), + "failed to build builtin runtime app manifest" + ); + + AppBuildError::ManifestFailure(format!("{err:#}")) + })?; + + let granted_permissions = SageGrantedPermissions::for_builtin_requested(manifest.permissions()) + .map_err(|err| { + tracing::error!("runtime app granted_permissions failed: {err}"); + AppBuildError::InternalError + })?; + + let snapshot = SageAppSnapshot::new( + format!("builtin-runtime:{app_id}"), + app_dir.to_string_lossy().to_string(), + manifest.clone(), + ) + .map_err(|err| { + tracing::error!("runtime app snapshot failed: {err}"); + AppBuildError::InternalError + })?; + + let common = SageAppCommon::new( + SageAppIdentity::new(app_id, app_id, app_dir.to_string_lossy().to_string()).map_err( + |err| { + tracing::error!("runtime app identity failed: {err}"); + AppBuildError::InternalError + }, + )?, + granted_permissions, + SageAppStorage::Unmanaged, + snapshot, + SageAppWalletScope::AllWallets, + ) + .map_err(|err| { + tracing::error!("runtime app common failed: {err}"); + AppBuildError::InternalError + })?; + + let entry_file = app_dir.join(common.entry_file()); + if !entry_file.is_file() { + return Err(AppBuildError::EntryFileNotFound); + } + + Ok(Some(SageApp::User(UserSageApp::new_installed( + common, + UserSageAppSource::Zip, + )))) +} diff --git a/crates/sage-apps/src/sandbox/commands.rs b/crates/sage-apps/src/sandbox/commands.rs new file mode 100644 index 000000000..6551994b8 --- /dev/null +++ b/crates/sage-apps/src/sandbox/commands.rs @@ -0,0 +1,53 @@ +use tauri::{AppHandle, State, command}; + +use super::gate::evaluate_app_launch_gate; +use super::runner::{begin_sandbox_run, sandbox_runner}; +use super::state_view::{build_effective_state, build_state_view}; +use super::types::{AppLaunchGateResult, SandboxStateView}; +use crate::{AppsHostState, resolve_app}; + +#[command] +#[specta::specta] +pub async fn apps_get_sandbox_state( + apps_state: State<'_, AppsHostState>, +) -> Result { + Ok(build_state_view(&apps_state).await) +} + +#[command] +#[specta::specta] +pub async fn apps_get_app_launch_gate( + app_handle: AppHandle, + apps_state: State<'_, AppsHostState>, + app_id: String, +) -> Result { + let resolved_app = resolve_app(&app_handle, &app_id) + .await + .map_err(|_| "app not found".to_string())?; + + let baseline = apps_state.sandbox.baseline.lock().await.clone(); + let current_run = apps_state.sandbox.current_run.lock().await.clone(); + + let effective = build_effective_state(&baseline, current_run.as_ref()); + + let evaluated_gate = resolved_app.with_app(|app| evaluate_app_launch_gate(app, &effective)); + + Ok(evaluated_gate) +} + +#[command] +#[specta::specta] +pub async fn apps_rerun_sandbox_tests( + app: AppHandle, + apps_state: State<'_, AppsHostState>, +) -> Result { + let view = begin_sandbox_run(&app, &apps_state).await?; + + let runner_app = app.clone(); + tokio::spawn(async move { + let runner = Box::pin(sandbox_runner(runner_app)); + runner.await; + }); + + Ok(view) +} diff --git a/crates/sage-apps/src/sandbox/gate.rs b/crates/sage-apps/src/sandbox/gate.rs new file mode 100644 index 000000000..a80ff18c1 --- /dev/null +++ b/crates/sage-apps/src/sandbox/gate.rs @@ -0,0 +1,79 @@ +use super::{AppLaunchGateResult, SandboxCapability, SandboxCapabilityStatus, SandboxState}; +use crate::{SharedSageApp, UserBridgeCapability}; + +fn capability_status( + state: &SandboxState, + capability: SandboxCapability, +) -> SandboxCapabilityStatus { + match capability { + SandboxCapability::StorageIsolationFromSage => state.storage_isolation_from_sage.status, + SandboxCapability::StoragePersistenceNormal => state.storage_persistence_normal.status, + SandboxCapability::StorageNonPersistenceIncognito => { + state.storage_non_persistence_incognito.status + } + SandboxCapability::NetworkAllowlistEnforced => state.network_allowlist_enforced.status, + } +} + +fn required_capabilities_for_app(app: &SharedSageApp) -> Vec { + let mut caps = vec![ + SandboxCapability::StorageIsolationFromSage, + SandboxCapability::NetworkAllowlistEnforced, + ]; + + if app.is_capability_granted(UserBridgeCapability::StoragePersistentWebview.into()) { + caps.push(SandboxCapability::StoragePersistenceNormal); + } else { + caps.push(SandboxCapability::StorageNonPersistenceIncognito); + } + + caps +} + +pub fn evaluate_app_launch_gate( + app: &SharedSageApp, + effective: &SandboxState, +) -> AppLaunchGateResult { + if app.is_system_app() || app.id().starts_with("__sage_test_") { + return AppLaunchGateResult { + allowed: true, + kind: "allowed".into(), + capability: None, + message: None, + }; + } + + for capability in required_capabilities_for_app(app) { + match capability_status(effective, capability) { + SandboxCapabilityStatus::Passed => {} + SandboxCapabilityStatus::Pending | SandboxCapabilityStatus::Running => { + return AppLaunchGateResult { + allowed: false, + kind: "sandboxPending".into(), + capability: Some(capability), + message: Some( + "Apps are allowed to launch only when all required sandbox capabilities have passed." + .into(), + ), + }; + } + SandboxCapabilityStatus::Failed => { + return AppLaunchGateResult { + allowed: false, + kind: "sandboxFailed".into(), + capability: Some(capability), + message: Some( + "Apps are blocked because a required sandbox capability failed.".into(), + ), + }; + } + } + } + + AppLaunchGateResult { + allowed: true, + kind: "allowed".into(), + capability: None, + message: None, + } +} diff --git a/crates/sage-apps/src/sandbox/ingest.rs b/crates/sage-apps/src/sandbox/ingest.rs new file mode 100644 index 000000000..45e8d1347 --- /dev/null +++ b/crates/sage-apps/src/sandbox/ingest.rs @@ -0,0 +1,96 @@ +use serde_json::Value; +use tauri::State; + +use super::store::{SandboxAppResult, replace_by_app_id}; +use super::types::{ + SandboxIsolationProbeResult, SandboxNetworkProbeResult, SandboxPersistenceReadProbeResult, + SandboxPersistenceWriteProbeResult, +}; +use crate::AppsHostState; + +pub async fn ingest_bridge_send_payload( + app_id: &str, + payload: &Value, + apps_state: &State<'_, AppsHostState>, +) { + let Some(kind) = payload.get("kind").and_then(Value::as_str) else { + return; + }; + + if kind != "sandbox_report" { + return; + } + + let Some(report) = payload.get("report") else { + return; + }; + + let Some(report_type) = report.get("type").and_then(Value::as_str) else { + return; + }; + + let Some(data) = report.get("data").cloned() else { + return; + }; + + let run_id = data + .get("runId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + + if run_id.is_empty() { + return; + } + + let mut runs = apps_state.sandbox.runs.lock().await; + let run = runs.entry(run_id).or_default(); + + match report_type { + "isolation" => { + if let Ok(parsed) = serde_json::from_value::(data) { + replace_by_app_id( + &mut run.isolation, + SandboxAppResult { + app_id: app_id.to_string(), + data: parsed, + }, + ); + } + } + "persistence_write" => { + if let Ok(parsed) = serde_json::from_value::(data) { + replace_by_app_id( + &mut run.persistence_write, + SandboxAppResult { + app_id: app_id.to_string(), + data: parsed, + }, + ); + } + } + "persistence_read" => { + if let Ok(parsed) = serde_json::from_value::(data) { + replace_by_app_id( + &mut run.persistence_read, + SandboxAppResult { + app_id: app_id.to_string(), + data: parsed, + }, + ); + } + } + "network" => { + if let Ok(parsed) = serde_json::from_value::(data) { + replace_by_app_id( + &mut run.network, + SandboxAppResult { + app_id: app_id.to_string(), + data: parsed, + }, + ); + } + } + _ => {} + } +} diff --git a/crates/sage-apps/src/sandbox/probes.rs b/crates/sage-apps/src/sandbox/probes.rs new file mode 100644 index 000000000..cfb98975a --- /dev/null +++ b/crates/sage-apps/src/sandbox/probes.rs @@ -0,0 +1,8 @@ +mod isolation; +mod network; +mod persistence; +mod poll; + +pub(super) use isolation::*; +pub(super) use network::*; +pub(super) use persistence::*; diff --git a/crates/sage-apps/src/sandbox/probes/isolation.rs b/crates/sage-apps/src/sandbox/probes/isolation.rs new file mode 100644 index 000000000..fe741d517 --- /dev/null +++ b/crates/sage-apps/src/sandbox/probes/isolation.rs @@ -0,0 +1,259 @@ +use std::time::Duration; + +use serde::Deserialize; +use tauri::{AppHandle, Listener, State}; +use tokio::sync::oneshot; +use tokio::time::timeout; + +use super::super::runtime::{start_test_app, stop_test_apps, unique_run_id}; +use super::poll::poll_isolation; +use crate::{ + AppsHostState, BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID, BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID, + get_sage_webview, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HostIsolationSeedResult { + run_id: String, + local_storage_seeded: bool, + indexed_db_seeded: bool, + error: Option, +} + +async fn seed_host_isolation_probe(app: &AppHandle, run_id: &str) -> Result<(), String> { + let webview = get_sage_webview(app)?; + let event_name = format!("sage:sandbox-host-seed:{run_id}"); + let event_name_json = serde_json::to_string(&event_name) + .map_err(|err| format!("failed to encode isolation seed event name: {err}"))?; + let run_id_json = serde_json::to_string(run_id) + .map_err(|err| format!("failed to encode isolation probe value: {err}"))?; + + let (sender, receiver) = oneshot::channel(); + let listener_id = webview.once(event_name.clone(), move |event| { + let _ = sender.send(event.payload().to_string()); + }); + + let script = format!( + r"(async function () {{ + const eventName = {event_name_json}; + const runId = {run_id_json}; + const result = {{ + runId, + localStorageSeeded: false, + indexedDbSeeded: false, + error: null, + }}; + + function errorMessage(error) {{ + return error instanceof Error ? error.message : String(error); + }} + + function seedIndexedDb() {{ + return new Promise((resolve, reject) => {{ + let db = null; + let settled = false; + + function closeDb() {{ + try {{ db?.close(); }} catch {{}} + }} + + function fail(error) {{ + if (settled) return; + settled = true; + closeDb(); + reject(error); + }} + + function succeed(value) {{ + if (settled) return; + settled = true; + closeDb(); + resolve(value); + }} + + let open; + try {{ + open = indexedDB.open('sage_probe_db', 1); + }} catch (error) {{ + fail(error); + return; + }} + + open.onerror = () => fail(open.error ?? new Error('IndexedDB open failed')); + open.onblocked = () => fail(new Error('IndexedDB open was blocked')); + open.onupgradeneeded = () => {{ + try {{ + const upgradeDb = open.result; + if (!upgradeDb.objectStoreNames.contains('probe_store')) {{ + upgradeDb.createObjectStore('probe_store'); + }} + }} catch (error) {{ + try {{ open.transaction?.abort(); }} catch {{}} + fail(error); + }} + }}; + open.onsuccess = () => {{ + try {{ + db = open.result; + if (!db.objectStoreNames.contains('probe_store')) {{ + fail(new Error('IndexedDB probe store is missing')); + return; + }} + + const writeTx = db.transaction('probe_store', 'readwrite'); + writeTx.objectStore('probe_store').put(runId, 'sage_probe_key'); + writeTx.onerror = () => + fail(writeTx.error ?? new Error('IndexedDB probe write failed')); + writeTx.onabort = () => + fail(writeTx.error ?? new Error('IndexedDB probe write was aborted')); + writeTx.oncomplete = () => {{ + try {{ + const readTx = db.transaction('probe_store', 'readonly'); + const read = readTx.objectStore('probe_store').get('sage_probe_key'); + read.onerror = () => + fail(read.error ?? new Error('IndexedDB probe verification failed')); + read.onsuccess = () => succeed(read.result === runId); + }} catch (error) {{ + fail(error); + }} + }}; + }} catch (error) {{ + fail(error); + }} + }}; + }}); + }} + + try {{ + localStorage.setItem('sage_probe_local_storage', runId); + result.localStorageSeeded = + localStorage.getItem('sage_probe_local_storage') === runId; + if (!result.localStorageSeeded) {{ + throw new Error('localStorage probe verification failed'); + }} + + result.indexedDbSeeded = await seedIndexedDb(); + if (!result.indexedDbSeeded) {{ + throw new Error('IndexedDB probe verification failed'); + }} + }} catch (error) {{ + result.error = errorMessage(error); + }} + + await window.__TAURI__.event.emit(eventName, result); +}})();" + ); + + if let Err(err) = webview.eval(script) { + webview.unlisten(listener_id); + return Err(format!("failed to seed host isolation probe data: {err}")); + } + + let payload = match timeout(Duration::from_secs(5), receiver).await { + Ok(Ok(payload)) => payload, + Ok(Err(_)) => { + return Err("host isolation seed acknowledgement channel closed".into()); + } + Err(_) => { + webview.unlisten(listener_id); + return Err("timed out waiting for host isolation seed acknowledgement".into()); + } + }; + + let result: HostIsolationSeedResult = serde_json::from_str(&payload) + .map_err(|err| format!("invalid host isolation seed acknowledgement: {err}"))?; + + if result.run_id != run_id { + return Err("host isolation seed acknowledgement had the wrong run ID".into()); + } + + if let Some(error) = result.error { + return Err(format!("failed to seed host isolation probe data: {error}")); + } + + if !result.local_storage_seeded || !result.indexed_db_seeded { + return Err("host isolation probe data could not be verified".into()); + } + + Ok(()) +} + +pub(in crate::sandbox) async fn run_isolation_test( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(bool, Option), String> { + let run_id = unique_run_id("sandbox-isolation"); + let app_ids = [ + BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID, + BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID, + ]; + + stop_test_apps(app, apps_state, &app_ids).await; + + seed_host_isolation_probe(app, &run_id).await?; + + start_test_app( + app, + apps_state, + BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID, + &[("runId", run_id.clone())], + ) + .await?; + start_test_app( + app, + apps_state, + BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID, + &[("runId", run_id.clone())], + ) + .await?; + + let results = poll_isolation(apps_state, &run_id, 2, 2_000).await?; + stop_test_apps(app, apps_state, &app_ids).await; + + let persistent = results + .iter() + .find(|r| r.app_id == BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID); + let incognito = results + .iter() + .find(|r| r.app_id == BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID); + + let Some(persistent) = persistent else { + return Ok((false, Some("Missing persistent isolation result.".into()))); + }; + let Some(incognito) = incognito else { + return Ok((false, Some("Missing incognito isolation result.".into()))); + }; + + for (label, result) in [ + ("persistent", &persistent.data), + ("incognito", &incognito.data), + ] { + if result.error.is_some() { + return Ok(( + false, + Some(format!( + "{label} isolation probe reported error: {}", + result.error.clone().unwrap_or_default() + )), + )); + } + + if result.local_storage_visible || result.indexed_db_visible { + return Ok(( + false, + Some(format!( + "{label} probe was able to observe Sage probe data." + )), + )); + } + } + + Ok(( + true, + Some( + "Both sandbox probe modes were unable to observe the seeded Sage host probe data." + .into(), + ), + )) +} diff --git a/crates/sage-apps/src/sandbox/probes/network.rs b/crates/sage-apps/src/sandbox/probes/network.rs new file mode 100644 index 000000000..c01045a3c --- /dev/null +++ b/crates/sage-apps/src/sandbox/probes/network.rs @@ -0,0 +1,64 @@ +use tauri::{AppHandle, State}; + +use super::super::runtime::{start_test_app, stop_test_apps, unique_run_id}; +use super::poll::poll_network; +use crate::{AppsHostState, BUILTIN_NETWORK_ALLOW_A_ID, BUILTIN_NETWORK_ALLOW_B_ID}; + +pub(in crate::sandbox) async fn run_network_test( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<(bool, Option), String> { + let run_id = unique_run_id("sandbox-network"); + let app_ids = [BUILTIN_NETWORK_ALLOW_A_ID, BUILTIN_NETWORK_ALLOW_B_ID]; + + stop_test_apps(app, apps_state, &app_ids).await; + + start_test_app( + app, + apps_state, + BUILTIN_NETWORK_ALLOW_A_ID, + &[("runId", run_id.clone())], + ) + .await?; + start_test_app( + app, + apps_state, + BUILTIN_NETWORK_ALLOW_B_ID, + &[("runId", run_id.clone())], + ) + .await?; + + let results = poll_network(apps_state, &run_id, 2, 4_000).await?; + stop_test_apps(app, apps_state, &app_ids).await; + + for result in &results { + if result.data.error.is_some() { + return Ok((false, result.data.error.clone())); + } + + if !result.data.allowed_ok { + return Ok(( + false, + Some(format!( + "{} could not reach allowed URL {}.", + result.app_id, result.data.allowed_url + )), + )); + } + + if result.data.blocked_ok { + return Ok(( + false, + Some(format!( + "{} was able to reach blocked URL {}.", + result.app_id, result.data.blocked_url + )), + )); + } + } + + Ok(( + true, + Some("Network allowlist probes succeeded for allowed URLs and failed for blocked URLs in both flipped configurations.".into()), + )) +} diff --git a/crates/sage-apps/src/sandbox/probes/persistence.rs b/crates/sage-apps/src/sandbox/probes/persistence.rs new file mode 100644 index 000000000..f705ba288 --- /dev/null +++ b/crates/sage-apps/src/sandbox/probes/persistence.rs @@ -0,0 +1,185 @@ +use tauri::{AppHandle, State}; + +use super::super::runtime::{start_test_app, stop_test_apps, unique_run_id}; +use super::poll::{poll_persistence_read, poll_persistence_write}; +use crate::{AppsHostState, BUILTIN_PERSISTENCE_INCOGNITO_ID, BUILTIN_PERSISTENCE_PERSISTENT_ID}; + +pub(in crate::sandbox) async fn run_persistence_test( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result<((bool, Option), (bool, Option)), String> { + let run_id = unique_run_id("sandbox-persistence"); + let app_ids = [ + BUILTIN_PERSISTENCE_PERSISTENT_ID, + BUILTIN_PERSISTENCE_INCOGNITO_ID, + ]; + + stop_test_apps(app, apps_state, &app_ids).await; + + start_test_app( + app, + apps_state, + BUILTIN_PERSISTENCE_PERSISTENT_ID, + &[("runId", run_id.clone()), ("phase", "write".into())], + ) + .await?; + start_test_app( + app, + apps_state, + BUILTIN_PERSISTENCE_INCOGNITO_ID, + &[("runId", run_id.clone()), ("phase", "write".into())], + ) + .await?; + + let write_results = poll_persistence_write(apps_state, &run_id, 2, 2_000).await?; + stop_test_apps(app, apps_state, &app_ids).await; + + let persistent_write = write_results + .iter() + .find(|r| r.app_id == BUILTIN_PERSISTENCE_PERSISTENT_ID); + let incognito_write = write_results + .iter() + .find(|r| r.app_id == BUILTIN_PERSISTENCE_INCOGNITO_ID); + + let Some(persistent_write) = persistent_write else { + return Ok(( + (false, Some("Missing persistent write result.".into())), + (false, Some("Missing incognito write result.".into())), + )); + }; + let Some(incognito_write) = incognito_write else { + return Ok(( + (false, Some("Missing persistent write result.".into())), + (false, Some("Missing incognito write result.".into())), + )); + }; + + if persistent_write.data.error.is_some() + || !persistent_write.data.local_storage_wrote + || !persistent_write.data.indexed_db_wrote + { + return Ok(( + ( + false, + Some( + persistent_write + .data + .error + .clone() + .unwrap_or_else(|| "Persistent write probe failed.".into()), + ), + ), + ( + false, + Some( + persistent_write + .data + .error + .clone() + .unwrap_or_else(|| "Persistent write probe failed.".into()), + ), + ), + )); + } + + if incognito_write.data.error.is_some() + || !incognito_write.data.local_storage_wrote + || !incognito_write.data.indexed_db_wrote + { + return Ok(( + ( + false, + Some( + incognito_write + .data + .error + .clone() + .unwrap_or_else(|| "Incognito write probe failed.".into()), + ), + ), + ( + false, + Some( + incognito_write + .data + .error + .clone() + .unwrap_or_else(|| "Incognito write probe failed.".into()), + ), + ), + )); + } + + start_test_app( + app, + apps_state, + BUILTIN_PERSISTENCE_PERSISTENT_ID, + &[("runId", run_id.clone()), ("phase", "read".into())], + ) + .await?; + start_test_app( + app, + apps_state, + BUILTIN_PERSISTENCE_INCOGNITO_ID, + &[("runId", run_id.clone()), ("phase", "read".into())], + ) + .await?; + + let read_results = poll_persistence_read(apps_state, &run_id, 2, 2_000).await?; + stop_test_apps(app, apps_state, &app_ids).await; + + let persistent_read = read_results + .iter() + .find(|r| r.app_id == BUILTIN_PERSISTENCE_PERSISTENT_ID); + let incognito_read = read_results + .iter() + .find(|r| r.app_id == BUILTIN_PERSISTENCE_INCOGNITO_ID); + + let Some(persistent_read) = persistent_read else { + return Ok(( + (false, Some("Missing persistent read result.".into())), + (false, Some("Missing incognito read result.".into())), + )); + }; + let Some(incognito_read) = incognito_read else { + return Ok(( + (false, Some("Missing persistent read result.".into())), + (false, Some("Missing incognito read result.".into())), + )); + }; + + let persistent_ok = persistent_read.data.error.is_none() + && persistent_read.data.local_storage_present + && persistent_read.data.indexed_db_present; + + let incognito_ok = incognito_read.data.error.is_none() + && !incognito_read.data.local_storage_present + && !incognito_read.data.indexed_db_present; + + Ok(( + ( + persistent_ok, + Some(if persistent_ok { + "Persistent mode retained localStorage and IndexedDB across reopen.".into() + } else { + persistent_read + .data + .error + .clone() + .unwrap_or_else(|| "Persistent read probe mismatch.".into()) + }), + ), + ( + incognito_ok, + Some(if incognito_ok { + "Incognito mode did not retain localStorage or IndexedDB across reopen.".into() + } else { + incognito_read + .data + .error + .clone() + .unwrap_or_else(|| "Incognito read probe mismatch.".into()) + }), + ), + )) +} diff --git a/crates/sage-apps/src/sandbox/probes/poll.rs b/crates/sage-apps/src/sandbox/probes/poll.rs new file mode 100644 index 000000000..d67f740db --- /dev/null +++ b/crates/sage-apps/src/sandbox/probes/poll.rs @@ -0,0 +1,121 @@ +use tauri::State; +use tokio::time::{Duration, sleep}; + +use super::super::store::SandboxAppResult; +use super::super::types::{ + SandboxIsolationProbeResult, SandboxNetworkProbeResult, SandboxPersistenceReadProbeResult, + SandboxPersistenceWriteProbeResult, +}; +use crate::{AppsHostState, unix_timestamp_ms}; + +pub async fn poll_isolation( + apps_state: &State<'_, AppsHostState>, + run_id: &str, + expected_count: usize, + timeout_ms: i64, +) -> Result>, String> { + let started = unix_timestamp_ms(); + + loop { + let results = { + let runs = apps_state.sandbox.runs.lock().await; + runs.get(run_id) + .map(|r| r.isolation.clone()) + .unwrap_or_default() + }; + + if results.len() >= expected_count { + return Ok(results); + } + + if unix_timestamp_ms() - started >= timeout_ms { + return Err("Timed out waiting for sandbox isolation results.".into()); + } + + sleep(Duration::from_millis(100)).await; + } +} + +pub async fn poll_persistence_write( + apps_state: &State<'_, AppsHostState>, + run_id: &str, + expected_count: usize, + timeout_ms: i64, +) -> Result>, String> { + let started = unix_timestamp_ms(); + + loop { + let results = { + let runs = apps_state.sandbox.runs.lock().await; + runs.get(run_id) + .map(|r| r.persistence_write.clone()) + .unwrap_or_default() + }; + + if results.len() >= expected_count { + return Ok(results); + } + + if unix_timestamp_ms() - started >= timeout_ms { + return Err("Timed out waiting for sandbox persistence write results.".into()); + } + + sleep(Duration::from_millis(100)).await; + } +} + +pub async fn poll_persistence_read( + apps_state: &State<'_, AppsHostState>, + run_id: &str, + expected_count: usize, + timeout_ms: i64, +) -> Result>, String> { + let started = unix_timestamp_ms(); + + loop { + let results = { + let runs = apps_state.sandbox.runs.lock().await; + runs.get(run_id) + .map(|r| r.persistence_read.clone()) + .unwrap_or_default() + }; + + if results.len() >= expected_count { + return Ok(results); + } + + if unix_timestamp_ms() - started >= timeout_ms { + return Err("Timed out waiting for sandbox persistence read results.".into()); + } + + sleep(Duration::from_millis(100)).await; + } +} + +pub async fn poll_network( + apps_state: &State<'_, AppsHostState>, + run_id: &str, + expected_count: usize, + timeout_ms: i64, +) -> Result>, String> { + let started = unix_timestamp_ms(); + + loop { + let results = { + let runs = apps_state.sandbox.runs.lock().await; + runs.get(run_id) + .map(|r| r.network.clone()) + .unwrap_or_default() + }; + + if results.len() >= expected_count { + return Ok(results); + } + + if unix_timestamp_ms() - started >= timeout_ms { + return Err("Timed out waiting for sandbox network results.".into()); + } + + sleep(Duration::from_millis(100)).await; + } +} diff --git a/crates/sage-apps/src/sandbox/runner.rs b/crates/sage-apps/src/sandbox/runner.rs new file mode 100644 index 000000000..dbdaf28a7 --- /dev/null +++ b/crates/sage-apps/src/sandbox/runner.rs @@ -0,0 +1,235 @@ +use tauri::{AppHandle, Manager, State}; + +use super::probes::{run_isolation_test, run_network_test, run_persistence_test}; +use super::state_view::{build_effective_state, build_state_view}; +use super::types::{ + SandboxCapability, SandboxCapabilityStatus, SandboxRunState, SandboxState, + build_running_sandbox_state, mark_cap, +}; +use crate::{AppsHostState, emit_sandbox_state_changed, unix_timestamp_ms}; + +pub async fn ensure_initial_sandbox_run(app: AppHandle) -> Result<(), String> { + let apps_state = app.state::(); + + let already_running = *apps_state.sandbox.running.lock().await; + if already_running { + return Ok(()); + } + + let baseline = apps_state.sandbox.baseline.lock().await.clone(); + let current_run = apps_state.sandbox.current_run.lock().await.clone(); + + if current_run.is_some() { + return Ok(()); + } + + if !sandbox_state_is_all_pending(&baseline) { + return Ok(()); + } + + begin_sandbox_run(&app, &apps_state).await?; + + let runner_app = app.clone(); + tokio::spawn(async move { + let runner = Box::pin(sandbox_runner(runner_app)); + runner.await; + }); + + Ok(()) +} + +pub(crate) async fn begin_sandbox_run( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, +) -> Result { + { + let mut running = apps_state.sandbox.running.lock().await; + if *running { + return Ok(build_state_view(apps_state).await); + } + *running = true; + } + + { + let mut runs = apps_state.sandbox.runs.lock().await; + runs.clear(); + } + + let run_state = SandboxRunState { + run_id: super::runtime::unique_run_id("sandbox-run"), + state: build_running_sandbox_state(unix_timestamp_ms()), + }; + + *apps_state.sandbox.current_run.lock().await = Some(run_state); + + emit_sandbox_state_changed(app, apps_state).await; + + Ok(build_state_view(apps_state).await) +} + +async fn update_current_run_state( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + state: SandboxState, +) { + if let Some(current_run) = apps_state.sandbox.current_run.lock().await.as_mut() { + current_run.state = state; + } + emit_sandbox_state_changed(app, apps_state).await; +} + +pub async fn sandbox_runner(app: AppHandle) { + let apps_state = app.state::(); + + let mut current_state = { + let current_run = apps_state.sandbox.current_run.lock().await.clone(); + + current_run.map_or_else( + || build_running_sandbox_state(unix_timestamp_ms()), + |r| r.state, + ) + }; + + let isolation_fut = run_isolation_test(&app, &apps_state); + let persistence_fut = run_persistence_test(&app, &apps_state); + let network_fut = run_network_test(&app, &apps_state); + + tokio::pin!(isolation_fut); + tokio::pin!(persistence_fut); + tokio::pin!(network_fut); + + let mut isolation_done = false; + let mut persistence_done = false; + let mut network_done = false; + + while !(isolation_done && persistence_done && network_done) { + tokio::select! { + res = &mut isolation_fut, if !isolation_done => { + isolation_done = true; + + match res { + Ok((passed, details)) => { + mark_cap( + &mut current_state, + SandboxCapability::StorageIsolationFromSage, + if passed { SandboxCapabilityStatus::Passed } else { SandboxCapabilityStatus::Failed }, + details, + unix_timestamp_ms(), + ); + } + Err(err) => { + mark_cap( + &mut current_state, + SandboxCapability::StorageIsolationFromSage, + SandboxCapabilityStatus::Failed, + Some(err), + unix_timestamp_ms(), + ); + } + } + + update_current_run_state(&app, &apps_state, current_state.clone()).await; + } + + res = &mut persistence_fut, if !persistence_done => { + persistence_done = true; + + match res { + Ok((normal, incog)) => { + mark_cap( + &mut current_state, + SandboxCapability::StoragePersistenceNormal, + if normal.0 { SandboxCapabilityStatus::Passed } else { SandboxCapabilityStatus::Failed }, + normal.1, + unix_timestamp_ms(), + ); + + mark_cap( + &mut current_state, + SandboxCapability::StorageNonPersistenceIncognito, + if incog.0 { SandboxCapabilityStatus::Passed } else { SandboxCapabilityStatus::Failed }, + incog.1, + unix_timestamp_ms(), + ); + } + Err(err) => { + mark_cap( + &mut current_state, + SandboxCapability::StoragePersistenceNormal, + SandboxCapabilityStatus::Failed, + Some(err.clone()), + unix_timestamp_ms(), + ); + + mark_cap( + &mut current_state, + SandboxCapability::StorageNonPersistenceIncognito, + SandboxCapabilityStatus::Failed, + Some(err), + unix_timestamp_ms(), + ); + } + } + + update_current_run_state(&app, &apps_state, current_state.clone()).await; + } + + res = &mut network_fut, if !network_done => { + network_done = true; + + match res { + Ok((passed, details)) => { + mark_cap( + &mut current_state, + SandboxCapability::NetworkAllowlistEnforced, + if passed { SandboxCapabilityStatus::Passed } else { SandboxCapabilityStatus::Failed }, + details, + unix_timestamp_ms(), + ); + } + Err(err) => { + mark_cap( + &mut current_state, + SandboxCapability::NetworkAllowlistEnforced, + SandboxCapabilityStatus::Failed, + Some(err), + unix_timestamp_ms(), + ); + } + } + + update_current_run_state(&app, &apps_state, current_state.clone()).await; + } + } + } + + let effective = { + let baseline = apps_state.sandbox.baseline.lock().await.clone(); + let temp_run = SandboxRunState { + run_id: "finalize".into(), + state: current_state.clone(), + }; + build_effective_state(&baseline, Some(&temp_run)) + }; + + current_state.overall_critical_status = + if effective.storage_isolation_from_sage.status == SandboxCapabilityStatus::Failed { + SandboxCapabilityStatus::Failed + } else { + SandboxCapabilityStatus::Passed + }; + current_state.finished_at = Some(unix_timestamp_ms()); + + *apps_state.sandbox.baseline.lock().await = current_state.clone(); + *apps_state.sandbox.current_run.lock().await = None; + *apps_state.sandbox.running.lock().await = false; + + emit_sandbox_state_changed(&app, &apps_state).await; +} + +fn sandbox_state_is_all_pending(state: &SandboxState) -> bool { + state.storage_isolation_from_sage.status == SandboxCapabilityStatus::Pending + && state.storage_persistence_normal.status == SandboxCapabilityStatus::Pending + && state.storage_non_persistence_incognito.status == SandboxCapabilityStatus::Pending + && state.network_allowlist_enforced.status == SandboxCapabilityStatus::Pending +} diff --git a/crates/sage-apps/src/sandbox/runtime.rs b/crates/sage-apps/src/sandbox/runtime.rs new file mode 100644 index 000000000..aff2e5750 --- /dev/null +++ b/crates/sage-apps/src/sandbox/runtime.rs @@ -0,0 +1,47 @@ +use std::collections::{BTreeMap, HashMap}; + +use tauri::{AppHandle, State}; +use uuid::Uuid; + +use crate::{AppsHostState, close_runtime_internal, start_sandbox_test}; + +pub(crate) async fn stop_test_apps( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_ids: &[&str], +) { + for app_id in app_ids { + close_runtime_internal(app, apps_state, app_id).await; + } +} + +pub(crate) async fn start_test_app( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + query: &[(&str, String)], +) -> Result<(), String> { + let mut query_map = HashMap::new(); + + query_map.insert("appId".to_string(), app_id.to_string()); + + for (k, v) in query { + query_map.insert((*k).to_string(), v.clone()); + } + + start_internal_runtime_for_sandbox(app, apps_state, app_id, query_map.into_iter().collect()) + .await +} + +pub(super) fn unique_run_id(prefix: &str) -> String { + format!("{prefix}-{}", Uuid::new_v4()) +} + +async fn start_internal_runtime_for_sandbox( + app: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app_id: &str, + query: BTreeMap, +) -> Result<(), String> { + start_sandbox_test(app, apps_state, app_id, query).await +} diff --git a/crates/sage-apps/src/sandbox/state_view.rs b/crates/sage-apps/src/sandbox/state_view.rs new file mode 100644 index 000000000..b5e7886b3 --- /dev/null +++ b/crates/sage-apps/src/sandbox/state_view.rs @@ -0,0 +1,63 @@ +use tauri::State; + +use super::types::{ + SandboxCapabilityResult, SandboxCapabilityStatus, SandboxRunState, SandboxState, + SandboxStateView, +}; +use crate::AppsHostState; + +fn effective_cap( + baseline: &SandboxCapabilityResult, + current: &SandboxCapabilityResult, +) -> SandboxCapabilityResult { + match current.status { + SandboxCapabilityStatus::Passed | SandboxCapabilityStatus::Failed => current.clone(), + SandboxCapabilityStatus::Pending | SandboxCapabilityStatus::Running => baseline.clone(), + } +} + +pub fn build_effective_state( + baseline: &SandboxState, + current_run: Option<&SandboxRunState>, +) -> SandboxState { + let Some(current_run) = current_run else { + return baseline.clone(); + }; + + let current = ¤t_run.state; + + SandboxState { + overall_critical_status: baseline.overall_critical_status, + storage_isolation_from_sage: effective_cap( + &baseline.storage_isolation_from_sage, + ¤t.storage_isolation_from_sage, + ), + storage_persistence_normal: effective_cap( + &baseline.storage_persistence_normal, + ¤t.storage_persistence_normal, + ), + storage_non_persistence_incognito: effective_cap( + &baseline.storage_non_persistence_incognito, + ¤t.storage_non_persistence_incognito, + ), + network_allowlist_enforced: effective_cap( + &baseline.network_allowlist_enforced, + ¤t.network_allowlist_enforced, + ), + started_at: baseline.started_at, + finished_at: baseline.finished_at, + } +} + +pub async fn build_state_view(apps_state: &State<'_, AppsHostState>) -> SandboxStateView { + let baseline = apps_state.sandbox.baseline.lock().await.clone(); + let current_run = apps_state.sandbox.current_run.lock().await.clone(); + + let effective = build_effective_state(&baseline, current_run.as_ref()); + + SandboxStateView { + baseline, + current_run, + effective, + } +} diff --git a/crates/sage-apps/src/sandbox/store.rs b/crates/sage-apps/src/sandbox/store.rs new file mode 100644 index 000000000..73192d270 --- /dev/null +++ b/crates/sage-apps/src/sandbox/store.rs @@ -0,0 +1,49 @@ +use std::collections::HashMap; + +use tokio::sync::Mutex; + +use super::types::{ + SandboxIsolationProbeResult, SandboxNetworkProbeResult, SandboxPersistenceReadProbeResult, + SandboxPersistenceWriteProbeResult, SandboxRunState, build_initial_sandbox_state, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxAppResult { + pub app_id: String, + pub data: T, +} + +#[derive(Debug, Clone, Default)] +pub struct SandboxRunResults { + pub isolation: Vec>, + pub persistence_write: Vec>, + pub persistence_read: Vec>, + pub network: Vec>, +} + +#[derive(Debug)] +pub struct SandboxStateStore { + pub baseline: Mutex, + pub current_run: Mutex>, + pub runs: Mutex>, + pub running: Mutex, +} + +impl Default for SandboxStateStore { + fn default() -> Self { + Self { + baseline: Mutex::new(build_initial_sandbox_state()), + current_run: Mutex::new(None), + runs: Mutex::new(HashMap::new()), + running: Mutex::new(false), + } + } +} + +pub fn replace_by_app_id(items: &mut Vec>, next: SandboxAppResult) { + if let Some(existing) = items.iter_mut().find(|item| item.app_id == next.app_id) { + *existing = next; + } else { + items.push(next); + } +} diff --git a/crates/sage-apps/src/sandbox/types.rs b/crates/sage-apps/src/sandbox/types.rs new file mode 100644 index 000000000..2222e19d7 --- /dev/null +++ b/crates/sage-apps/src/sandbox/types.rs @@ -0,0 +1,166 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum SandboxCapability { + StorageIsolationFromSage, + StoragePersistenceNormal, + StorageNonPersistenceIncognito, + NetworkAllowlistEnforced, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SandboxCapabilityStatus { + Pending, + Running, + Passed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SandboxCapabilityResult { + pub status: SandboxCapabilityStatus, + pub checked_at: Option, + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SandboxState { + pub overall_critical_status: SandboxCapabilityStatus, + pub storage_isolation_from_sage: SandboxCapabilityResult, + pub storage_persistence_normal: SandboxCapabilityResult, + pub storage_non_persistence_incognito: SandboxCapabilityResult, + pub network_allowlist_enforced: SandboxCapabilityResult, + pub started_at: Option, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SandboxRunState { + pub run_id: String, + pub state: SandboxState, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SandboxStateView { + pub baseline: SandboxState, + pub current_run: Option, + pub effective: SandboxState, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AppLaunchGateResult { + pub allowed: bool, + pub kind: String, + pub capability: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SandboxIsolationProbeResult { + pub run_id: String, + pub local_storage_visible: bool, + pub indexed_db_visible: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SandboxPersistenceWriteProbeResult { + pub run_id: String, + pub local_storage_wrote: bool, + pub indexed_db_wrote: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SandboxPersistenceReadProbeResult { + pub run_id: String, + pub local_storage_present: bool, + pub indexed_db_present: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SandboxNetworkProbeResult { + pub run_id: String, + pub allowed_url: String, + pub blocked_url: String, + pub allowed_ok: bool, + pub blocked_ok: bool, + pub error: Option, +} + +pub fn make_cap( + status: SandboxCapabilityStatus, + details: Option, +) -> SandboxCapabilityResult { + SandboxCapabilityResult { + status, + checked_at: None, + details, + } +} + +pub fn mark_cap( + state: &mut SandboxState, + cap: SandboxCapability, + status: SandboxCapabilityStatus, + details: Option, + checked_at: i64, +) { + let next = SandboxCapabilityResult { + status, + checked_at: Some(checked_at), + details, + }; + + match cap { + SandboxCapability::StorageIsolationFromSage => { + state.storage_isolation_from_sage = next; + } + SandboxCapability::StoragePersistenceNormal => { + state.storage_persistence_normal = next; + } + SandboxCapability::StorageNonPersistenceIncognito => { + state.storage_non_persistence_incognito = next; + } + SandboxCapability::NetworkAllowlistEnforced => { + state.network_allowlist_enforced = next; + } + } +} + +pub fn build_initial_sandbox_state() -> SandboxState { + SandboxState { + overall_critical_status: SandboxCapabilityStatus::Pending, + storage_isolation_from_sage: make_cap(SandboxCapabilityStatus::Pending, None), + storage_persistence_normal: make_cap(SandboxCapabilityStatus::Pending, None), + storage_non_persistence_incognito: make_cap(SandboxCapabilityStatus::Pending, None), + network_allowlist_enforced: make_cap(SandboxCapabilityStatus::Pending, None), + started_at: None, + finished_at: None, + } +} + +pub fn build_running_sandbox_state(started_at: i64) -> SandboxState { + SandboxState { + overall_critical_status: SandboxCapabilityStatus::Running, + storage_isolation_from_sage: make_cap(SandboxCapabilityStatus::Running, None), + storage_persistence_normal: make_cap(SandboxCapabilityStatus::Running, None), + storage_non_persistence_incognito: make_cap(SandboxCapabilityStatus::Running, None), + network_allowlist_enforced: make_cap(SandboxCapabilityStatus::Running, None), + started_at: Some(started_at), + finished_at: None, + } +} diff --git a/crates/sage-apps/src/security.rs b/crates/sage-apps/src/security.rs new file mode 100644 index 000000000..2c820e0f7 --- /dev/null +++ b/crates/sage-apps/src/security.rs @@ -0,0 +1,10 @@ +mod bridge; +mod csp; +mod net; +mod protocol; + +pub use csp::*; +pub use protocol::*; + +pub(crate) use bridge::*; +pub(crate) use net::*; diff --git a/crates/sage-apps/src/security/bridge.rs b/crates/sage-apps/src/security/bridge.rs new file mode 100644 index 000000000..9162ae19f --- /dev/null +++ b/crates/sage-apps/src/security/bridge.rs @@ -0,0 +1,43 @@ +use tauri::{AppHandle, Manager}; + +use crate::{ + BridgeOrigin, app_id_from_webview_label, get_webview_in_sage_window, is_allowed_app_url, + protocol_scheme_for_app, resolve_running_app, +}; + +pub(crate) async fn assert_bridge_origin( + app_handle: &AppHandle, + webview_label: &String, +) -> Result { + let app_id = app_id_from_webview_label(webview_label) + .ok_or_else(|| format!("invalid app runtime label: {webview_label}"))?; + + let runtime = resolve_running_app(&app_handle.state(), app_id) + .await + .map_err(|_| format!("failed to find runtime for app {app_id}"))?; + + let app = runtime.into_app(); + + if !app.webview_label_matches(webview_label) { + return Err(format!( + "bridge denied for {webview_label}: webview label mismatch" + )); + } + + let app_webview = get_webview_in_sage_window(app_handle, webview_label)?; + + let current_url = app_webview + .url() + .map_err(|e| format!("failed to read current webview url: {e}"))?; + + if !is_allowed_app_url(¤t_url, &app) { + return Err(format!( + "bridge denied for {webview_label}: current url {} is outside {}://{}/...", + current_url, + protocol_scheme_for_app(&app), + app.origin_id() + )); + } + + Ok(BridgeOrigin { app }) +} diff --git a/crates/sage-apps/src/security/csp.rs b/crates/sage-apps/src/security/csp.rs new file mode 100644 index 000000000..ae95d6de8 --- /dev/null +++ b/crates/sage-apps/src/security/csp.rs @@ -0,0 +1,179 @@ +use std::collections::BTreeSet; + +use crate::SharedSageApp; + +fn csp_source_list(items: &[String]) -> String { + items.join(" ") +} + +pub fn build_app_csp(app: &SharedSageApp, network_id: &str) -> String { + let mut connect_sources = BTreeSet::from(["'self'".to_string()]); + + app.with(|app| { + for entry in app + .granted_permissions() + .network() + .effective_whitelist_for_network(network_id) + { + connect_sources.insert(entry.as_permission_string()); + } + }); + + let child_src = csp_source_list(&["'none'".to_string()]); + let connect_src = csp_source_list(&connect_sources.into_iter().collect::>()); + let default_src = csp_source_list(&["'self'".to_string()]); + let font_src = csp_source_list(&["'self'".to_string(), "data:".to_string()]); + let frame_src = csp_source_list(&["'none'".to_string()]); + let img_src = csp_source_list(&[ + "'self'".to_string(), + "data:".to_string(), + "blob:".to_string(), + ]); + let manifest_src = csp_source_list(&["'none'".to_string()]); + let media_src = csp_source_list(&[ + "'self'".to_string(), + "data:".to_string(), + "blob:".to_string(), + ]); + let object_src = csp_source_list(&["'none'".to_string()]); + let prefetch_src = csp_source_list(&["'none'".to_string()]); + let script_src = csp_source_list(&["'self'".to_string(), "'wasm-unsafe-eval'".to_string()]); + let style_src = csp_source_list(&["'self'".to_string(), "'unsafe-inline'".to_string()]); + let worker_src = csp_source_list(&["'self'".to_string()]); + let frame_ancestors = csp_source_list(&["'self'".to_string()]); + let base_uri = csp_source_list(&["'none'".to_string()]); + let form_action = csp_source_list(&["'none'".to_string()]); + + format!( + "child-src {child_src}; \ + connect-src {connect_src}; \ + default-src {default_src}; \ + font-src {font_src}; \ + frame-src {frame_src}; \ + img-src {img_src}; \ + manifest-src {manifest_src}; \ + media-src {media_src}; \ + object-src {object_src}; \ + prefetch-src {prefetch_src}; \ + script-src {script_src}; \ + style-src {style_src}; \ + worker-src {worker_src}; \ + base-uri {base_uri}; \ + form-action {form_action}; \ + frame-ancestors {frame_ancestors};" + ) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + use std::fs; + + use tempfile::{TempDir, tempdir}; + + use super::*; + use crate::{ + SageAppCommon, SageAppIdentity, SageAppManifestFile, SageAppManifestSageVersion, + SageAppManifestVersion, SageAppPackageManifest, SageAppPackageManifestParts, + SageAppSnapshot, SageAppStorage, SageAppUrl, SageAppWalletScope, SageGrantedPermissions, + SageNetworkWhitelistEntry, SageRequestedCapabilities, SageRequestedNetworkPermissions, + SageRequestedNetworkWhitelist, SageRequestedPermissions, UserSageApp, UserSageAppSource, + }; + + fn entry(scheme: &str, host: &str) -> SageNetworkWhitelistEntry { + SageNetworkWhitelistEntry::new(scheme, host).unwrap() + } + + fn manifest(permissions: SageRequestedPermissions) -> SageAppPackageManifest { + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: SageAppManifestVersion(0), + name: "test app".to_string(), + icon: None, + sage_version: SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + version: "1.0.0".to_string(), + permissions, + files: vec![SageAppManifestFile::new("index.html", "a".repeat(64), 1).unwrap()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + fn app_with_network_grants() -> (SharedSageApp, TempDir) { + let shared = entry("https", "shared.example.com"); + let mainnet = entry("https", "mainnet.example.com"); + let testnet = entry("https", "testnet.example.com"); + + let requested = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [], + [shared.clone()], + [ + ( + "mainnet".to_string(), + SageRequestedNetworkWhitelist::new([], [mainnet.clone()]), + ), + ( + "testnet11".to_string(), + SageRequestedNetworkWhitelist::new([], [testnet.clone()]), + ), + ], + ) + .unwrap(), + SageRequestedCapabilities::empty(), + ) + .unwrap(); + + let granted = SageGrantedPermissions::new( + &requested, + [], + [shared], + BTreeMap::from([ + ("mainnet".to_string(), BTreeSet::from([mainnet])), + ("testnet11".to_string(), BTreeSet::from([testnet])), + ]), + ) + .unwrap(); + + let dir = tempdir().unwrap(); + fs::write(dir.path().join("index.html"), "x").unwrap(); + let snapshot = + SageAppSnapshot::new("hash", dir.path().to_string_lossy(), manifest(requested)) + .unwrap(); + let common = SageAppCommon::new( + SageAppIdentity::new("app-id", "origin-id", dir.path().to_string_lossy()).unwrap(), + granted, + SageAppStorage::Unmanaged, + snapshot, + SageAppWalletScope::AllWallets, + ) + .unwrap(); + let app = UserSageApp::new_installed( + common, + UserSageAppSource::Url { + app_url: SageAppUrl::parse("https://example.com/app/").unwrap(), + }, + ); + + (SharedSageApp::new(app.into_sage_app()), dir) + } + + #[test] + fn csp_connect_src_scopes_network_specific_whitelist_to_active_network() { + let (app, _dir) = app_with_network_grants(); + + let mainnet_csp = build_app_csp(&app, "mainnet"); + assert!(mainnet_csp.contains("https://shared.example.com")); + assert!(mainnet_csp.contains("https://mainnet.example.com")); + assert!(!mainnet_csp.contains("https://testnet.example.com")); + + let testnet_csp = build_app_csp(&app, "testnet11"); + assert!(testnet_csp.contains("https://shared.example.com")); + assert!(testnet_csp.contains("https://testnet.example.com")); + assert!(!testnet_csp.contains("https://mainnet.example.com")); + } +} diff --git a/crates/sage-apps/src/security/net.rs b/crates/sage-apps/src/security/net.rs new file mode 100644 index 000000000..10e1ab64a --- /dev/null +++ b/crates/sage-apps/src/security/net.rs @@ -0,0 +1,299 @@ +//! SSRF-guarded HTTP fetching for app installs and updates. +//! +//! App URLs are user-supplied, so downloads must not reach local or otherwise +//! non-public infrastructure. Each request resolves and vets its destination, +//! pins the connection to the vetted addresses, and validates redirect hops. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use anyhow::{Context, Result as AnyResult}; +use url::{Host, Url}; + +const MAX_DOWNLOAD_REDIRECTS: usize = 5; + +/// Fetches an app-related URL while validating every destination and redirect. +/// Status handling is deliberately left to the caller. +pub(crate) async fn get_with_ssrf_guard(url: &str) -> AnyResult { + let mut current = Url::parse(url).with_context(|| format!("invalid download URL {url}"))?; + let initial_is_loopback = is_explicit_loopback_host(¤t); + + for redirects in 0..=MAX_DOWNLOAD_REDIRECTS { + // A public app must never gain permission to target loopback merely by + // redirecting there or embedding a loopback asset URL. Local development + // apps retain that permission for explicit loopback destinations. + let allow_loopback = allows_loopback_target(initial_is_loopback, ¤t); + let response = send_validated_get(¤t, allow_loopback).await?; + if !response.status().is_redirection() { + return Ok(response); + } + + if redirects == MAX_DOWNLOAD_REDIRECTS { + anyhow::bail!("too many redirects while downloading {url}"); + } + + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .with_context(|| format!("redirect from {current} is missing a location header"))?; + + current = current + .join(location) + .with_context(|| format!("invalid redirect location from {current}"))?; + } + + unreachable!("redirect loop is bounded above") +} + +async fn send_validated_get(url: &Url, allow_loopback: bool) -> AnyResult { + let pinned_addrs = validate_download_target(url, allow_loopback).await?; + let mut builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + // A proxy receives the hostname rather than the vetted socket address, + // so it would defeat DNS pinning and could reintroduce SSRF. + .no_proxy(); + + if let (Some(domain), Some(addrs)) = (url.domain(), pinned_addrs.as_deref()) { + builder = builder.resolve_to_addrs(domain, addrs); + } + + let client = builder.build().context("failed to build download client")?; + client + .get(url.clone()) + .send() + .await + .with_context(|| format!("failed to GET {url}")) +} + +/// Validates a URL and, for domains, returns the resolved addresses to pin. +async fn validate_download_target( + url: &Url, + allow_loopback: bool, +) -> AnyResult>> { + match url.scheme() { + "https" => {} + "http" if allow_loopback => {} + scheme => anyhow::bail!("refusing to download {url}: unsupported scheme '{scheme}'"), + } + + match url + .host() + .with_context(|| format!("download URL {url} must include a host"))? + { + Host::Ipv4(ip) => { + ensure_ip_allowed(IpAddr::V4(ip), allow_loopback, url)?; + Ok(None) + } + Host::Ipv6(ip) => { + ensure_ip_allowed(IpAddr::V6(ip), allow_loopback, url)?; + Ok(None) + } + Host::Domain(domain) => { + let port = url.port_or_known_default().unwrap_or(443); + let addrs: Vec = tokio::net::lookup_host((domain, port)) + .await + .with_context(|| format!("failed to resolve download host {domain}"))? + .collect(); + + if addrs.is_empty() { + anyhow::bail!("download host {domain} did not resolve to any address"); + } + + for addr in &addrs { + ensure_ip_allowed(addr.ip(), allow_loopback, url)?; + } + + Ok(Some(addrs)) + } + } +} + +fn is_explicit_loopback_host(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + +fn allows_loopback_target(allow_loopback_source: bool, url: &Url) -> bool { + allow_loopback_source && is_explicit_loopback_host(url) +} + +fn ensure_ip_allowed(ip: IpAddr, allow_loopback: bool, url: &Url) -> AnyResult<()> { + if is_forbidden_ip(ip, allow_loopback) { + anyhow::bail!("refusing to download {url}: host resolves to disallowed address {ip}"); + } + + Ok(()) +} + +fn is_forbidden_ip(ip: IpAddr, allow_loopback: bool) -> bool { + match ip { + IpAddr::V4(ip) => is_forbidden_ipv4(ip, allow_loopback), + IpAddr::V6(ip) => { + if let Some(mapped) = ip.to_ipv4_mapped() { + return is_forbidden_ipv4(mapped, allow_loopback); + } + + is_forbidden_ipv6(ip, allow_loopback) + } + } +} + +fn is_forbidden_ipv4(ip: Ipv4Addr, allow_loopback: bool) -> bool { + if ip.is_loopback() { + return !allow_loopback; + } + + let [first, second, third, _] = ip.octets(); + ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_multicast() + // "This network" (0.0.0.0/8), CGNAT (100.64.0.0/10), IETF protocol + // assignments (192.0.0.0/24), deprecated 6to4 relay (192.88.99.0/24), + // benchmark testing (198.18.0.0/15), and reserved space (240.0.0.0/4). + || first == 0 + || (first == 100 && (second & 0xc0) == 64) + || (first == 192 && second == 0 && third == 0) + || (first == 192 && second == 88 && third == 99) + || (first == 198 && matches!(second, 18 | 19)) + || first >= 240 +} + +fn is_forbidden_ipv6(ip: Ipv6Addr, allow_loopback: bool) -> bool { + if ip.is_loopback() { + return !allow_loopback; + } + + let segments = ip.segments(); + let first = segments[0]; + + ip.is_unspecified() + || ip.is_multicast() + // IPv4-compatible addresses (::/96) are not public IPv6 destinations. + || segments[..6].iter().all(|segment| *segment == 0) + // Unique-local (fc00::/7), link-local (fe80::/10), and deprecated + // site-local (fec0::/10) addresses. + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || (first & 0xffc0) == 0xfec0 + // Discard-only (100::/64), Teredo (2001::/32), ORCHIDv2 + // (2001:20::/28), documentation (2001:db8::/32), and 6to4 (2002::/16). + || (first == 0x0100 + && segments[1] == 0 + && segments[2] == 0 + && segments[3] == 0) + || (first == 0x2001 && segments[1] == 0) + || (first == 0x2001 && (segments[1] & 0xfff0) == 0x0020) + || (first == 0x2001 && segments[1] == 0x0db8) + || first == 0x2002 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn forbidden(host: &str) -> bool { + is_forbidden_ip(host.parse().unwrap(), false) + } + + #[test] + fn rejects_non_public_addresses() { + for host in [ + "0.1.2.3", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "192.0.0.1", + "192.88.99.1", + "192.168.1.1", + "198.18.0.1", + "203.0.113.1", + "240.0.0.1", + "::", + "::1", + "::10.0.0.1", + "::ffff:10.0.0.1", + "fc00::1", + "fe80::1", + "fec0::1", + "100::1", + "2001::1", + "2001:20::1", + "2001:db8::1", + "2002:c0a8:1::", + ] { + assert!(forbidden(host), "expected {host} to be rejected"); + } + } + + #[test] + fn accepts_public_addresses() { + for host in ["1.1.1.1", "8.8.8.8", "104.16.0.1", "2606:4700::1111"] { + assert!(!forbidden(host), "expected {host} to be accepted"); + } + } + + #[test] + fn loopback_requires_an_explicit_loopback_url() { + assert!(is_forbidden_ip("127.0.0.1".parse().unwrap(), false)); + assert!(!is_forbidden_ip("127.0.0.1".parse().unwrap(), true)); + assert!(is_forbidden_ip("::1".parse().unwrap(), false)); + assert!(!is_forbidden_ip("::1".parse().unwrap(), true)); + + assert!(is_forbidden_ip("10.0.0.1".parse().unwrap(), true)); + assert!(is_forbidden_ip("169.254.169.254".parse().unwrap(), true)); + } + + #[test] + fn identifies_explicit_loopback_hosts() { + for url in [ + "http://localhost:4173/app/", + "http://127.0.0.1:4173/app/", + "http://[::1]:4173/app/", + ] { + assert!(is_explicit_loopback_host(&Url::parse(url).unwrap())); + } + + for url in ["https://example.com/app/", "https://10.0.0.1/app/"] { + assert!(!is_explicit_loopback_host(&Url::parse(url).unwrap())); + } + } + + #[tokio::test] + async fn rejects_private_ip_literal_downloads() { + let url = Url::parse("https://169.254.169.254/latest/meta-data/").unwrap(); + let err = validate_download_target(&url, false).await.unwrap_err(); + assert!(err.to_string().contains("disallowed address")); + } + + #[tokio::test] + async fn only_an_explicit_local_install_can_fetch_loopback() { + let local_install = Url::parse("http://127.0.0.1:4173/app/").unwrap(); + assert!(allows_loopback_target( + is_explicit_loopback_host(&local_install), + &local_install + )); + validate_download_target(&local_install, true) + .await + .unwrap(); + + let public_install = Url::parse("https://example.com/app/").unwrap(); + let redirect_target = Url::parse("https://127.0.0.1:4173/app/").unwrap(); + assert!(!allows_loopback_target( + is_explicit_loopback_host(&public_install), + &redirect_target + )); + let err = validate_download_target(&redirect_target, false) + .await + .unwrap_err(); + assert!(err.to_string().contains("disallowed address")); + } +} diff --git a/crates/sage-apps/src/security/protocol.rs b/crates/sage-apps/src/security/protocol.rs new file mode 100644 index 000000000..f33c56e44 --- /dev/null +++ b/crates/sage-apps/src/security/protocol.rs @@ -0,0 +1,273 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{Result as AnyResult, anyhow}; +use tauri::http::{Request, Response, StatusCode}; +use tauri::{AppHandle, Manager}; + +use crate::{ + AppState, ResolvedRunningApp, SharedSageApp, app_id_from_webview_label, build_app_csp, + resolve_running_app, +}; + +pub async fn handle_user_app_protocol_request( + app_handle: AppHandle, + webview_label: String, + request: Request>, +) -> Response> { + let result = async { + let runtime = get_protocol_request_runtime(&app_handle, &webview_label).await?; + + let app = runtime.into_app(); + if !app.is_user_app() { + anyhow::bail!("not a user runtime"); + } + + let is_sandbox_test = app.with(|app| app.common().is_sandbox_test()); + + match handle_app_protocol_request(&app_handle, &app, &request).await { + Ok(response) => Ok(response), + Err(err) if is_sandbox_test => Ok(protocol_error_response("sage-app", &err)), + Err(_) => Ok(not_found_response()), + } + } + .await; + + result.unwrap_or_else(|_| not_found_response()) +} + +pub async fn handle_system_app_protocol_request( + app_handle: AppHandle, + webview_label: String, + request: Request>, +) -> Response> { + let result = async { + let runtime = get_protocol_request_runtime(&app_handle, &webview_label).await?; + let app = runtime.into_app(); + if !app.is_system_app() { + anyhow::bail!("not a system runtime"); + } + + handle_app_protocol_request(&app_handle, &app, &request) + .await + .map_err(|err| anyhow!("sage-system-app error: {err}")) + } + .await; + + result.unwrap_or_else(|err| protocol_error_response("sage-system-app", &err)) +} + +async fn get_protocol_request_runtime( + app_handle: &AppHandle, + webview_label: &str, +) -> AnyResult { + let app_id = + app_id_from_webview_label(webview_label).ok_or_else(|| anyhow!("invalid webview label"))?; + + resolve_running_app(&app_handle.state(), app_id) + .await + .map_err(|_| anyhow!("failed to find runtime for app {app_id}")) +} + +async fn handle_app_protocol_request( + app_handle: &AppHandle, + app: &SharedSageApp, + request: &Request>, +) -> AnyResult>> { + let file_path = protocol_file_path_for_request(app, request)?; + + let mime = mime_guess::from_path(&file_path) + .first_or_octet_stream() + .essence_str() + .to_string(); + + let network_id = active_network_id(app_handle).await?; + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime) + .header("Cache-Control", "no-store") + .header("Content-Security-Policy", build_app_csp(app, &network_id)) + .header("X-Content-Type-Options", "nosniff") + .body(fs::read(&file_path)?) + .map_err(|err| anyhow!("failed to build app protocol response: {err}")) +} + +fn protocol_file_path_for_request( + app: &SharedSageApp, + request: &Request>, +) -> AnyResult { + if request.uri().host() != Some(&app.origin_id()) { + anyhow::bail!("host mismatch"); + } + + if request.headers().contains_key("Service-Worker") { + anyhow::bail!("Service worker forbidden"); + } + + let request_path = request.uri().path(); + + app.with(|app| app.active_snapshot().resolve_file_path(request_path)) +} + +fn not_found_response() -> Response> { + Response::builder() + .status(StatusCode::NOT_FOUND) + .header("Content-Type", "text/plain; charset=utf-8") + .body("Not found".to_string().into_bytes()) + .expect("failed to build error response") +} + +fn protocol_error_response(prefix: &str, err: &anyhow::Error) -> Response> { + tracing::error!(prefix, error = %err, "protocol request failed"); + + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("Content-Type", "text/plain; charset=utf-8") + .body(format!("{prefix} error: {err}").into_bytes()) + .expect("failed to build protocol error response") +} + +async fn active_network_id(app_handle: &AppHandle) -> AnyResult { + use std::time::Duration; + + use tokio::time::timeout; + + const TIMEOUT: Duration = Duration::from_millis(500); + + let state = app_handle.state::(); + + let sage = timeout(TIMEOUT, state.lock()) + .await + .map_err(|_| anyhow!("active network id unavailable because Sage state is locked"))?; + + Ok(sage.network_id()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use tempfile::{TempDir, tempdir}; + + use super::*; + use crate::{ + SageAppCommon, SageAppIdentity, SageAppManifestFile, SageAppManifestSageVersion, + SageAppManifestVersion, SageAppPackageManifest, SageAppPackageManifestParts, + SageAppSnapshot, SageAppStorage, SageAppUrl, SageAppWalletScope, SageGrantedPermissions, + SageRequestedPermissions, UserSageApp, UserSageAppSource, + }; + + fn manifest_file(path: &str) -> SageAppManifestFile { + SageAppManifestFile::new(path, "a".repeat(64), 1).unwrap() + } + + fn manifest() -> SageAppPackageManifest { + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: SageAppManifestVersion(0), + name: "test app".to_string(), + icon: None, + sage_version: SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![manifest_file("index.html"), manifest_file("nested/app.js")], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + fn app() -> (SharedSageApp, TempDir) { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("index.html"), "x").unwrap(); + fs::create_dir_all(dir.path().join("nested")).unwrap(); + fs::write(dir.path().join("nested/app.js"), "x").unwrap(); + + let manifest = manifest(); + let granted = + SageGrantedPermissions::new(manifest.permissions(), [], [], BTreeMap::default()) + .unwrap(); + let snapshot = + SageAppSnapshot::new("hash", dir.path().to_string_lossy(), manifest).unwrap(); + let common = SageAppCommon::new( + SageAppIdentity::new("app-id", "origin-id", dir.path().to_string_lossy()).unwrap(), + granted, + SageAppStorage::Unmanaged, + snapshot, + SageAppWalletScope::AllWallets, + ) + .unwrap(); + let app = UserSageApp::new_installed( + common, + UserSageAppSource::Url { + app_url: SageAppUrl::parse("https://example.com/app/").unwrap(), + }, + ); + + (SharedSageApp::new(app.into_sage_app()), dir) + } + + fn request(uri: &str) -> Request> { + Request::builder().uri(uri).body(Vec::new()).unwrap() + } + + #[test] + fn protocol_file_path_accepts_matching_host_and_snapshot_path() { + let (app, _dir) = app(); + let request = request("sage-app://origin-id/nested/app.js"); + let expected = app.with(|app| app.active_snapshot().file_path("nested/app.js")); + + assert_eq!( + protocol_file_path_for_request(&app, &request).unwrap(), + expected.canonicalize().unwrap() + ); + } + + #[test] + fn protocol_file_path_rejects_host_mismatch() { + let (app, _dir) = app(); + let request = request("sage-app://other-origin/index.html"); + + let err = protocol_file_path_for_request(&app, &request).unwrap_err(); + + assert!( + err.to_string().contains("host mismatch"), + "unexpected error: {err}" + ); + } + + #[test] + fn protocol_file_path_rejects_service_worker_requests() { + let (app, _dir) = app(); + let request = Request::builder() + .uri("sage-app://origin-id/index.html") + .header("Service-Worker", "script") + .body(Vec::new()) + .unwrap(); + + let err = protocol_file_path_for_request(&app, &request).unwrap_err(); + + assert!( + err.to_string().contains("Service worker forbidden"), + "unexpected error: {err}" + ); + } + + #[test] + fn protocol_file_path_rejects_traversal_paths() { + let (app, _dir) = app(); + + for uri in [ + "sage-app://origin-id/../secret.txt", + "sage-app://origin-id/nested/../index.html", + ] { + assert!( + protocol_file_path_for_request(&app, &request(uri)).is_err(), + "expected {uri} to be rejected" + ); + } + } +} diff --git a/crates/sage-apps/src/settings.rs b/crates/sage-apps/src/settings.rs new file mode 100644 index 000000000..1ff5dfac9 --- /dev/null +++ b/crates/sage-apps/src/settings.rs @@ -0,0 +1,18 @@ +use tauri::{State, command}; + +use crate::{AppsHostState, Result}; + +#[command] +#[specta::specta] +pub async fn apps_get_auto_update_enabled(apps_state: State<'_, AppsHostState>) -> Result { + Ok(apps_state.db.get_auto_update_enabled().await?) +} + +#[command] +#[specta::specta] +pub async fn apps_set_auto_update_enabled( + apps_state: State<'_, AppsHostState>, + enabled: bool, +) -> Result { + Ok(apps_state.db.set_auto_update_enabled(enabled).await?) +} diff --git a/crates/sage-apps/src/storage.rs b/crates/sage-apps/src/storage.rs new file mode 100644 index 000000000..ead4216f5 --- /dev/null +++ b/crates/sage-apps/src/storage.rs @@ -0,0 +1,24 @@ +#[cfg(target_os = "windows")] +use std::path::PathBuf; + +#[cfg(target_os = "windows")] +pub fn data_directory_for(directory_name: &str) -> PathBuf { + PathBuf::from("profiles").join(directory_name) +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub fn parse_data_store_id(identifier_hex: &str) -> Result<[u8; 16], String> { + let bytes = hex::decode(identifier_hex) + .map_err(|err| format!("invalid data store identifier hex: {err}"))?; + + if bytes.len() != 16 { + return Err(format!( + "invalid data store identifier length {}, expected 16 bytes", + bytes.len() + )); + } + + let mut out = [0_u8; 16]; + out.copy_from_slice(&bytes); + Ok(out) +} diff --git a/crates/sage-apps/src/system_apps.rs b/crates/sage-apps/src/system_apps.rs new file mode 100644 index 000000000..fb67ad33c --- /dev/null +++ b/crates/sage-apps/src/system_apps.rs @@ -0,0 +1,295 @@ +use std::collections::BTreeMap; +use std::fmt::Display; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::Result as AnyResult; +use serde::Serialize; +use specta::Type; + +use crate::{ + SageApp, SageAppCommon, SageAppIdentity, SageAppPackageManifest, SageAppSnapshot, + SageAppStorage, SageAppWalletScope, SageGrantedPermissions, SageGrantedSystemPermissions, + SystemBridgeCapability, SystemSageApp, UserBridgeCapability, builtin_apps_root, + get_user_capability_definition, +}; + +pub const SYSTEM_APP_TASK_MANAGER_ID: &str = "task-manager"; +pub const SYSTEM_APP_APP_UPDATE_ID: &str = "app-update"; +pub const SYSTEM_APP_APP_INSTALL_ID: &str = "app-install"; +pub const SYSTEM_APP_BRIDGE_APPROVAL_ID: &str = "bridge-approval"; +pub const SYSTEM_APP_DONATION_ID: &str = "donation"; +pub const SYSTEM_APP_SANDBOX_TESTS_ID: &str = "sandbox-tests"; + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinSystemAppSpec { + pub app_id: &'static str, + pub dir_name: &'static str, + pub usage: SystemAppUsage, + pub system_capabilities: &'static [SystemBridgeCapability], + pub user_grantable_capabilities: &'static [UserBridgeCapability], +} +#[derive(Debug, Clone, Copy, Serialize, Type, PartialEq, Eq)] +pub enum SystemAppUsage { + Standalone, + Contextual, +} + +const BUILTIN_SYSTEM_APPS: &[BuiltinSystemAppSpec] = &[ + BuiltinSystemAppSpec { + app_id: SYSTEM_APP_TASK_MANAGER_ID, + dir_name: "task-manager", + usage: SystemAppUsage::Standalone, + system_capabilities: &[ + SystemBridgeCapability::RuntimeManagerListRuntimes, + SystemBridgeCapability::RuntimeManagerFocusTaskbarRuntime, + SystemBridgeCapability::RuntimeManagerHideRuntime, + SystemBridgeCapability::RuntimeManagerKillRuntime, + SystemBridgeCapability::RuntimeManagerListenRuntimesChanged, + ], + user_grantable_capabilities: &[], + }, + BuiltinSystemAppSpec { + app_id: SYSTEM_APP_APP_UPDATE_ID, + dir_name: "app-update", + usage: SystemAppUsage::Contextual, + system_capabilities: &[ + SystemBridgeCapability::CapabilityDefinitionsRead, + SystemBridgeCapability::AppPermissionsRead, + SystemBridgeCapability::AppPermissionsApply, + SystemBridgeCapability::AppUpdateRead, + SystemBridgeCapability::AppUpdateApply, + SystemBridgeCapability::WalletListWallets, + SystemBridgeCapability::RuntimeManagerCloseSelf, + ], + user_grantable_capabilities: &[], + }, + BuiltinSystemAppSpec { + app_id: SYSTEM_APP_APP_INSTALL_ID, + dir_name: "app-install", + usage: SystemAppUsage::Contextual, + system_capabilities: &[ + SystemBridgeCapability::CapabilityDefinitionsRead, + SystemBridgeCapability::AppInstallPreview, + SystemBridgeCapability::AppInstallApply, + SystemBridgeCapability::FileSystemSelectFile, + SystemBridgeCapability::WalletListWallets, + SystemBridgeCapability::RuntimeManagerCloseSelf, + ], + user_grantable_capabilities: &[], + }, + BuiltinSystemAppSpec { + app_id: SYSTEM_APP_BRIDGE_APPROVAL_ID, + dir_name: "bridge-approval", + usage: SystemAppUsage::Contextual, + system_capabilities: &[ + SystemBridgeCapability::BridgeApprovalList, + SystemBridgeCapability::BridgeApprovalResolve, + SystemBridgeCapability::BridgeApprovalListenApprovalsChanged, + SystemBridgeCapability::RuntimeManagerGetActiveTaskbarRuntime, + SystemBridgeCapability::RuntimeManagerHideSelf, + SystemBridgeCapability::RuntimeManagerCloseSelf, + ], + user_grantable_capabilities: &[], + }, + BuiltinSystemAppSpec { + app_id: SYSTEM_APP_DONATION_ID, + dir_name: "donation", + usage: SystemAppUsage::Contextual, + system_capabilities: &[ + SystemBridgeCapability::DonationGetDetails, + SystemBridgeCapability::RuntimeManagerCloseSelf, + ], + user_grantable_capabilities: &[UserBridgeCapability::WalletSendXchAutoSubmit], + }, + BuiltinSystemAppSpec { + app_id: SYSTEM_APP_SANDBOX_TESTS_ID, + dir_name: "sandbox-tests", + usage: SystemAppUsage::Contextual, + system_capabilities: &[ + SystemBridgeCapability::SandboxGetState, + SystemBridgeCapability::SandboxRerunTests, + SystemBridgeCapability::SandboxListenStateChanged, + SystemBridgeCapability::RuntimeManagerCloseSelf, + ], + user_grantable_capabilities: &[], + }, +]; + +#[derive(Debug, Clone)] +pub enum AppBuildError { + AppDirMissing, + ManifestFailure(String), + InternalError, + EntryFileNotFound, +} + +impl Display for AppBuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppBuildError::AppDirMissing => write!(f, "app directory is missing"), + AppBuildError::ManifestFailure(err) => write!(f, "manifest failure: {err}"), + AppBuildError::EntryFileNotFound => write!(f, "entry file not found"), + AppBuildError::InternalError => write!(f, "internal app build error"), + } + } +} + +#[derive(Debug, Copy, Clone)] +pub enum ReadBuiltinManifestError { + NotFound, + ParseFailed, +} + +pub fn builtin_system_app_spec(app_id: &str) -> Option<&'static BuiltinSystemAppSpec> { + BUILTIN_SYSTEM_APPS + .iter() + .find(|spec| spec.app_id == app_id) +} + +pub fn builtin_system_apps_root() -> PathBuf { + builtin_apps_root().join("system") +} + +fn read_builtin_manifest( + app_dir: &Path, +) -> Result { + let manifest_path = app_dir.join("sage-manifest.json"); + + let manifest_text = + fs::read_to_string(&manifest_path).map_err(|_| ReadBuiltinManifestError::NotFound)?; + + serde_json::from_str(&manifest_text).map_err(|_| ReadBuiltinManifestError::ParseFailed) +} + +pub fn build_builtin_system_app(app_id: &str) -> Result, AppBuildError> { + let Some(spec) = builtin_system_app_spec(app_id) else { + return Ok(None); + }; + + let app_dir = builtin_system_apps_root().join(spec.dir_name); + + if !app_dir.is_dir() { + tracing::error!( + "[build_builtin_system_app] missing app_dir for {app_id}: {}", + app_dir.display() + ); + return Err(AppBuildError::AppDirMissing); + } + + let manifest = match read_builtin_manifest(&app_dir) { + Ok(m) => m, + Err(err) => { + tracing::error!( + "[build_builtin_system_app] manifest failure for {app_id} at {}: {err:?}", + app_dir.display() + ); + return Err(AppBuildError::ManifestFailure(format!("{err:?}"))); + } + }; + + let requested_capabilities = manifest + .permissions() + .capabilities() + .required() + .chain(manifest.permissions().capabilities().optional()) + .copied() + .filter(|capability| { + get_user_capability_definition(*capability) + .flags() + .user_grantable() + }); + + let granted_permissions = match SageGrantedPermissions::new_with_extra_granted_capabilities( + manifest.permissions(), + requested_capabilities, + spec.user_grantable_capabilities.iter().copied(), + manifest + .permissions() + .network() + .whitelist() + .required() + .cloned(), + BTreeMap::new(), + ) { + Ok(p) => p, + Err(err) => { + tracing::error!( + "[build_builtin_system_app] granted_permissions failure for {app_id}: {err:?}\nmanifest: {manifest:?}" + ); + return Err(AppBuildError::InternalError); + } + }; + + let snapshot = match SageAppSnapshot::new_builtin_system( + spec.app_id, + app_dir.to_string_lossy().to_string(), + manifest, + ) { + Ok(s) => s, + Err(err) => { + tracing::error!("[build_builtin_system_app] snapshot failure for {app_id}: {err:?}"); + return Err(AppBuildError::InternalError); + } + }; + + let identity = match SageAppIdentity::new( + spec.app_id, + spec.app_id, + app_dir.to_string_lossy().to_string(), + ) { + Ok(i) => i, + Err(err) => { + tracing::error!("[build_builtin_system_app] identity failure for {app_id}: {err:?}"); + return Err(AppBuildError::InternalError); + } + }; + + let common = match SageAppCommon::new( + identity, + granted_permissions, + SageAppStorage::Unmanaged, + snapshot, + SageAppWalletScope::AllWallets, + ) { + Ok(c) => c, + Err(err) => { + tracing::error!("[build_builtin_system_app] common failure for {app_id}: {err:?}"); + return Err(AppBuildError::InternalError); + } + }; + + let app = SystemSageApp::new( + common, + spec.usage, + SageGrantedSystemPermissions::new(spec.system_capabilities.iter().copied()), + ); + + Ok(Some(SageApp::System(app))) +} + +pub fn list_builtin_system_apps() -> AnyResult> { + let system_apps_root = builtin_system_apps_root(); + + if !system_apps_root.is_dir() { + tracing::warn!( + "[list_builtin_system_apps] builtin system apps root is missing: {}", + system_apps_root.display() + ); + return Ok(Vec::new()); + } + + let mut out = Vec::new(); + + for spec in BUILTIN_SYSTEM_APPS { + if let Some(app) = build_builtin_system_app(spec.app_id) + .map_err(|err| anyhow::anyhow!(format!("{err}")))? + { + out.push(app); + } + } + + Ok(out) +} diff --git a/crates/sage-apps/src/types.rs b/crates/sage-apps/src/types.rs new file mode 100644 index 000000000..7a36ddad0 --- /dev/null +++ b/crates/sage-apps/src/types.rs @@ -0,0 +1,17 @@ +mod app; +mod invariants; +mod manifest; +mod network; +mod normalizers; +mod permissions; +mod storage; +mod url; + +pub(crate) use app::*; +pub(crate) use invariants::*; +pub(crate) use manifest::*; +pub(crate) use network::*; +pub(crate) use normalizers::*; +pub(crate) use permissions::*; +pub(crate) use storage::*; +pub(crate) use url::*; diff --git a/crates/sage-apps/src/types/app.rs b/crates/sage-apps/src/types/app.rs new file mode 100644 index 000000000..5356bf4af --- /dev/null +++ b/crates/sage-apps/src/types/app.rs @@ -0,0 +1,19 @@ +mod author; +mod common; +mod donation; +mod preview; +mod snapshot; +mod system_apps; +mod user_apps; +mod view; +mod wallet_scope; + +pub(crate) use author::*; +pub(crate) use common::*; +pub(crate) use donation::*; +pub(crate) use preview::*; +pub(crate) use snapshot::*; +pub(crate) use system_apps::*; +pub(crate) use user_apps::*; +pub(crate) use view::*; +pub(crate) use wallet_scope::*; diff --git a/crates/sage-apps/src/types/app/author.rs b/crates/sage-apps/src/types/app/author.rs new file mode 100644 index 000000000..22e674d19 --- /dev/null +++ b/crates/sage-apps/src/types/app/author.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{normalized_non_empty_string, normalized_optional_string}; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct SageAppAuthor { + name: String, + avatar: Option, +} + +impl SageAppAuthor { + pub fn new(name: impl Into, avatar: Option>) -> anyhow::Result { + Ok(Self { + name: normalized_non_empty_string(name, "author name")?, + avatar: normalized_optional_string(avatar), + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn avatar(&self) -> Option<&str> { + self.avatar.as_deref() + } +} diff --git a/crates/sage-apps/src/types/app/common.rs b/crates/sage-apps/src/types/app/common.rs new file mode 100644 index 000000000..62fdfa715 --- /dev/null +++ b/crates/sage-apps/src/types/app/common.rs @@ -0,0 +1,498 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::{ + CapabilityFlags, SANDBOX_TEST_ID_PREFIX, SageAppPackageManifest, SageAppSnapshot, + SageAppStorage, SageAppWalletScope, SageGrantedPermissions, SageRequestedPermissions, + UserBridgeCapability, UserSageAppPendingUpdate, normalized_non_empty_string, + validate_snapshot_entry_and_icon_exist, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct SageAppIdentity { + id: String, + origin_id: String, + app_dir: String, +} + +#[derive(Debug, Serialize)] +pub struct SageAppCommon { + identity: SageAppIdentity, + granted_permissions: SageGrantedPermissions, + storage: SageAppStorage, + origin_webview_storage_may_contain_secrets: bool, + active_snapshot: SageAppSnapshot, + wallet_scope: SageAppWalletScope, +} + +impl SageAppCommon { + pub(crate) fn new( + identity: SageAppIdentity, + granted_permissions: SageGrantedPermissions, + storage: SageAppStorage, + snapshot: SageAppSnapshot, + wallet_scope: SageAppWalletScope, + ) -> anyhow::Result { + Self::build( + identity, + granted_permissions, + storage, + false, + snapshot, + wallet_scope, + ) + } + + pub(crate) fn from_persisted_parts( + identity: SageAppIdentity, + granted_permissions: SageGrantedPermissions, + storage: SageAppStorage, + origin_webview_storage_may_contain_secrets: bool, + snapshot: SageAppSnapshot, + wallet_scope: SageAppWalletScope, + ) -> anyhow::Result { + Self::build( + identity, + granted_permissions, + storage, + origin_webview_storage_may_contain_secrets, + snapshot, + wallet_scope, + ) + } + + pub(crate) fn apply_update( + &mut self, + pending: &UserSageAppPendingUpdate, + granted_permissions: SageGrantedPermissions, + snapshot: SageAppSnapshot, + ) -> anyhow::Result<()> { + let next = Self::build( + self.identity.clone(), + granted_permissions, + self.storage.clone(), + self.origin_webview_storage_may_contain_secrets, + snapshot, + self.wallet_scope.clone(), + )?; + + if next.active_snapshot.manifest() != pending.manifest() { + anyhow::bail!("update snapshot manifest does not match pending update manifest"); + } + + *self = next; + Ok(()) + } + + pub(crate) fn update_permissions( + &mut self, + granted_permissions: &SageGrantedPermissions, + ) -> anyhow::Result<()> { + let requested = self.active_manifest().permissions(); + + let required_network = requested.network().whitelist().required().cloned(); + + let granted_network = granted_permissions.network().whitelist_iter().cloned(); + + let mut whitelist_by_network = granted_permissions.network().whitelist_by_network().clone(); + + for (network_id, whitelist) in requested.network().whitelist_by_network() { + whitelist_by_network + .entry(network_id.clone()) + .or_default() + .extend(whitelist.required().cloned()); + } + + let granted_permissions = SageGrantedPermissions::new( + requested, + granted_permissions.capabilities().copied(), + required_network.chain(granted_network), + whitelist_by_network, + )?; + + let next = Self::build( + self.identity.clone(), + granted_permissions, + self.storage.clone(), + self.origin_webview_storage_may_contain_secrets, + self.active_snapshot.clone(), + self.wallet_scope.clone(), + )?; + + *self = next; + Ok(()) + } + + pub(crate) fn replace_storage_and_origin( + &mut self, + storage: SageAppStorage, + origin_id: impl Into, + origin_webview_storage_may_contain_secrets: bool, + ) -> anyhow::Result<()> { + let next = Self::build( + SageAppIdentity::new(self.id().to_string(), origin_id, self.app_dir().to_string())?, + self.granted_permissions.clone(), + storage, + origin_webview_storage_may_contain_secrets, + self.active_snapshot.clone(), + self.wallet_scope.clone(), + )?; + + *self = next; + Ok(()) + } + + pub(crate) fn mark_origin_webview_storage_may_contain_secrets(&mut self) -> anyhow::Result<()> { + self.replace_origin_webview_storage_may_contain_secrets(true) + } + + pub(crate) fn replace_origin_webview_storage_may_contain_secrets( + &mut self, + origin_webview_storage_may_contain_secrets: bool, + ) -> anyhow::Result<()> { + let next = Self::build( + self.identity.clone(), + self.granted_permissions.clone(), + self.storage.clone(), + origin_webview_storage_may_contain_secrets, + self.active_snapshot.clone(), + self.wallet_scope.clone(), + )?; + + *self = next; + Ok(()) + } + + pub(crate) fn origin_webview_storage_may_contain_secrets(&self) -> bool { + self.origin_webview_storage_may_contain_secrets + } + + pub(crate) fn clone_durable(&self) -> Self { + Self { + identity: self.identity.clone(), + granted_permissions: self.granted_permissions.clone(), + storage: self.storage.clone(), + origin_webview_storage_may_contain_secrets: self + .origin_webview_storage_may_contain_secrets, + active_snapshot: self.active_snapshot.clone(), + wallet_scope: self.wallet_scope.clone(), + } + } + + pub(crate) fn capability_flags(&self) -> CapabilityFlags { + CapabilityFlags::from_capabilities(&self.granted_permissions.capabilities_vec()) + } + + pub(crate) fn has_secret_access(&self) -> bool { + self.capability_flags().accesses_sensitive_secret() + } + + pub(crate) fn has_external_access(&self) -> bool { + self.capability_flags().externally_observable() + || !self + .granted_permissions + .network() + .all_whitelist_entries() + .is_empty() + } + + pub(crate) fn has_persistent_webview_storage(&self) -> bool { + self.granted_permissions() + .capabilities() + .any(|cap| *cap == UserBridgeCapability::StoragePersistentWebview) + } + + fn build( + identity: SageAppIdentity, + granted_permissions: SageGrantedPermissions, + storage: SageAppStorage, + origin_webview_storage_may_contain_secrets: bool, + snapshot: SageAppSnapshot, + wallet_scope: SageAppWalletScope, + ) -> anyhow::Result { + let manifest = snapshot.manifest(); + + let granted_permissions = SageGrantedPermissions::from_requested_and_granted( + manifest.permissions(), + granted_permissions, + )?; + + let common = Self { + identity, + granted_permissions, + storage, + origin_webview_storage_may_contain_secrets, + active_snapshot: snapshot, + wallet_scope, + }; + + if common.has_external_access() && common.has_secret_access() { + anyhow::bail!( + "app permissions cannot include both external access and sensitive secret access" + ); + } + if common.has_external_access() && common.origin_webview_storage_may_contain_secrets { + anyhow::bail!( + "app permissions cannot include external access while origin webview storage may contain secrets" + ); + } + + validate_snapshot_entry_and_icon_exist( + &common.active_snapshot, + common.entry_file(), + common.icon_file(), + "app", + )?; + + Ok(common) + } + + pub fn is_wallet_in_scope(&self, fingerprint: u32) -> bool { + match self.wallet_scope() { + SageAppWalletScope::AllWallets => true, + SageAppWalletScope::SelectedWallets { fingerprints } => { + fingerprints.contains(&fingerprint) + } + } + } + + pub fn identity(&self) -> &SageAppIdentity { + &self.identity + } + pub fn id(&self) -> &str { + &self.identity.id + } + + pub fn origin_id(&self) -> &str { + self.identity.origin_id() + } + + pub fn name(&self) -> &str { + self.active_manifest().name() + } + + pub fn version(&self) -> &str { + self.active_manifest().version() + } + + pub fn app_dir(&self) -> &str { + &self.identity.app_dir + } + + pub fn app_path(&self) -> PathBuf { + PathBuf::from(&self.identity.app_dir) + } + + pub fn entry_file(&self) -> &str { + self.active_manifest().entry() + } + + pub fn icon_file(&self) -> Option<&str> { + self.active_manifest().icon() + } + + pub fn requested_permissions(&self) -> &SageRequestedPermissions { + self.active_manifest().permissions() + } + + pub fn granted_permissions(&self) -> &SageGrantedPermissions { + &self.granted_permissions + } + + pub fn storage(&self) -> &SageAppStorage { + &self.storage + } + + pub fn wallet_scope(&self) -> &SageAppWalletScope { + &self.wallet_scope + } + + pub(crate) fn update_wallet_scope(&mut self, wallet_scope: SageAppWalletScope) { + self.wallet_scope = wallet_scope; + } + + pub fn active_snapshot(&self) -> &SageAppSnapshot { + &self.active_snapshot + } + + pub fn active_manifest(&self) -> &SageAppPackageManifest { + self.active_snapshot.manifest() + } + + pub fn entry_path(&self) -> PathBuf { + self.active_snapshot + .file_path(self.active_manifest().entry()) + } + + pub fn icon_path(&self) -> Option { + self.active_manifest() + .icon() + .as_ref() + .map(|icon_file| self.active_snapshot.file_path(icon_file)) + } + + pub fn is_sandbox_test(&self) -> bool { + self.id().starts_with(SANDBOX_TEST_ID_PREFIX) + } +} + +impl SageAppIdentity { + pub fn new( + id: impl Into, + origin_id: impl Into, + app_dir: impl Into, + ) -> anyhow::Result { + Ok(Self { + id: normalized_non_empty_string(id, "app id")?, + origin_id: normalized_non_empty_string(origin_id, "app origin id")?, + app_dir: normalized_non_empty_string(app_dir, "app directory")?, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn origin_id(&self) -> &str { + &self.origin_id + } + + pub fn app_dir(&self) -> &str { + &self.app_dir + } +} + +#[derive(Debug, Deserialize)] +struct SageAppIdentityRaw { + id: String, + origin_id: String, + app_dir: String, +} + +impl<'de> Deserialize<'de> for SageAppIdentity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = SageAppIdentityRaw::deserialize(deserializer)?; + + SageAppIdentity::new(raw.id, raw.origin_id, raw.app_dir).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Deserialize)] +pub(crate) struct SageAppCommonRaw { + identity: SageAppIdentity, + granted_permissions: SageGrantedPermissions, + storage: SageAppStorage, + origin_webview_storage_may_contain_secrets: bool, + active_snapshot: SageAppSnapshot, + wallet_scope: SageAppWalletScope, +} + +impl TryFrom for SageAppCommon { + type Error = anyhow::Error; + + fn try_from(raw: SageAppCommonRaw) -> anyhow::Result { + SageAppCommon::build( + raw.identity, + raw.granted_permissions, + raw.storage, + raw.origin_webview_storage_may_contain_secrets, + raw.active_snapshot, + raw.wallet_scope, + ) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fs; + + use tempfile::{TempDir, tempdir}; + + use super::*; + use crate::{ + SageAppManifestFile, SageAppManifestSageVersion, SageAppManifestVersion, + SageAppPackageManifest, SageAppPackageManifestParts, SageNetworkWhitelistEntry, + SageRequestedCapabilities, SageRequestedNetworkPermissions, SageRequestedPermissions, + UserBridgeCapability, + }; + + fn entry(scheme: &str, host: &str) -> SageNetworkWhitelistEntry { + SageNetworkWhitelistEntry::new(scheme, host).unwrap() + } + + fn manifest(permissions: SageRequestedPermissions) -> SageAppPackageManifest { + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: SageAppManifestVersion(0), + name: "test app".to_string(), + icon: None, + sage_version: SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + version: "1.0.0".to_string(), + permissions, + files: vec![SageAppManifestFile::new("index.html", "a".repeat(64), 1).unwrap()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + fn tainted_app() -> (SageAppCommon, SageNetworkWhitelistEntry, TempDir) { + let optional_network = entry("https", "optional.example.com"); + let requested = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new([], [optional_network.clone()], []).unwrap(), + SageRequestedCapabilities::new([], [UserBridgeCapability::StoragePersistentWebview]), + ) + .unwrap(); + let granted = SageGrantedPermissions::new( + &requested, + [UserBridgeCapability::StoragePersistentWebview], + [], + BTreeMap::new(), + ) + .unwrap(); + + let dir = tempdir().unwrap(); + let dir_path = dir.path().to_path_buf(); + fs::write(dir_path.join("index.html"), "x").unwrap(); + let snapshot = + SageAppSnapshot::new("hash", dir_path.to_string_lossy(), manifest(requested)).unwrap(); + + let app = SageAppCommon::from_persisted_parts( + SageAppIdentity::new("app-id", "origin-id", dir_path.to_string_lossy()).unwrap(), + granted, + SageAppStorage::Unmanaged, + true, + snapshot, + SageAppWalletScope::AllWallets, + ) + .unwrap(); + + (app, optional_network, dir) + } + + #[test] + fn update_permissions_rejects_external_access_when_origin_storage_may_contain_secrets() { + let (mut app, optional_network, _dir) = tainted_app(); + + let granted_with_network = app + .granted_permissions() + .with_network_whitelist_entry_added(app.requested_permissions(), optional_network) + .unwrap(); + + let err = app.update_permissions(&granted_with_network).unwrap_err(); + + assert!( + err.to_string() + .contains("external access while origin webview storage may contain secrets"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/sage-apps/src/types/app/donation.rs b/crates/sage-apps/src/types/app/donation.rs new file mode 100644 index 000000000..907ccf9c7 --- /dev/null +++ b/crates/sage-apps/src/types/app/donation.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::normalized_non_empty_string; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct SageAppDonation { + address: String, +} + +impl SageAppDonation { + pub fn new(address: impl Into) -> anyhow::Result { + let address = normalized_non_empty_string(address, "donation address")?; + + if !address.starts_with("xch") && !address.starts_with("txch") { + anyhow::bail!("invalid donation address format"); + } + + Ok(Self { address }) + } + + pub fn address(&self) -> &str { + &self.address + } +} diff --git a/crates/sage-apps/src/types/app/preview.rs b/crates/sage-apps/src/types/app/preview.rs new file mode 100644 index 000000000..7a7a106cb --- /dev/null +++ b/crates/sage-apps/src/types/app/preview.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + SageAppIconView, SageAppPackageManifest, SageAppPackageManifestPreview, SageAppUrl, + normalized_non_empty_string, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UserSageAppPendingUpdate { + app_url: SageAppUrl, + manifest_hash: String, + manifest: SageAppPackageManifest, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SageAppUrlPreview { + app_url: SageAppUrl, + manifest_hash: String, + manifest: SageAppPackageManifestPreview, + icon: Option, +} + +impl UserSageAppPendingUpdate { + pub fn new( + app_url: SageAppUrl, + manifest_hash: String, + manifest: SageAppPackageManifest, + ) -> Self { + Self { + app_url, + manifest_hash, + manifest, + } + } + + pub fn app_url(&self) -> &SageAppUrl { + &self.app_url + } + + pub fn manifest_hash(&self) -> &str { + &self.manifest_hash + } + + pub fn manifest(&self) -> &SageAppPackageManifest { + &self.manifest + } +} + +impl SageAppUrlPreview { + pub async fn new( + app_url: &SageAppUrl, + manifest: SageAppPackageManifestPreview, + manifest_hash: String, + ) -> anyhow::Result { + let icon = SageAppIconView::from_url_preview(app_url, &manifest).await; + + Ok(Self { + app_url: app_url.clone(), + manifest_hash: normalized_non_empty_string(manifest_hash, "manifest hash")?, + manifest, + icon, + }) + } + + pub async fn from_full_manifest( + app_url: &SageAppUrl, + manifest: SageAppPackageManifest, + manifest_hash: String, + ) -> anyhow::Result { + Self::new( + app_url, + SageAppPackageManifestPreview::Full { manifest }, + manifest_hash, + ) + .await + } + + pub async fn from_pending_update(pending: &UserSageAppPendingUpdate) -> Self { + let app_url = pending.app_url().clone(); + let icon = SageAppIconView::from_url_manifest(&app_url, &pending.manifest().clone()).await; + Self { + app_url, + manifest_hash: pending.manifest_hash().to_string(), + manifest: SageAppPackageManifestPreview::Full { + manifest: pending.manifest().clone(), + }, + icon, + } + } + + pub fn app_url(&self) -> &SageAppUrl { + &self.app_url + } + + pub fn manifest_hash(&self) -> &str { + &self.manifest_hash + } + + pub fn manifest(&self) -> &SageAppPackageManifestPreview { + &self.manifest + } + + pub fn full_manifest(&self) -> Option<&SageAppPackageManifest> { + self.manifest.full_manifest() + } + + pub fn require_full_manifest(&self) -> anyhow::Result<&SageAppPackageManifest> { + self.full_manifest().ok_or_else(|| { + anyhow::anyhow!( + "manifest could not be fully parsed: {}", + self.manifest + .parse_error() + .unwrap_or("unknown manifest parse error") + ) + }) + } +} diff --git a/crates/sage-apps/src/types/app/snapshot.rs b/crates/sage-apps/src/types/app/snapshot.rs new file mode 100644 index 000000000..1351d52c7 --- /dev/null +++ b/crates/sage-apps/src/types/app/snapshot.rs @@ -0,0 +1,224 @@ +use std::path::{Component, Path, PathBuf}; + +use serde::Serialize; +use serde::{Deserialize, Deserializer}; + +use crate::{SageAppPackageManifest, normalized_non_empty_string}; + +#[derive(Debug, Clone, Serialize)] +pub struct SageAppSnapshot { + manifest_hash: String, + snapshot_dir: String, + manifest: SageAppPackageManifest, +} + +impl SageAppSnapshot { + pub fn new( + manifest_hash: impl Into, + snapshot_dir: impl Into, + manifest: SageAppPackageManifest, + ) -> anyhow::Result { + let manifest_hash = normalized_non_empty_string(manifest_hash, "snapshot manifest hash")?; + let snapshot_dir = normalized_non_empty_string(snapshot_dir, "snapshot directory")?; + + let snapshot = Self { + manifest_hash, + snapshot_dir, + manifest, + }; + + snapshot.validate_files_exist_internal()?; + + Ok(snapshot) + } + + pub fn new_builtin_system( + app_id: &str, + snapshot_dir: impl Into, + manifest: SageAppPackageManifest, + ) -> anyhow::Result { + Self::new(format!("builtin-system:{app_id}"), snapshot_dir, manifest) + } + + pub fn resolve_file_path(&self, request_path: &str) -> anyhow::Result { + let normalized = if request_path.is_empty() || request_path == "/" { + self.manifest().entry() + } else { + request_path.trim_start_matches('/') + }; + + let relative = Path::new(normalized); + + if relative.is_absolute() { + anyhow::bail!("snapshot path must be relative"); + } + + for component in relative.components() { + match component { + Component::Normal(_) => {} + _ => anyhow::bail!("invalid snapshot path component in {request_path}"), + } + } + + let root = Path::new(self.snapshot_dir()); + let path = root.join(relative); + + if !path.is_file() { + anyhow::bail!("snapshot file not found: {request_path}"); + } + + let canonical_root = root.canonicalize()?; + let canonical_path = path.canonicalize()?; + + if !canonical_path.starts_with(&canonical_root) { + anyhow::bail!("snapshot path escapes root: {request_path}"); + } + + Ok(canonical_path) + } + + pub fn manifest_hash(&self) -> &str { + &self.manifest_hash + } + + pub fn snapshot_dir(&self) -> &str { + &self.snapshot_dir + } + + pub fn manifest(&self) -> &SageAppPackageManifest { + &self.manifest + } + + pub fn file_path(&self, path: &str) -> PathBuf { + Path::new(&self.snapshot_dir).join(path) + } + + fn validate_files_exist_internal(&self) -> anyhow::Result<()> { + for file in self.manifest.files() { + let path = self.file_path(file.path()); + + if !path.is_file() { + anyhow::bail!("snapshot file does not exist: {}", path.display()); + } + } + + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +struct SageAppSnapshotDeserialize { + manifest_hash: String, + snapshot_dir: String, + manifest: SageAppPackageManifest, +} + +impl<'de> Deserialize<'de> for SageAppSnapshot { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = SageAppSnapshotDeserialize::deserialize(deserializer)?; + + SageAppSnapshot::new(raw.manifest_hash, raw.snapshot_dir, raw.manifest) + .map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::{TempDir, tempdir}; + + use super::*; + use crate::{ + SageAppManifestFile, SageAppManifestSageVersion, SageAppManifestVersion, + SageAppPackageManifestParts, SageRequestedPermissions, + }; + + fn manifest_file(path: &str) -> SageAppManifestFile { + SageAppManifestFile::new(path, "a".repeat(64), 1).unwrap() + } + + fn manifest() -> SageAppPackageManifest { + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: SageAppManifestVersion(0), + name: "test app".to_string(), + icon: None, + sage_version: SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![manifest_file("index.html"), manifest_file("nested/file.js")], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + fn snapshot() -> (SageAppSnapshot, TempDir) { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("index.html"), "x").unwrap(); + fs::create_dir_all(dir.path().join("nested")).unwrap(); + fs::write(dir.path().join("nested/file.js"), "x").unwrap(); + + let snapshot = + SageAppSnapshot::new("hash", dir.path().to_string_lossy(), manifest()).unwrap(); + + (snapshot, dir) + } + + #[test] + fn resolve_file_path_defaults_empty_and_root_to_manifest_entry() { + let (snapshot, _dir) = snapshot(); + let entry = snapshot.file_path("index.html").canonicalize().unwrap(); + + assert_eq!(snapshot.resolve_file_path("").unwrap(), entry); + assert_eq!(snapshot.resolve_file_path("/").unwrap(), entry); + } + + #[test] + fn resolve_file_path_accepts_nested_snapshot_file() { + let (snapshot, _dir) = snapshot(); + let nested = snapshot.file_path("nested/file.js").canonicalize().unwrap(); + + assert_eq!( + snapshot.resolve_file_path("/nested/file.js").unwrap(), + nested + ); + } + + #[test] + fn resolve_file_path_rejects_traversal_components() { + let (snapshot, _dir) = snapshot(); + + for path in ["../secret.txt", "/../secret.txt", "nested/../index.html"] { + assert!( + snapshot.resolve_file_path(path).is_err(), + "expected {path} to be rejected" + ); + } + } + + #[cfg(unix)] + #[test] + fn resolve_file_path_rejects_symlink_that_escapes_snapshot_root() { + let (snapshot, dir) = snapshot(); + let external = dir.path().parent().unwrap().join("external-secret.txt"); + fs::write(&external, "secret").unwrap(); + std::os::unix::fs::symlink(&external, dir.path().join("nested/link.txt")).unwrap(); + + let err = snapshot + .resolve_file_path("nested/link.txt") + .expect_err("escaping symlink must be rejected"); + + assert!( + err.to_string().contains("escapes root"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/sage-apps/src/types/app/system_apps.rs b/crates/sage-apps/src/types/app/system_apps.rs new file mode 100644 index 000000000..25181e22c --- /dev/null +++ b/crates/sage-apps/src/types/app/system_apps.rs @@ -0,0 +1,107 @@ +use serde::Serialize; +use specta::Type; + +use crate::{SageApp, SageAppCommon, SageGrantedSystemPermissions, SystemAppUsage}; + +#[derive(Debug, Clone, Serialize, Type, PartialEq, Eq)] +#[serde(tag = "kind")] +pub enum AppPresentation { + Taskbar, + Modal(AppModalPresentation), +} + +#[derive(Debug, Clone, Serialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppModalPresentation { + visible_over_app_ids: Vec, + visible_over_launchpad: bool, + priority: i32, +} + +#[derive(Debug)] +pub struct SystemSageApp { + common: SageAppCommon, + usage: SystemAppUsage, + system_granted_permissions: SageGrantedSystemPermissions, +} + +impl SystemSageApp { + pub fn new( + common: SageAppCommon, + usage: SystemAppUsage, + system_granted_permissions: SageGrantedSystemPermissions, + ) -> Self { + Self { + common, + usage, + system_granted_permissions, + } + } + + pub fn into_sage_app(self) -> SageApp { + SageApp::System(self) + } + + pub fn common(&self) -> &SageAppCommon { + &self.common + } + + pub fn common_mut(&mut self) -> &mut SageAppCommon { + &mut self.common + } + + pub fn usage(&self) -> SystemAppUsage { + self.usage + } + + pub fn system_granted_permissions(&self) -> &SageGrantedSystemPermissions { + &self.system_granted_permissions + } +} + +impl AppModalPresentation { + pub fn over_apps(app_ids: Vec, priority: i32) -> Self { + Self { + visible_over_app_ids: app_ids, + visible_over_launchpad: false, + priority, + } + } + + pub fn over_app_and_launchpad(app_id: String, priority: i32) -> Self { + Self { + visible_over_app_ids: vec![app_id], + visible_over_launchpad: true, + priority, + } + } + + pub fn over_launchpad(priority: i32) -> Self { + Self { + visible_over_app_ids: vec![], + visible_over_launchpad: true, + priority, + } + } + + pub fn visible_over_app_ids(&self) -> Vec { + self.visible_over_app_ids.clone() + } + + pub fn visible_over_launchpad(&self) -> bool { + self.visible_over_launchpad + } + + pub fn update_app_ids(&mut self, target_app_ids: Vec) -> bool { + if self.visible_over_app_ids == target_app_ids { + return false; + } + + self.visible_over_app_ids = target_app_ids; + true + } + + pub fn priority(&self) -> i32 { + self.priority + } +} diff --git a/crates/sage-apps/src/types/app/user_apps.rs b/crates/sage-apps/src/types/app/user_apps.rs new file mode 100644 index 000000000..657b95f71 --- /dev/null +++ b/crates/sage-apps/src/types/app/user_apps.rs @@ -0,0 +1,564 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tokio::sync::OwnedMutexGuard; + +use crate::{ + BridgeCapability, SageAppCommon, SageAppCommonRaw, SageAppIconView, SageAppManifestHeaderV0, + SageAppSnapshot, SageAppStorage, SageAppUrl, SageGrantedPermissions, + SageGrantedSystemPermissions, SageRequestedPermissions, SharedRuntime, SystemSageApp, + UserSageAppPendingUpdate, UserSageAppPendingUpdateView, +}; + +#[derive(Debug)] +pub struct ResolvedStoppedApp { + app: SharedSageApp, + _guard: OwnedMutexGuard<()>, +} + +#[derive(Debug)] +pub struct ResolvedRunningApp { + runtime: SharedRuntime, +} + +impl ResolvedStoppedApp { + pub fn new(app: SharedSageApp, _guard: OwnedMutexGuard<()>) -> Self { + Self { app, _guard } + } + + pub fn with_app(&self, f: impl FnOnce(&SharedSageApp) -> T) -> T { + f(&self.app) + } + + pub fn into_app(self) -> SharedSageApp { + self.app + } + + /// Returns the app without releasing its per-app operation lock. + pub fn into_app_and_guard(self) -> (SharedSageApp, OwnedMutexGuard<()>) { + (self.app, self._guard) + } +} + +impl ResolvedRunningApp { + pub fn new(runtime: SharedRuntime) -> Self { + Self { runtime } + } + + pub fn runtime(&self) -> SharedRuntime { + self.runtime.clone() + } + + pub fn with_app(&self, f: impl FnOnce(&SharedSageApp) -> T) -> T { + let app = self.runtime.app(); + f(&app) + } + + #[allow(clippy::wrong_self_convention)] + pub fn into_app(&self) -> SharedSageApp { + self.runtime.app().clone() + } +} + +#[derive(Debug)] +pub enum ResolvedApp { + Stopped(ResolvedStoppedApp), + Running(ResolvedRunningApp), +} + +impl ResolvedApp { + pub fn with_app(&self, f: impl FnOnce(&SharedSageApp) -> T) -> T { + match self { + Self::Stopped(stopped) => stopped.with_app(f), + Self::Running(running) => running.with_app(f), + } + } + + pub fn clone_app_for_operation(&self) -> SharedSageApp { + match self { + Self::Stopped(stopped) => stopped.app.clone(), + Self::Running(running) => running.runtime.app(), + } + } +} + +#[derive(Debug)] +pub struct SharedSageApp { + inner: Arc>, +} + +impl SharedSageApp { + pub(crate) fn new(app: SageApp) -> Self { + Self { + inner: Arc::new(parking_lot::RwLock::new(app)), + } + } + + pub(crate) fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } + + pub(crate) fn replace_committed(&self, next: SageApp) { + let mut app = self.inner.write(); + *app = next; + } + + pub(crate) fn should_review_pending_update(&self) -> bool { + self.with(|sage_app| { + let Some(user_app) = sage_app.as_user() else { + return false; + }; + + user_app.pending_update().is_some_and(|pending| { + UserSageAppPendingUpdateView::from_pending_update( + pending, + user_app.common().granted_permissions(), + ) + .decision() + .is_review() + }) + }) + } + + pub(crate) fn runtime_can_persist_secrets(&self) -> bool { + self.with(|app| { + app.common().has_secret_access() && app.common().has_persistent_webview_storage() + }) + } + + pub(crate) fn with(&self, f: impl FnOnce(&SageApp) -> T) -> T { + let app = self.inner.read(); + f(&app) + } + + pub(crate) fn try_with(&self, f: impl FnOnce(&SageApp) -> Result) -> Result { + let app = self.inner.read(); + f(&app) + } + + pub fn is_user_app(&self) -> bool { + self.with(SageApp::is_user) + } + + pub fn is_system_app(&self) -> bool { + self.with(SageApp::is_system) + } + + pub fn is_wallet_in_scope(&self, fingerprint: u32) -> bool { + self.with(|app| app.common().is_wallet_in_scope(fingerprint)) + } + + pub(crate) fn id(&self) -> String { + self.with(|app| app.id().to_string()) + } + + pub(crate) fn name(&self) -> String { + self.with(|app| app.name().to_string()) + } + + pub(crate) fn origin_id(&self) -> String { + self.with(|app| app.origin_id().to_string()) + } + + pub(crate) fn is_capability_granted(&self, capability: BridgeCapability) -> bool { + self.with(|app| match capability { + BridgeCapability::User(capability) => { + app.granted_permissions().has_capability(capability) + } + + BridgeCapability::System(capability) => app + .system_granted_permissions() + .is_some_and(|permissions| permissions.capabilities().contains(&capability)), + }) + } + + pub(crate) fn webview_label(&self) -> String { + self.with(|app| { + if app.is_system() { + format!("system-app-{}", app.id()) + } else { + format!("app-{}", app.id()) + } + }) + } + + pub(crate) fn webview_label_matches(&self, label: &str) -> bool { + let app_id = self.id(); + + if let Some(extracted_app_id) = label.strip_prefix("app-") { + return self.is_user_app() && extracted_app_id == app_id; + } + + if let Some(extracted_app_id) = label.strip_prefix("system-app-") { + return self.is_system_app() && extracted_app_id == app_id; + } + + false + } +} + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum SageApp { + System(SystemSageApp), + User(UserSageApp), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UserSageAppSource { + Zip, + Url { app_url: SageAppUrl }, +} + +#[derive(Debug, Serialize)] +pub struct UserSageApp { + common: SageAppCommon, + source: UserSageAppSource, + pending_update: Option, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CorruptedInstalledSageApp { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, + + app_dir: String, + error: String, + + #[serde(skip_serializing_if = "Option::is_none")] + manifest_header: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, +} + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum ListedSageApp { + User(UserSageApp), + System(SystemSageApp), + Corrupted(CorruptedInstalledSageApp), +} + +impl UserSageApp { + pub(crate) fn new_installed(common: SageAppCommon, source: UserSageAppSource) -> Self { + Self { + common, + source, + pending_update: None, + } + } + + pub(crate) fn load_persisted( + common: SageAppCommon, + source: UserSageAppSource, + pending_update: Option, + ) -> Self { + Self { + common, + source, + pending_update, + } + } + + pub(crate) fn clone_durable(&self) -> Self { + Self { + common: self.common.clone_durable(), + source: self.source.clone(), + pending_update: self.pending_update.clone(), + } + } + + pub(crate) fn set_pending_update(&mut self, pending_update: Option) { + self.pending_update = pending_update; + } + + pub(crate) fn common(&self) -> &SageAppCommon { + &self.common + } + + pub(crate) fn common_mut(&mut self) -> &mut SageAppCommon { + &mut self.common + } + + pub(crate) fn source(&self) -> &UserSageAppSource { + &self.source + } + + pub(crate) fn pending_update(&self) -> Option<&UserSageAppPendingUpdate> { + self.pending_update.as_ref() + } +} + +impl SageApp { + pub(crate) fn is_user(&self) -> bool { + matches!(self, Self::User(_)) + } + + pub(crate) fn is_system(&self) -> bool { + matches!(self, Self::System(_)) + } + + pub(crate) fn common(&self) -> &SageAppCommon { + match self { + Self::System(app) => app.common(), + Self::User(app) => app.common(), + } + } + + pub(crate) fn common_mut(&mut self) -> &mut SageAppCommon { + match self { + Self::System(app) => app.common_mut(), + Self::User(app) => app.common_mut(), + } + } + + pub(crate) fn clone_for_rollback(&self) -> anyhow::Result { + match self { + Self::User(app) => Ok(Self::User(app.clone_durable())), + Self::System(_) => { + anyhow::bail!("system apps are immutable") + } + } + } + + pub(crate) fn apply_update( + &mut self, + pending: &UserSageAppPendingUpdate, + granted_permissions: SageGrantedPermissions, + snapshot: SageAppSnapshot, + ) -> anyhow::Result<()> { + let Self::User(app) = self else { + anyhow::bail!("system app cannot receive user update"); + }; + + app.common_mut() + .apply_update(pending, granted_permissions, snapshot) + .map_err(|err| anyhow::anyhow!("failed to apply app update: {err}"))?; + + app.set_pending_update(None); + + Ok(()) + } + + pub(crate) fn id(&self) -> &str { + self.common().id() + } + + pub(crate) fn origin_id(&self) -> &str { + self.common().origin_id() + } + + pub(crate) fn name(&self) -> &str { + self.common().name() + } + + pub(crate) fn version(&self) -> &str { + self.common().version() + } + + pub(crate) fn app_path(&self) -> PathBuf { + self.common().app_path() + } + + pub(crate) fn entry_file(&self) -> String { + self.common().entry_file().to_string() + } + + pub(crate) fn requested_permissions(&self) -> &SageRequestedPermissions { + self.common().requested_permissions() + } + + pub(crate) fn granted_permissions(&self) -> &SageGrantedPermissions { + self.common().granted_permissions() + } + + pub(crate) fn system_granted_permissions(&self) -> Option<&SageGrantedSystemPermissions> { + match self { + Self::System(app) => Some(app.system_granted_permissions()), + Self::User(_) => None, + } + } + + pub(crate) fn storage(&self) -> &SageAppStorage { + self.common().storage() + } + + pub(crate) fn active_snapshot(&self) -> &SageAppSnapshot { + self.common().active_snapshot() + } + + pub(crate) fn set_pending_update( + &mut self, + pending_update: Option, + ) -> anyhow::Result<()> { + match self { + Self::User(app) => { + app.set_pending_update(pending_update); + Ok(()) + } + Self::System(_) => anyhow::bail!("system app cannot have pending user update"), + } + } + + pub(crate) fn as_user(&self) -> Option<&UserSageApp> { + match self { + Self::User(app) => Some(app), + Self::System(_) => None, + } + } +} + +impl CorruptedInstalledSageApp { + pub(crate) fn new( + id: impl Into, + app_dir: impl Into, + error: impl Into, + ) -> Self { + Self { + id: id.into(), + icon: None, + app_dir: app_dir.into(), + error: error.into(), + manifest_header: None, + source: None, + } + } + + pub(crate) fn with_icon(mut self, icon: Option) -> Self { + self.icon = icon; + self + } + + pub(crate) fn with_manifest_header( + mut self, + manifest_header: Option, + ) -> Self { + self.manifest_header = manifest_header; + self + } + + pub(crate) fn with_source(mut self, source: Option) -> Self { + self.source = source; + self + } + + pub(crate) fn id(&self) -> &str { + &self.id + } +} + +impl UserSageApp { + pub(crate) fn into_sage_app(self) -> SageApp { + SageApp::User(self) + } +} + +#[derive(Debug, Deserialize)] +struct UserSageAppRaw { + common: SageAppCommonRaw, + source: UserSageAppSource, + + #[serde(default)] + pending_update: Option, +} + +impl<'de> Deserialize<'de> for UserSageApp { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = UserSageAppRaw::deserialize(deserializer)?; + + let common = raw.common.try_into().map_err(serde::de::Error::custom)?; + + Ok(UserSageApp::load_persisted( + common, + raw.source, + raw.pending_update, + )) + } +} + +#[cfg(test)] +impl UserSageAppSource { + pub(crate) fn url(app_url: impl AsRef) -> anyhow::Result { + let app_url = SageAppUrl::parse(app_url.as_ref())?; + Ok(Self::Url { app_url }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fs; + use std::sync::Arc; + + use tempfile::{TempDir, tempdir}; + use tokio::sync::Mutex; + + use super::*; + use crate::{ + SageAppIdentity, SageAppManifestFile, SageAppManifestSageVersion, SageAppManifestVersion, + SageAppPackageManifest, SageAppPackageManifestParts, SageAppWalletScope, + }; + + fn test_app() -> (SharedSageApp, TempDir) { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("index.html"), "x").unwrap(); + + let requested_permissions = SageRequestedPermissions::default(); + let manifest = SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: SageAppManifestVersion(0), + name: "test app".to_string(), + icon: None, + sage_version: SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + version: "1.0.0".to_string(), + permissions: requested_permissions.clone(), + files: vec![SageAppManifestFile::new("index.html", "a".repeat(64), 1).unwrap()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap(); + let granted_permissions = + SageGrantedPermissions::new(&requested_permissions, [], [], BTreeMap::new()).unwrap(); + let snapshot = + SageAppSnapshot::new("hash", dir.path().to_string_lossy(), manifest).unwrap(); + let common = SageAppCommon::new( + SageAppIdentity::new("app-id", "origin-id", dir.path().to_string_lossy()).unwrap(), + granted_permissions, + SageAppStorage::Unmanaged, + snapshot, + SageAppWalletScope::AllWallets, + ) + .unwrap(); + let app = UserSageApp::new_installed(common, UserSageAppSource::Zip); + + (SharedSageApp::new(app.into_sage_app()), dir) + } + + #[tokio::test] + async fn stopped_app_guard_can_be_held_after_extracting_app() { + let lock = Arc::new(Mutex::new(())); + let guard = lock.clone().lock_owned().await; + let (app, _dir) = test_app(); + let resolved = ResolvedStoppedApp::new(app, guard); + + let (_app, guard) = resolved.into_app_and_guard(); + + assert!(lock.clone().try_lock_owned().is_err()); + drop(guard); + assert!(lock.try_lock_owned().is_ok()); + } +} diff --git a/crates/sage-apps/src/types/app/view.rs b/crates/sage-apps/src/types/app/view.rs new file mode 100644 index 000000000..2d599ff8f --- /dev/null +++ b/crates/sage-apps/src/types/app/view.rs @@ -0,0 +1,15 @@ +mod common; +mod network; +mod permission; +mod preview; +mod snapshot; +mod system_apps; +mod user_apps; + +pub(crate) use common::*; +pub(crate) use network::*; +pub(crate) use permission::*; +pub(crate) use preview::*; +pub(crate) use snapshot::*; +pub(crate) use system_apps::*; +pub(crate) use user_apps::*; diff --git a/crates/sage-apps/src/types/app/view/common.rs b/crates/sage-apps/src/types/app/view/common.rs new file mode 100644 index 000000000..e38bc9d88 --- /dev/null +++ b/crates/sage-apps/src/types/app/view/common.rs @@ -0,0 +1,188 @@ +use anyhow::Context; +use serde::Serialize; +use specta::Type; +use url::Url; + +use crate::{ + SageAppCommon, SageAppIdentity, SageAppPackageManifest, SageAppPackageManifestPreview, + SageAppSnapshotView, SageAppUrl, SageAppWalletScope, SageGrantedPermissionsView, + security::get_with_ssrf_guard, +}; + +const MAX_REMOTE_ICON_BYTES: u64 = 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SageAppIdentityView { + id: String, + origin_id: String, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SageAppCommonView { + identity: SageAppIdentityView, + granted_permissions: SageGrantedPermissionsView, + wallet_scope: SageAppWalletScope, + active_snapshot: SageAppSnapshotView, + icon: Option, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SageAppIconView { + mime: String, + bytes: Vec, +} + +impl From<&SageAppCommon> for SageAppCommonView { + fn from(common: &SageAppCommon) -> Self { + Self { + identity: common.identity().into(), + active_snapshot: common.active_snapshot().into(), + granted_permissions: common.granted_permissions().into(), + wallet_scope: common.wallet_scope().clone(), + icon: SageAppIconView::from_common(common), + } + } +} + +impl From<&SageAppIdentity> for SageAppIdentityView { + fn from(value: &SageAppIdentity) -> Self { + Self { + id: value.id().to_string(), + origin_id: value.origin_id().to_string(), + } + } +} + +impl SageAppIconView { + pub(crate) fn from_common(common: &SageAppCommon) -> Option { + let icon_path = common.active_snapshot().manifest().icon()?; + Self::from_common_file(common, icon_path) + } + + pub(crate) fn author_avatar_from_common(common: &SageAppCommon) -> Option { + let avatar_path = common.active_snapshot().manifest().author()?.avatar()?; + + Self::from_common_file(common, avatar_path) + } + + fn from_common_file(common: &SageAppCommon, path: &str) -> Option { + let file_path = common.active_snapshot().resolve_file_path(path).ok()?; + + Self::from_file_path(&file_path) + } + + pub(crate) fn from_file_path(file_path: &std::path::Path) -> Option { + let bytes = std::fs::read(file_path).ok()?; + let mime = mime_guess::from_path(file_path) + .first_or_octet_stream() + .essence_str() + .to_string(); + + Some(Self { mime, bytes }) + } + + pub(crate) async fn from_url_manifest( + base: &SageAppUrl, + manifest: &SageAppPackageManifest, + ) -> Option { + let icon_path = manifest.icon()?; + Self::from_url(base, icon_path).await + } + + pub async fn from_url_preview( + base: &SageAppUrl, + preview: &SageAppPackageManifestPreview, + ) -> Option { + match preview { + SageAppPackageManifestPreview::Full { manifest } => { + Self::from_url_manifest(base, manifest).await + } + SageAppPackageManifestPreview::Partial { + manifest_header, .. + } => { + let icon_path = manifest_header.icon.as_deref()?; + + Self::from_url(base, icon_path).await + } + } + } + + async fn from_url(base: &SageAppUrl, icon_path: &str) -> Option { + let base_url = Url::parse(base.as_str()).ok()?; + let resolved = base_url.join(icon_path).ok()?; + + let resp = get_with_ssrf_guard(resolved.as_str()).await.ok()?; + if !resp.status().is_success() { + return None; + } + + let mime = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let bytes = read_remote_icon_bytes(resp).await.ok()?; + + Some(Self { mime, bytes }) + } +} + +async fn read_remote_icon_bytes(mut resp: reqwest::Response) -> anyhow::Result> { + if let Some(content_length) = resp.content_length() { + ensure_remote_icon_size(content_length)?; + } + + let mut bytes = Vec::with_capacity(usize::try_from(MAX_REMOTE_ICON_BYTES).unwrap_or(0)); + let mut received = 0u64; + + while let Some(chunk) = resp + .chunk() + .await + .context("failed to read remote icon body")? + { + let chunk_len = u64::try_from(chunk.len()).context("remote icon chunk too large")?; + + received = received + .checked_add(chunk_len) + .context("remote icon body size overflow")?; + + ensure_remote_icon_size(received)?; + bytes.extend_from_slice(&chunk); + } + + Ok(bytes) +} + +fn ensure_remote_icon_size(size: u64) -> anyhow::Result<()> { + if size > MAX_REMOTE_ICON_BYTES { + anyhow::bail!("remote app icon exceeds maximum size {MAX_REMOTE_ICON_BYTES}"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_icon_size_accepts_limit() { + ensure_remote_icon_size(MAX_REMOTE_ICON_BYTES).unwrap(); + } + + #[test] + fn remote_icon_size_rejects_over_limit() { + let err = ensure_remote_icon_size(MAX_REMOTE_ICON_BYTES + 1) + .expect_err("oversized remote icon must be rejected"); + + assert!( + err.to_string().contains("remote app icon exceeds"), + "unexpected error: {err}" + ); + } +} diff --git a/crates/sage-apps/src/types/app/view/network.rs b/crates/sage-apps/src/types/app/view/network.rs new file mode 100644 index 000000000..64c99d540 --- /dev/null +++ b/crates/sage-apps/src/types/app/view/network.rs @@ -0,0 +1,20 @@ +use serde::Serialize; +use specta::Type; + +use crate::SageNetworkWhitelistEntry; + +#[derive(Debug, Clone, Serialize, Type, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub struct SageNetworkWhitelistEntryView { + scheme: String, + host: String, +} + +impl From<&SageNetworkWhitelistEntry> for SageNetworkWhitelistEntryView { + fn from(entry: &SageNetworkWhitelistEntry) -> Self { + Self { + scheme: entry.scheme().to_string(), + host: entry.host().to_string(), + } + } +} diff --git a/crates/sage-apps/src/types/app/view/permission.rs b/crates/sage-apps/src/types/app/view/permission.rs new file mode 100644 index 000000000..7542977e6 --- /dev/null +++ b/crates/sage-apps/src/types/app/view/permission.rs @@ -0,0 +1,217 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{ + CapabilityDefinition, CapabilityFlags, SageGrantedNetworkPermissions, SageGrantedPermissions, + SageGrantedSystemPermissions, SageNetworkWhitelistEntry, SageNetworkWhitelistEntryView, + SageRequestedPermissions, SystemBridgeCapability, UserBridgeCapability, +}; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Copy, Serialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageAppCapabilityFlagsView { + externally_observable: bool, + accesses_sensitive_secret: bool, + requestable_by_app: bool, + user_grantable: bool, +} + +#[derive(Debug, Clone, Serialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageAppCapabilityDefinitionView { + key: String, + label: String, + description: String, + flags: SageAppCapabilityFlagsView, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageGrantedPermissionsInput { + capabilities: BTreeSet, + network: SageGrantedNetworkPermissionsInput, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageGrantedNetworkPermissionsInput { + #[serde(default)] + whitelist: BTreeSet, + + #[serde(default)] + whitelist_by_network: BTreeMap>, +} + +#[derive(Debug, Clone, Serialize, Type, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageGrantedPermissionsView { + capabilities: BTreeSet, + network: SageGrantedNetworkPermissionsView, +} + +#[derive(Debug, Clone, Serialize, Type, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageGrantedNetworkPermissionsView { + whitelist: BTreeSet, + + #[serde(default)] + whitelist_by_network: BTreeMap>, +} + +#[derive(Debug, Clone, Serialize, Type, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageGrantedSystemPermissionsView { + capabilities: Vec, +} + +impl SageGrantedPermissionsInput { + pub fn new( + capabilities: impl IntoIterator, + network_whitelist: impl IntoIterator, + network_whitelist_by_network: BTreeMap>, + ) -> Self { + Self { + capabilities: capabilities.into_iter().collect(), + network: SageGrantedNetworkPermissionsInput { + whitelist: network_whitelist.into_iter().collect(), + whitelist_by_network: network_whitelist_by_network, + }, + } + } + + pub fn resolve( + &self, + requested: &SageRequestedPermissions, + ) -> anyhow::Result { + SageGrantedPermissions::new( + requested, + self.capabilities.iter().copied(), + self.network.whitelist.iter().cloned(), + self.network.whitelist_by_network.clone(), + ) + } + + pub fn with_additional(mut self, additional: SageGrantedPermissionsInput) -> Self { + self.capabilities.extend(additional.capabilities); + + self.network.whitelist.extend(additional.network.whitelist); + + for (network_id, entries) in additional.network.whitelist_by_network { + self.network + .whitelist_by_network + .entry(network_id) + .or_default() + .extend(entries); + } + + self + } +} + +impl From<&SageGrantedPermissions> for SageGrantedPermissionsView { + fn from(permissions: &SageGrantedPermissions) -> Self { + Self { + network: permissions.network().into(), + capabilities: permissions.shared_capabilities().iter().copied().collect(), + } + } +} + +impl From<&SageGrantedNetworkPermissions> for SageGrantedNetworkPermissionsView { + fn from(value: &SageGrantedNetworkPermissions) -> Self { + Self { + whitelist: value.whitelist().iter().map(Into::into).collect(), + whitelist_by_network: value + .whitelist_by_network() + .iter() + .map(|(network_id, entries)| { + (network_id.clone(), entries.iter().map(Into::into).collect()) + }) + .collect(), + } + } +} + +impl From<&SageGrantedSystemPermissions> for SageGrantedSystemPermissionsView { + fn from(value: &SageGrantedSystemPermissions) -> Self { + Self { + capabilities: value.capabilities().to_vec(), + } + } +} + +impl From for SageAppCapabilityFlagsView { + fn from(flags: CapabilityFlags) -> Self { + Self { + externally_observable: flags.externally_observable(), + accesses_sensitive_secret: flags.accesses_sensitive_secret(), + requestable_by_app: flags.requestable_by_app(), + user_grantable: flags.user_grantable(), + } + } +} + +impl From> for SageAppCapabilityDefinitionView { + fn from(definition: CapabilityDefinition) -> Self { + Self { + key: definition.capability().key().to_string(), + label: definition.label().to_string(), + description: definition.description().to_string(), + flags: definition.flags().into(), + } + } +} + +impl From<(&SageGrantedPermissions, &SageRequestedPermissions)> for SageGrantedPermissionsInput { + fn from((granted, requested): (&SageGrantedPermissions, &SageRequestedPermissions)) -> Self { + let capabilities = granted + .capabilities() + .copied() + .filter(|capability| requested.capabilities().is_allowed(*capability)); + + let network_whitelist = granted + .network() + .whitelist_iter() + .filter(|entry| requested.network().whitelist().is_allowed(entry)) + .cloned() + .chain(requested.network().whitelist().required().cloned()); + + let mut whitelist_by_network = granted + .network() + .whitelist_by_network() + .iter() + .filter_map(|(network_id, granted_entries)| { + let requested_whitelist = + requested.network().whitelist_by_network().get(network_id)?; + + let entries = granted_entries + .iter() + .filter(|entry| requested_whitelist.is_allowed(entry)) + .cloned() + .collect::>(); + + if entries.is_empty() { + None + } else { + Some((network_id.clone(), entries)) + } + }) + .collect::>(); + + for (network_id, requested_whitelist) in requested.network().whitelist_by_network() { + let required = requested_whitelist.required().cloned().collect::>(); + + if !required.is_empty() { + whitelist_by_network + .entry(network_id.clone()) + .or_default() + .extend(required); + } + } + + Self::new(capabilities, network_whitelist, whitelist_by_network) + } +} diff --git a/crates/sage-apps/src/types/app/view/preview.rs b/crates/sage-apps/src/types/app/view/preview.rs new file mode 100644 index 000000000..be6609a86 --- /dev/null +++ b/crates/sage-apps/src/types/app/view/preview.rs @@ -0,0 +1,203 @@ +use serde::Serialize; +use specta::Type; + +use crate::{ + SageAppPackageManifest, SageAppUrl, SageGrantedPermissions, SageNetworkWhitelistEntry, + SageRequestedPermissions, UserBridgeCapability, UserSageAppPendingUpdate, + get_user_capability_definition, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UserSageAppPendingUpdateView { + app_url: SageAppUrl, + manifest_hash: String, + manifest: SageAppPackageManifest, + decision: UserSageAppPendingUpdateDecisionView, +} + +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UserSageAppPendingUpdateDecisionReviewView { + required_user_grantable_capabilities: Vec, + required_network_whitelist: Vec, + required_network_whitelist_by_network: + std::collections::BTreeMap>, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UserSageAppPendingUpdateDecisionView { + Apply, + Review(UserSageAppPendingUpdateDecisionReviewView), +} + +impl UserSageAppPendingUpdateView { + pub fn from_pending_update( + value: &UserSageAppPendingUpdate, + active_grants: &SageGrantedPermissions, + ) -> Self { + Self { + app_url: value.app_url().clone(), + manifest_hash: value.manifest_hash().to_string(), + manifest: value.manifest().clone(), + decision: UserSageAppPendingUpdateDecisionView::from_pending_update( + active_grants, + value.manifest().permissions(), + ), + } + } + + pub fn decision(&self) -> &UserSageAppPendingUpdateDecisionView { + &self.decision + } +} + +impl UserSageAppPendingUpdateDecisionView { + pub fn from_pending_update( + active_grants: &SageGrantedPermissions, + pending_permissions: &SageRequestedPermissions, + ) -> Self { + let required_user_grantable_capabilities = pending_permissions + .capabilities() + .required() + .copied() + .filter(|capability| { + get_user_capability_definition(*capability) + .flags() + .user_grantable() + }) + .filter(|capability| !active_grants.has_capability(*capability)) + .collect::>(); + + let required_network_whitelist = pending_permissions + .network() + .whitelist() + .required() + .filter(|entry| !active_grants.network().whitelist().contains(*entry)) + .cloned() + .collect::>(); + + let required_network_whitelist_by_network = pending_permissions + .network() + .whitelist_by_network() + .iter() + .filter_map(|(network_id, requested_whitelist)| { + let granted_entries = active_grants + .network() + .whitelist_by_network() + .get(network_id); + + let missing = requested_whitelist + .required() + .filter(|entry| { + !granted_entries.is_some_and(|entries| entries.contains(*entry)) + }) + .cloned() + .collect::>(); + + if missing.is_empty() { + None + } else { + Some((network_id.clone(), missing)) + } + }) + .collect::>(); + + if required_user_grantable_capabilities.is_empty() + && required_network_whitelist.is_empty() + && required_network_whitelist_by_network.is_empty() + { + Self::Apply + } else { + Self::Review(UserSageAppPendingUpdateDecisionReviewView { + required_user_grantable_capabilities, + required_network_whitelist, + required_network_whitelist_by_network, + }) + } + } + + pub fn is_review(&self) -> bool { + matches!(self, Self::Review { .. }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::{ + SageAppManifestFile, SageAppManifestSageVersion, SageAppManifestVersion, + SageAppPackageManifestParts, SageNetworkWhitelistEntry, SageRequestedCapabilities, + SageRequestedNetworkPermissions, SageRequestedNetworkWhitelist, + }; + + fn entry(scheme: &str, host: &str) -> SageNetworkWhitelistEntry { + SageNetworkWhitelistEntry::new(scheme, host).unwrap() + } + + fn manifest(permissions: SageRequestedPermissions) -> SageAppPackageManifest { + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: SageAppManifestVersion(0), + name: "test app".to_string(), + icon: None, + sage_version: SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + version: "1.0.0".to_string(), + permissions, + files: vec![SageAppManifestFile::new("index.html", "a".repeat(64), 1).unwrap()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap() + } + + #[test] + fn pending_update_decision_reviews_new_required_network_specific_entries() { + let old_permissions = SageRequestedPermissions::empty(); + let active_grants = + SageGrantedPermissions::new(&old_permissions, [], [], BTreeMap::new()).unwrap(); + let required_entry = entry("https", "mainnet-required.example.com"); + let pending_permissions = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [], + [], + [( + "mainnet".to_string(), + SageRequestedNetworkWhitelist::new([required_entry.clone()], []), + )], + ) + .unwrap(), + SageRequestedCapabilities::empty(), + ) + .unwrap(); + + let pending = UserSageAppPendingUpdate::new( + SageAppUrl::parse("https://example.com/app/manifest.json").unwrap(), + "manifest-hash".to_string(), + manifest(pending_permissions), + ); + + let view = UserSageAppPendingUpdateView::from_pending_update(&pending, &active_grants); + + let UserSageAppPendingUpdateDecisionView::Review(review) = view.decision() else { + panic!("expected pending update to require review"); + }; + + assert!(review.required_user_grantable_capabilities.is_empty()); + assert!(review.required_network_whitelist.is_empty()); + assert_eq!( + review + .required_network_whitelist_by_network + .get("mainnet") + .unwrap(), + &vec![required_entry] + ); + } +} diff --git a/crates/sage-apps/src/types/app/view/snapshot.rs b/crates/sage-apps/src/types/app/view/snapshot.rs new file mode 100644 index 000000000..11943be6d --- /dev/null +++ b/crates/sage-apps/src/types/app/view/snapshot.rs @@ -0,0 +1,18 @@ +use serde::Serialize; +use specta::Type; + +use crate::{SageAppPackageManifest, SageAppSnapshot}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SageAppSnapshotView { + manifest: SageAppPackageManifest, +} + +impl From<&SageAppSnapshot> for SageAppSnapshotView { + fn from(value: &SageAppSnapshot) -> Self { + Self { + manifest: value.manifest().clone(), + } + } +} diff --git a/crates/sage-apps/src/types/app/view/system_apps.rs b/crates/sage-apps/src/types/app/view/system_apps.rs new file mode 100644 index 000000000..3724605d0 --- /dev/null +++ b/crates/sage-apps/src/types/app/view/system_apps.rs @@ -0,0 +1,20 @@ +use serde::Serialize; +use specta::Type; + +use crate::{SageAppCommonView, SageGrantedSystemPermissionsView, SystemSageApp}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SystemSageAppView { + common: SageAppCommonView, + system_granted_permissions: SageGrantedSystemPermissionsView, +} + +impl From<&SystemSageApp> for SystemSageAppView { + fn from(value: &SystemSageApp) -> Self { + Self { + common: value.common().into(), + system_granted_permissions: value.system_granted_permissions().into(), + } + } +} diff --git a/crates/sage-apps/src/types/app/view/user_apps.rs b/crates/sage-apps/src/types/app/view/user_apps.rs new file mode 100644 index 000000000..31427861f --- /dev/null +++ b/crates/sage-apps/src/types/app/view/user_apps.rs @@ -0,0 +1,80 @@ +use serde::Serialize; +use specta::Type; + +use crate::{ + CorruptedInstalledSageApp, ListedSageApp, SageApp, SageAppCommonView, SharedSageApp, + SystemSageAppView, UserSageApp, UserSageAppPendingUpdateView, UserSageAppSource, +}; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[allow(clippy::large_enum_variant)] +pub enum ListedSageAppView { + User(UserSageAppView), + System(SystemSageAppView), + Corrupted(CorruptedInstalledSageApp), +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SageAppView { + System(SystemSageAppView), + User(UserSageAppView), +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UserSageAppView { + common: SageAppCommonView, + source: UserSageAppSource, + + #[serde(skip_serializing_if = "Option::is_none")] + pending_update: Option, +} + +impl From<&SharedSageApp> for SageAppView { + fn from(app: &SharedSageApp) -> Self { + app.with(|app| match app { + SageApp::User(app) => SageAppView::User(app.into()), + SageApp::System(app) => SageAppView::System(app.into()), + }) + } +} + +impl From for SageAppView { + fn from(app: SharedSageApp) -> Self { + (&app).into() + } +} + +impl From<&UserSageApp> for UserSageAppView { + fn from(app: &UserSageApp) -> Self { + Self { + common: app.common().into(), + source: app.source().clone(), + pending_update: app.pending_update().map(|pending| { + UserSageAppPendingUpdateView::from_pending_update( + pending, + app.common().granted_permissions(), + ) + }), + } + } +} + +impl From for UserSageAppView { + fn from(app: UserSageApp) -> Self { + (&app).into() + } +} + +impl From<&ListedSageApp> for ListedSageAppView { + fn from(value: &ListedSageApp) -> Self { + match value { + ListedSageApp::User(app) => ListedSageAppView::User(app.into()), + ListedSageApp::System(app) => ListedSageAppView::System(app.into()), + ListedSageApp::Corrupted(app) => ListedSageAppView::Corrupted(app.clone()), + } + } +} diff --git a/crates/sage-apps/src/types/app/wallet_scope.rs b/crates/sage-apps/src/types/app/wallet_scope.rs new file mode 100644 index 000000000..79f992941 --- /dev/null +++ b/crates/sage-apps/src/types/app/wallet_scope.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SageAppWalletScope { + AllWallets, + SelectedWallets { fingerprints: Vec }, +} + +impl Default for SageAppWalletScope { + fn default() -> Self { + Self::SelectedWallets { + fingerprints: vec![], + } + } +} diff --git a/crates/sage-apps/src/types/invariants.rs b/crates/sage-apps/src/types/invariants.rs new file mode 100644 index 000000000..76dc4fbb5 --- /dev/null +++ b/crates/sage-apps/src/types/invariants.rs @@ -0,0 +1,9 @@ +mod app; +mod manifest; +mod permission; +mod url; + +pub(crate) use app::*; +pub(crate) use manifest::*; +pub(crate) use permission::*; +pub(crate) use url::*; diff --git a/crates/sage-apps/src/types/invariants/app.rs b/crates/sage-apps/src/types/invariants/app.rs new file mode 100644 index 000000000..eb7062671 --- /dev/null +++ b/crates/sage-apps/src/types/invariants/app.rs @@ -0,0 +1,29 @@ +use std::path::PathBuf; + +use crate::SageAppSnapshot; + +pub fn validate_snapshot_entry_and_icon_exist( + snapshot: &SageAppSnapshot, + entry_file: &str, + icon_file: Option<&str>, + label: &str, +) -> anyhow::Result<()> { + let entry_file = snapshot.file_path(entry_file); + + if !entry_file.is_file() { + anyhow::bail!( + "{label} entry file does not exist: {}", + entry_file.display() + ); + } + + if let Some(icon_file) = icon_file { + let icon_file: PathBuf = snapshot.file_path(icon_file); + + if !icon_file.is_file() { + anyhow::bail!("{label} icon file does not exist: {}", icon_file.display()); + } + } + + Ok(()) +} diff --git a/crates/sage-apps/src/types/invariants/manifest.rs b/crates/sage-apps/src/types/invariants/manifest.rs new file mode 100644 index 000000000..945ad06bd --- /dev/null +++ b/crates/sage-apps/src/types/invariants/manifest.rs @@ -0,0 +1,246 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, anyhow}; + +use crate::{SageAppManifestFile, bytes_sha256_hex, normalized_optional_string}; + +pub const MAX_APP_FILE_COUNT: usize = 2000; +pub const MAX_APP_TOTAL_SIZE_BYTES: u64 = 50 * 1024 * 1024; +pub const MAX_APP_PATH_LENGTH: usize = 512; + +pub fn normalize_optional_manifest_path( + path: Option, + label: &str, +) -> anyhow::Result> { + let path = normalized_optional_string(path); + + if let Some(path) = &path { + validate_manifest_file_path(path).map_err(|err| anyhow!("{label} is invalid: {err}"))?; + } + + Ok(path) +} + +pub fn validate_manifest_file_path(path: &str) -> anyhow::Result<()> { + if path.is_empty() { + anyhow::bail!("manifest file path cannot be empty"); + } + + if path.len() > MAX_APP_PATH_LENGTH { + anyhow::bail!("manifest file path exceeds max length {MAX_APP_PATH_LENGTH}: {path}"); + } + + if path.starts_with('/') || path.starts_with('\\') { + anyhow::bail!("manifest file path must be relative: {path}"); + } + + if path.contains('\\') { + anyhow::bail!("manifest file path must use forward slashes: {path}"); + } + + if path + .split('/') + .any(|part| part == "." || part == ".." || part.is_empty()) + { + anyhow::bail!("manifest file path is invalid: {path}"); + } + + Ok(()) +} + +pub fn validate_sha256_hex(value: &str) -> anyhow::Result<()> { + if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) { + anyhow::bail!("invalid sha256 hex: {value}"); + } + + Ok(()) +} + +pub fn validate_manifest_files(files: &[SageAppManifestFile]) -> anyhow::Result { + if files.is_empty() { + anyhow::bail!("manifest files cannot be empty"); + } + + if files.len() > MAX_APP_FILE_COUNT { + anyhow::bail!( + "manifest file count {} exceeds limit {}", + files.len(), + MAX_APP_FILE_COUNT + ); + } + + let mut seen = std::collections::BTreeSet::new(); + let mut total: u64 = 0; + + for file in files { + validate_manifest_file_path(file.path())?; + validate_sha256_hex(file.sha256())?; + + if !seen.insert(file.path().to_string()) { + anyhow::bail!("duplicate manifest file path: {}", file.path()); + } + + total = total + .checked_add(file.size()) + .ok_or_else(|| anyhow!("manifest total size overflow"))?; + } + + if total > MAX_APP_TOTAL_SIZE_BYTES { + anyhow::bail!("manifest total size {total} exceeds limit {MAX_APP_TOTAL_SIZE_BYTES}"); + } + + Ok(total) +} + +pub fn validate_declared_manifest_asset_exists( + path: Option<&str>, + files: &[SageAppManifestFile], + label: &str, +) -> anyhow::Result<()> { + let Some(path) = path else { + return Ok(()); + }; + + if !files.iter().any(|file| file.path() == path) { + anyhow::bail!("manifest {label} file is not listed in files: {path}"); + } + + Ok(()) +} + +pub fn validate_package_files_match_manifest( + package_root: &Path, + files: &[SageAppManifestFile], +) -> anyhow::Result<()> { + for file in files { + let relative_path = file.path(); + let path = package_root.join(relative_path); + + if !path.is_file() { + anyhow::bail!("manifest file missing from package: {relative_path}"); + } + + let bytes = fs::read(&path) + .with_context(|| format!("failed to read package file {}", path.display()))?; + + let actual_hash = bytes_sha256_hex(&bytes); + if actual_hash != file.sha256() { + anyhow::bail!("sha256 mismatch for {relative_path}"); + } + + let actual_size = u64::try_from(bytes.len()).context("file too large")?; + if actual_size != file.size() { + anyhow::bail!("size mismatch for {relative_path}"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_manifest_file(path: &str, size: u64) -> SageAppManifestFile { + SageAppManifestFile::new(path.to_string(), "a".repeat(64), size).unwrap() + } + + #[test] + fn validate_manifest_file_path_accepts_normal_relative_path() { + validate_manifest_file_path("dist/index.html").unwrap(); + } + + #[test] + fn validate_manifest_file_path_rejects_absolute_path() { + assert!(validate_manifest_file_path("/etc/passwd").is_err()); + } + + #[test] + fn validate_manifest_file_path_rejects_parent_traversal() { + assert!(validate_manifest_file_path("../secret.txt").is_err()); + } + + #[test] + fn validate_manifest_file_path_rejects_current_dir_segment() { + assert!(validate_manifest_file_path("./index.html").is_err()); + assert!(validate_manifest_file_path("dist/./index.html").is_err()); + } + + #[test] + fn validate_manifest_file_path_rejects_empty_segment() { + assert!(validate_manifest_file_path("dist//index.html").is_err()); + } + + #[test] + fn validate_manifest_file_path_rejects_backslashes() { + assert!(validate_manifest_file_path(r"dist\index.html").is_err()); + } + + #[test] + fn validate_sha256_hex_accepts_valid_hash() { + validate_sha256_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .unwrap(); + } + + #[test] + fn validate_sha256_hex_rejects_invalid_hash() { + assert!(validate_sha256_hex("not-a-sha").is_err()); + } + + #[test] + fn validate_manifest_files_rejects_empty_list() { + let err = validate_manifest_files(&[]).unwrap_err(); + assert!(err.to_string().contains("cannot be empty")); + } + + #[test] + fn validate_manifest_files_rejects_duplicate_paths() { + let files = vec![ + sample_manifest_file("dist/index.html", 1), + sample_manifest_file("dist/index.html", 2), + ]; + + let err = validate_manifest_files(&files).unwrap_err(); + assert!(err.to_string().contains("duplicate manifest file path")); + } + + #[test] + fn validate_manifest_files_rejects_invalid_nested_path() { + let err = SageAppManifestFile::new("dist//index.html", "a".repeat(64), 1).unwrap_err(); + assert!(err.to_string().contains("manifest file path is invalid")); + } + + #[test] + fn validate_manifest_files_rejects_file_count_over_limit() { + let files: Vec<_> = (0..=MAX_APP_FILE_COUNT) + .map(|i| sample_manifest_file(&format!("dist/file-{i}.txt"), 1)) + .collect(); + + let err = validate_manifest_files(&files).unwrap_err(); + assert!(err.to_string().contains("exceeds limit")); + } + + #[test] + fn validate_manifest_files_rejects_total_size_over_limit() { + let files = vec![ + sample_manifest_file("dist/a.bin", MAX_APP_TOTAL_SIZE_BYTES), + sample_manifest_file("dist/b.bin", 1), + ]; + + let err = validate_manifest_files(&files).unwrap_err(); + assert!(err.to_string().contains("manifest total size")); + assert!(err.to_string().contains("exceeds limit")); + } + + #[test] + fn validate_manifest_files_returns_total_size_when_valid() { + let files = vec![ + sample_manifest_file("dist/index.html", 100), + sample_manifest_file("dist/icon.png", 23), + ]; + + let total = validate_manifest_files(&files).unwrap(); + assert_eq!(total, 123); + } +} diff --git a/crates/sage-apps/src/types/invariants/permission.rs b/crates/sage-apps/src/types/invariants/permission.rs new file mode 100644 index 000000000..39be9fa13 --- /dev/null +++ b/crates/sage-apps/src/types/invariants/permission.rs @@ -0,0 +1,132 @@ +use std::collections::BTreeSet; + +use crate::{ + CapabilityFlags, SageNetworkWhitelistEntry, SageRequestedCapabilities, UserBridgeCapability, + get_user_capability_definition, +}; + +pub fn validate_permissions_policy( + capabilities: impl IntoIterator, + network: impl IntoIterator, + context: &str, +) -> anyhow::Result<()> { + let capability_flags = capabilities + .into_iter() + .fold(CapabilityFlags::EMPTY, |flags, cap| { + flags.union(get_user_capability_definition(cap).flags()) + }); + + let has_secret_access = capability_flags.accesses_sensitive_secret(); + let has_external_access = + capability_flags.externally_observable() || network.into_iter().next().is_some(); + + if has_secret_access && has_external_access { + anyhow::bail!("{context} cannot include both external access and sensitive secret access"); + } + + Ok(()) +} + +pub fn validate_requested_capabilities_are_requestable( + capabilities: &SageRequestedCapabilities, +) -> anyhow::Result<()> { + for capability in capabilities.required().chain(capabilities.optional()) { + let definition = get_user_capability_definition(*capability); + + if !definition.flags().requestable_by_app() { + anyhow::bail!( + "capability is not requestable by app manifest: {}", + capability.key() + ); + } + } + + Ok(()) +} + +pub fn validate_network_id(network_id: &str) -> anyhow::Result<()> { + if network_id.trim() != network_id { + anyhow::bail!( + "network whitelist network id must not contain leading or trailing whitespace" + ); + } + + if network_id.is_empty() { + anyhow::bail!("network whitelist network id cannot be empty"); + } + + if !matches!(network_id, "mainnet" | "testnet11") { + anyhow::bail!( + "unsupported network whitelist network id: {network_id}; expected mainnet or testnet11" + ); + } + + Ok(()) +} + +pub fn build_user_grantable_capability_set( + requested: &SageRequestedCapabilities, + capabilities: impl IntoIterator, +) -> anyhow::Result> { + let capabilities = capabilities.into_iter().collect::>(); + + validate_user_granted_capabilities(requested, &capabilities)?; + validate_required_user_grantable_capabilities_present(requested, &capabilities)?; + + Ok(capabilities) +} + +pub fn validate_user_granted_capabilities( + requested: &SageRequestedCapabilities, + user_granted: &BTreeSet, +) -> anyhow::Result<()> { + for capability in user_granted { + let definition = get_user_capability_definition(*capability); + let flags = definition.flags(); + + if !flags.user_grantable() { + anyhow::bail!( + "granted capability is not user grantable: {}", + capability.key() + ); + } + + if flags.requestable_by_app() && !requested.is_allowed(*capability) { + anyhow::bail!( + "granted capability not requested in manifest: {}", + capability.key() + ); + } + } + + Ok(()) +} + +pub fn validate_required_user_grantable_capabilities_present( + requested: &SageRequestedCapabilities, + user_granted: &BTreeSet, +) -> anyhow::Result<()> { + for capability in requested.required() { + let definition = get_user_capability_definition(*capability); + + if definition.flags().user_grantable() && !user_granted.contains(capability) { + anyhow::bail!("missing required capability: {}", capability.key()); + } + } + + Ok(()) +} + +pub fn split_required_optional_set( + required: impl IntoIterator, + optional: impl IntoIterator, +) -> (BTreeSet, BTreeSet) { + let required = required.into_iter().collect::>(); + + let optional = optional + .into_iter() + .filter(|item| !required.contains(item)) + .collect::>(); + + (required, optional) +} diff --git a/crates/sage-apps/src/types/invariants/url.rs b/crates/sage-apps/src/types/invariants/url.rs new file mode 100644 index 000000000..937b356f9 --- /dev/null +++ b/crates/sage-apps/src/types/invariants/url.rs @@ -0,0 +1,38 @@ +use anyhow::Result as AnyResult; +use url::Url; + +pub fn normalize_app_url(mut url: Url) -> AnyResult { + validate_app_url(&url)?; + + url.set_query(None); + url.set_fragment(None); + + if !url.path().ends_with('/') { + let path = url.path().trim_end_matches('/'); + url.set_path(&format!("{path}/")); + } + + Ok(url) +} + +pub fn validate_app_url(url: &Url) -> AnyResult<()> { + match url.scheme() { + "https" => {} + "http" if is_localhost(url) => {} + scheme => { + anyhow::bail!( + "unsupported app URL scheme '{scheme}', only https is allowed except http://localhost" + ); + } + } + + if url.host_str().is_none() { + anyhow::bail!("app URL must include a host"); + } + + Ok(()) +} + +fn is_localhost(url: &Url) -> bool { + matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) +} diff --git a/crates/sage-apps/src/types/manifest.rs b/crates/sage-apps/src/types/manifest.rs new file mode 100644 index 000000000..1d6ed8cb4 --- /dev/null +++ b/crates/sage-apps/src/types/manifest.rs @@ -0,0 +1,557 @@ +use std::path::Path; + +use serde::{Deserialize, Deserializer, Serialize}; +use specta::Type; + +use crate::{ + SageAppAuthor, SageAppDonation, SageRequestedPermissions, normalize_optional_manifest_path, + normalized_non_empty_string, validate_declared_manifest_asset_exists, + validate_manifest_file_path, validate_manifest_files, validate_package_files_match_manifest, + validate_sha256_hex, +}; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct SageAppManifestVersion(pub u16); + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageAppManifestSageVersion { + pub min: String, + + #[serde(default)] + pub tested_max: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageAppManifestHeaderV0 { + #[serde(default)] + pub manifest_version: SageAppManifestVersion, + + pub name: String, + + #[serde(default)] + pub icon: Option, + + pub sage_version: SageAppManifestSageVersion, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct SageAppManifestFile { + path: String, + sha256: String, + size: u64, +} + +#[derive(Debug)] +pub struct SageAppPackageManifestParts { + pub manifest_version: SageAppManifestVersion, + pub name: String, + pub icon: Option, + pub sage_version: SageAppManifestSageVersion, + pub version: String, + pub permissions: SageRequestedPermissions, + pub files: Vec, + pub entry: Option, + pub author: Option, + pub donation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[allow(clippy::large_enum_variant)] +pub enum SageAppPackageManifestPreview { + Full { + manifest: SageAppPackageManifest, + }, + Partial { + manifest_header: SageAppManifestHeaderV0, + parse_error: String, + }, +} + +#[derive(Debug, Clone, Serialize, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageAppPackageManifest { + manifest_version: SageAppManifestVersion, + name: String, + icon: Option, + sage_version: SageAppManifestSageVersion, + version: String, + permissions: SageRequestedPermissions, + files: Vec, + total_bytes: u64, + entry: Option, + author: Option, + donation: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawSageAppPackageManifest { + #[serde(default)] + manifest_version: SageAppManifestVersion, + + name: String, + + #[serde(default)] + icon: Option, + + sage_version: SageAppManifestSageVersion, + + version: String, + + #[serde(default)] + permissions: Option, + + #[serde(default)] + files: Vec, + + #[serde(default)] + entry: Option, + + #[serde(default)] + author: Option, + + #[serde(default)] + donation: Option, +} + +impl<'de> Deserialize<'de> for SageAppPackageManifest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = ::deserialize(deserializer)?; + + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: raw.manifest_version, + name: raw.name, + icon: raw.icon, + sage_version: raw.sage_version, + version: raw.version, + permissions: raw + .permissions + .unwrap_or_else(SageRequestedPermissions::empty), + files: raw.files, + entry: raw.entry, + author: raw.author, + donation: raw.donation, + }) + .map_err(serde::de::Error::custom) + } +} + +impl TryFrom for SageAppPackageManifest { + type Error = anyhow::Error; + + fn try_from(value: SageAppPackageManifestParts) -> anyhow::Result { + if value.manifest_version.0 != 0 { + return Err(anyhow::anyhow!( + "unsupported manifestVersion {}", + value.manifest_version.0 + )); + } + + let name = normalized_non_empty_string(value.name, "manifest name")?; + let version = normalized_non_empty_string(value.version, "manifest version")?; + + let sage_version_min = + normalized_non_empty_string(value.sage_version.min, "manifest sageVersion.min")?; + + let sage_version_tested_max = value + .sage_version + .tested_max + .map(|tested_max| { + normalized_non_empty_string(tested_max, "manifest sageVersion.testedMax") + }) + .transpose()?; + + let sage_version = SageAppManifestSageVersion { + min: sage_version_min, + tested_max: sage_version_tested_max, + }; + + let entry = normalize_optional_manifest_path(value.entry, "manifest entry")?; + let icon = normalize_optional_manifest_path(value.icon, "manifest icon")?; + + let author = value + .author + .map(|author| SageAppAuthor::new(author.name(), author.avatar())) + .transpose()?; + + let donation = value + .donation + .map(|donation| SageAppDonation::new(donation.address())) + .transpose()?; + + let total_bytes = validate_manifest_files(&value.files)?; + + validate_declared_manifest_asset_exists(entry.as_deref(), &value.files, "entry")?; + validate_declared_manifest_asset_exists(icon.as_deref(), &value.files, "icon")?; + + Ok(Self { + manifest_version: value.manifest_version, + name, + icon, + sage_version, + version, + permissions: value.permissions, + files: value.files, + total_bytes, + entry, + author, + donation, + }) + } +} + +impl SageAppPackageManifestPreview { + pub fn full_manifest(&self) -> Option<&SageAppPackageManifest> { + match self { + Self::Full { manifest } => Some(manifest), + Self::Partial { .. } => None, + } + } + + pub fn manifest_header(&self) -> SageAppManifestHeaderV0 { + match self { + Self::Full { manifest } => manifest.header_v0(), + Self::Partial { + manifest_header, .. + } => manifest_header.clone(), + } + } + + pub fn parse_error(&self) -> Option<&str> { + match self { + Self::Full { .. } => None, + Self::Partial { parse_error, .. } => Some(parse_error), + } + } +} + +impl SageAppPackageManifestParts { + pub fn v0_defaults() -> (SageAppManifestVersion, SageAppManifestSageVersion) { + ( + SageAppManifestVersion(0), + SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + ) + } +} + +impl SageAppPackageManifest { + pub fn validate_package_files(&self, package_root: &Path) -> anyhow::Result<()> { + validate_package_files_match_manifest(package_root, self.files()) + } + + pub fn manifest_version(&self) -> SageAppManifestVersion { + self.manifest_version + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn sage_version(&self) -> &SageAppManifestSageVersion { + &self.sage_version + } + + pub fn permissions(&self) -> &SageRequestedPermissions { + &self.permissions + } + + pub fn files(&self) -> &[SageAppManifestFile] { + &self.files + } + + pub fn total_bytes(&self) -> u64 { + self.total_bytes + } + + pub fn entry(&self) -> &str { + self.entry.as_deref().unwrap_or("index.html") + } + + pub fn icon(&self) -> Option<&str> { + self.icon.as_deref() + } + + pub fn author(&self) -> Option<&SageAppAuthor> { + self.author.as_ref() + } + + pub fn donation(&self) -> Option<&SageAppDonation> { + self.donation.as_ref() + } + + pub fn header_v0(&self) -> SageAppManifestHeaderV0 { + SageAppManifestHeaderV0 { + manifest_version: self.manifest_version, + name: self.name.clone(), + icon: self.icon.clone(), + sage_version: self.sage_version.clone(), + } + } +} + +impl SageAppManifestFile { + pub fn new( + path: impl Into, + sha256: impl Into, + size: u64, + ) -> anyhow::Result { + let path = normalized_non_empty_string(path, "manifest file path")?; + validate_manifest_file_path(&path)?; + + let sha256 = normalized_non_empty_string(sha256, "manifest file sha256")?; + validate_sha256_hex(&sha256)?; + + Ok(Self { path, sha256, size }) + } + + pub fn path(&self) -> &str { + &self.path + } + + pub fn sha256(&self) -> &str { + &self.sha256 + } + + pub fn size(&self) -> u64 { + self.size + } +} + +pub fn parse_manifest_version_from_value( + value: &serde_json::Value, +) -> anyhow::Result { + let version = value + .get("manifestVersion") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("manifestVersion is missing"))?; + + let version = + u16::try_from(version).map_err(|_| anyhow::anyhow!("manifestVersion is too large"))?; + + Ok(SageAppManifestVersion(version)) +} + +pub fn parse_manifest_header_v0_from_value( + value: serde_json::Value, +) -> anyhow::Result { + let version = parse_manifest_version_from_value(&value)?; + + if version.0 != 0 { + return Err(anyhow::anyhow!( + "unsupported manifest header version {}", + version.0 + )); + } + + serde_json::from_value(value) + .map_err(|err| anyhow::anyhow!("failed to parse manifest v0 header: {err}")) +} + +#[cfg(test)] +mod tests { + use crate::{ + SageAppManifestFile, SageAppManifestSageVersion, SageAppManifestVersion, + SageAppPackageManifest, SageAppPackageManifestParts, SageNetworkWhitelistEntry, + SageRequestedCapabilities, SageRequestedNetworkPermissions, SageRequestedPermissions, + UserBridgeCapability, + }; + + fn sample_manifest_file(path: &str, size: u64) -> SageAppManifestFile { + SageAppManifestFile::new(path.to_string(), "a".repeat(64), size).unwrap() + } + + fn sample_file() -> SageAppManifestFile { + sample_manifest_file("index.html", 123) + } + + fn entry(scheme: &str, host: &str) -> SageNetworkWhitelistEntry { + SageNetworkWhitelistEntry::new(scheme, host).unwrap() + } + + fn requested_permissions() -> SageRequestedPermissions { + SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [entry("https", "required.example.com")], + [entry("wss", "optional.example.com")], + [], + ) + .unwrap(), + SageRequestedCapabilities::new( + [UserBridgeCapability::WalletSendXch], + [ + UserBridgeCapability::StoragePersistentWebview, + UserBridgeCapability::WalletGetSecretKey, + ], + ), + ) + .unwrap() + } + + fn manifest_header_parts() -> (SageAppManifestVersion, SageAppManifestSageVersion) { + ( + SageAppManifestVersion(0), + SageAppManifestSageVersion { + min: "0.0.0".to_string(), + tested_max: None, + }, + ) + } + + fn sample_manifest_with( + entry_file: Option, + icon_file: Option, + ) -> SageAppPackageManifest { + let mut files = vec![sample_manifest_file("index.html", 1)]; + + if let Some(entry_file) = &entry_file + && entry_file != "index.html" + { + files.push(sample_manifest_file(entry_file, 1)); + } + + if let Some(icon_file) = &icon_file { + files.push(sample_manifest_file(icon_file, 1)); + } + + let (manifest_version, sage_version) = manifest_header_parts(); + + SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: "Test App".to_string(), + icon: icon_file, + sage_version, + version: "1.0.0".to_string(), + permissions: requested_permissions(), + files, + entry: entry_file, + author: None, + donation: None, + }) + .unwrap() + } + + #[test] + fn manifest_rejects_blank_name() { + let (manifest_version, sage_version) = manifest_header_parts(); + + let err = SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: " ".to_string(), + icon: Some("icon.png".to_string()), + sage_version, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![sample_file()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap_err(); + + assert!(err.to_string().contains("name cannot be empty")); + } + + #[test] + fn manifest_rejects_blank_version() { + let (manifest_version, sage_version) = manifest_header_parts(); + + let err = SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: "Test".to_string(), + icon: Some("icon.png".to_string()), + sage_version, + version: " ".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![sample_file()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap_err(); + + assert!(err.to_string().contains("version cannot be empty")); + } + + #[test] + fn manifest_rejects_unsupported_manifest_version() { + let (_, sage_version) = manifest_header_parts(); + + let err = SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version: SageAppManifestVersion(1), + name: "Test".to_string(), + icon: None, + sage_version, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![sample_file()], + entry: Some("index.html".to_string()), + author: None, + donation: None, + }) + .unwrap_err(); + + assert!(err.to_string().contains("unsupported manifestVersion 1")); + } + + #[test] + fn manifest_total_size_is_computed() { + let (manifest_version, sage_version) = manifest_header_parts(); + + let manifest = SageAppPackageManifest::try_from(SageAppPackageManifestParts { + manifest_version, + name: "Test App".to_string(), + icon: None, + sage_version, + version: "1.0.0".to_string(), + permissions: SageRequestedPermissions::empty(), + files: vec![sample_manifest_file("dist/index.html", 123)], + entry: Some("dist/index.html".to_string()), + author: None, + donation: None, + }) + .unwrap(); + + assert_eq!(manifest.total_bytes(), 123); + } + + #[test] + fn manifest_entry_file_uses_explicit_entry() { + let manifest = + sample_manifest_with(Some("entry.html".to_string()), Some("icon.svg".to_string())); + + assert_eq!(manifest.entry(), "entry.html"); + } + + #[test] + fn manifest_entry_file_defaults_to_index_html() { + let manifest = sample_manifest_with(None, Some("icon.svg".to_string())); + assert_eq!(manifest.entry(), "index.html"); + } + + #[test] + fn manifest_icon_file_uses_explicit_icon() { + let manifest = + sample_manifest_with(Some("entry.html".to_string()), Some("icon.svg".to_string())); + + assert_eq!(manifest.icon().unwrap(), "icon.svg"); + } + + #[test] + fn manifest_icon_file_defaults_to_none() { + let manifest = sample_manifest_with(Some("entry.html".to_string()), None); + assert_eq!(manifest.icon(), None); + } +} diff --git a/crates/sage-apps/src/types/network.rs b/crates/sage-apps/src/types/network.rs new file mode 100644 index 000000000..924524e09 --- /dev/null +++ b/crates/sage-apps/src/types/network.rs @@ -0,0 +1,211 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Deserializer, Serialize}; +use specta::Type; + +use crate::split_required_optional_set; + +#[derive(Debug, Clone, Serialize, Type, PartialEq, Eq, PartialOrd, Ord)] +pub struct SageNetworkWhitelistEntry { + scheme: String, + host: String, +} + +#[derive(Debug, Clone, Serialize, Type, Default, PartialEq, Eq)] +pub struct SageRequestedNetworkWhitelist { + required: BTreeSet, + optional: BTreeSet, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RawSageNetworkWhitelistEntry { + String(String), + Object { scheme: String, host: String }, +} + +impl<'de> Deserialize<'de> for SageNetworkWhitelistEntry { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match RawSageNetworkWhitelistEntry::deserialize(deserializer)? { + RawSageNetworkWhitelistEntry::String(value) => { + value.parse().map_err(serde::de::Error::custom) + } + RawSageNetworkWhitelistEntry::Object { scheme, host } => { + Self::new(scheme, host).map_err(serde::de::Error::custom) + } + } + } +} + +impl SageNetworkWhitelistEntry { + pub fn new(scheme: impl Into, host: impl Into) -> anyhow::Result { + let scheme = scheme.into().trim().to_ascii_lowercase(); + let host = host.into().trim().to_ascii_lowercase(); + + if !Self::is_allowed_scheme(&scheme) { + anyhow::bail!("invalid scheme '{scheme}', only https and wss allowed"); + } + + if !Self::is_csp_safe_host(&host) { + anyhow::bail!("invalid host in network entry: {scheme}://{host}"); + } + + Ok(Self { scheme, host }) + } + + pub fn scheme(&self) -> &str { + &self.scheme + } + + pub fn host(&self) -> &str { + &self.host + } + + pub fn as_permission_string(&self) -> String { + format!("{}://{}", self.scheme, self.host) + } + + fn is_allowed_scheme(s: &str) -> bool { + matches!(s, "https" | "wss") + } + + fn is_csp_safe_host(host: &str) -> bool { + if host.is_empty() + || !host.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '*' | ':' | '[' | ']') + }) + { + return false; + } + + // A wildcard is only allowed as a single leading label ("*.example.com"). + // A bare "*" (or forms like "*:443" / "a*.example.com") matches every + // host in CSP, which would turn the whitelist into an open connect-src. + if host.contains('*') { + let Some(rest) = host.strip_prefix("*.") else { + return false; + }; + let hostname = rest.split_once(':').map_or(rest, |(hostname, _)| hostname); + + if hostname.is_empty() || rest.contains('*') { + return false; + } + } + + // Require at least one real label character so "*." and "." are rejected. + host.chars().any(char::is_alphanumeric) + } + + #[cfg(test)] + pub fn new_unchecked(scheme: &str, host: &str) -> Self { + Self::new(scheme, host).unwrap() + } +} + +impl std::str::FromStr for SageNetworkWhitelistEntry { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + + let (scheme, host) = value + .split_once("://") + .ok_or_else(|| anyhow::anyhow!("invalid network entry, missing scheme: {value}"))?; + + Self::new(scheme, host) + } +} + +impl SageRequestedNetworkWhitelist { + pub fn new( + required: impl IntoIterator, + optional: impl IntoIterator, + ) -> Self { + let (required, optional) = split_required_optional_set(required, optional); + Self { required, optional } + } + + pub fn empty() -> Self { + Self::new([], []) + } + + pub fn required(&self) -> impl Iterator { + self.required.iter() + } + + pub fn optional(&self) -> impl Iterator { + self.optional.iter() + } + + pub fn is_required(&self, entry: &SageNetworkWhitelistEntry) -> bool { + self.required.contains(entry) + } + + pub fn is_optional(&self, entry: &SageNetworkWhitelistEntry) -> bool { + self.optional.contains(entry) + } + + pub fn is_allowed(&self, entry: &SageNetworkWhitelistEntry) -> bool { + self.is_required(entry) || self.is_optional(entry) + } +} + +#[cfg(test)] +mod tests { + use super::SageNetworkWhitelistEntry; + + #[test] + fn network_entry_accepts_csp_safe_hosts() { + for host in [ + "example.com", + "*.example.com", + "localhost:4173", + "127.0.0.1:4173", + "[::1]:4173", + ] { + SageNetworkWhitelistEntry::new("https", host) + .unwrap_or_else(|err| panic!("expected {host} to be accepted: {err}")); + } + } + + #[test] + fn network_entry_rejects_csp_separators_and_controls() { + for host in [ + "example.com/script.js", + "example.com?x=1", + "example.com#frag", + "example.com; script-src 'unsafe-inline'", + "example.com,https://evil.example", + "example.com\tfoo", + "example.com\nfoo", + "example.com'foo", + "example.com\"foo", + "example.com`foo", + ] { + assert!( + SageNetworkWhitelistEntry::new("https", host).is_err(), + "expected {host:?} to be rejected" + ); + } + } + + #[test] + fn network_entry_rejects_match_all_wildcards() { + for host in [ + "*", + "*:443", + "*.", + "*.:443", + "a*.example.com", + "*.*.example.com", + ] { + assert!( + SageNetworkWhitelistEntry::new("https", host).is_err(), + "expected wildcard host {host:?} to be rejected" + ); + } + } +} diff --git a/crates/sage-apps/src/types/normalizers.rs b/crates/sage-apps/src/types/normalizers.rs new file mode 100644 index 000000000..f526fb228 --- /dev/null +++ b/crates/sage-apps/src/types/normalizers.rs @@ -0,0 +1,18 @@ +pub(crate) fn normalized_non_empty_string( + value: impl Into, + label: &str, +) -> anyhow::Result { + let value = value.into().trim().to_string(); + + if value.is_empty() { + anyhow::bail!("{label} cannot be empty"); + } + + Ok(value) +} + +pub(crate) fn normalized_optional_string(value: Option>) -> Option { + value + .map(|value| value.into().trim().to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/crates/sage-apps/src/types/permissions.rs b/crates/sage-apps/src/types/permissions.rs new file mode 100644 index 000000000..b73ada7a8 --- /dev/null +++ b/crates/sage-apps/src/types/permissions.rs @@ -0,0 +1,7 @@ +mod granted; +mod requested; +#[cfg(test)] +mod tests; + +pub use granted::*; +pub use requested::*; diff --git a/crates/sage-apps/src/types/permissions/granted.rs b/crates/sage-apps/src/types/permissions/granted.rs new file mode 100644 index 000000000..f119b8950 --- /dev/null +++ b/crates/sage-apps/src/types/permissions/granted.rs @@ -0,0 +1,441 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Deserializer, Serialize}; +use specta::Type; + +use super::{SageRequestedNetworkPermissions, SageRequestedPermissions}; +use crate::{ + SageNetworkWhitelistEntry, SharedCapabilitiesExt, SystemBridgeCapability, UserBridgeCapability, + build_user_grantable_capability_set, get_user_capability_definition, + validate_permissions_policy, +}; + +pub type NetworkWhitelistByNetwork = BTreeMap>; + +pub fn network_whitelist_by_network_from_iter(items: I) -> NetworkWhitelistByNetwork +where + I: IntoIterator, + E: IntoIterator, +{ + items + .into_iter() + .map(|(network_id, entries)| (network_id, entries.into_iter().collect::>())) + .collect() +} + +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageGrantedNetworkPermissions { + whitelist: BTreeSet, + whitelist_by_network: NetworkWhitelistByNetwork, +} + +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct SageGrantedPermissions { + capabilities: BTreeSet, + network: SageGrantedNetworkPermissions, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Type)] +pub struct SageGrantedSystemPermissions { + capabilities: Vec, +} + +impl SageGrantedSystemPermissions { + pub fn new(capabilities: impl IntoIterator) -> Self { + Self { + capabilities: capabilities.into_iter().collect(), + } + } + + pub fn capabilities(&self) -> &[SystemBridgeCapability] { + &self.capabilities + } +} + +impl SageGrantedNetworkPermissions { + pub fn new( + requested: &SageRequestedNetworkPermissions, + whitelist: impl IntoIterator, + whitelist_by_network: NetworkWhitelistByNetwork, + ) -> anyhow::Result { + let whitelist = whitelist.into_iter().collect::>(); + + for entry in &whitelist { + if !requested.whitelist().is_allowed(entry) { + anyhow::bail!( + "granted shared network whitelist entry not requested in manifest: {}", + entry.as_permission_string() + ); + } + } + + let mut by_network = BTreeMap::new(); + + for (network_id, entries) in whitelist_by_network { + let network_id = network_id.trim().to_string(); + + if network_id.is_empty() { + anyhow::bail!("granted network whitelist network id cannot be empty"); + } + + let Some(requested_whitelist) = requested.whitelist_by_network().get(&network_id) + else { + anyhow::bail!( + "granted network-specific whitelist entry for unrequested network: {network_id}", + ); + }; + + for entry in &entries { + if !requested_whitelist.is_allowed(entry) { + anyhow::bail!( + "granted network-specific whitelist entry not requested in manifest for {}: {}", + network_id, + entry.as_permission_string() + ); + } + } + + by_network.insert(network_id, entries); + } + + Ok(Self { + whitelist, + whitelist_by_network: by_network, + }) + } + + pub fn whitelist(&self) -> &BTreeSet { + &self.whitelist + } + + pub fn whitelist_iter(&self) -> impl Iterator { + self.whitelist.iter() + } + + pub fn whitelist_by_network(&self) -> &NetworkWhitelistByNetwork { + &self.whitelist_by_network + } + + pub fn effective_whitelist_for_network( + &self, + network_id: &str, + ) -> Vec { + self.whitelist + .iter() + .cloned() + .chain( + self.whitelist_by_network + .get(network_id) + .into_iter() + .flat_map(|entries| entries.iter().cloned()), + ) + .collect::>() + .into_iter() + .collect() + } + + pub fn all_whitelist_entries(&self) -> Vec { + self.whitelist + .iter() + .cloned() + .chain( + self.whitelist_by_network + .values() + .flat_map(|entries| entries.iter().cloned()), + ) + .collect() + } +} + +impl SageGrantedPermissions { + pub fn new( + requested: &SageRequestedPermissions, + capabilities: impl IntoIterator, + network_whitelist: impl IntoIterator, + network_whitelist_by_network: NetworkWhitelistByNetwork, + ) -> anyhow::Result { + Self::new_with_extra_granted_capabilities( + requested, + capabilities, + std::iter::empty(), + network_whitelist, + network_whitelist_by_network, + ) + } + + pub(crate) fn new_with_extra_granted_capabilities( + requested: &SageRequestedPermissions, + capabilities: impl IntoIterator, + extra_granted_capabilities: impl IntoIterator, + network_whitelist: impl IntoIterator, + network_whitelist_by_network: NetworkWhitelistByNetwork, + ) -> anyhow::Result { + let mut capabilities = + build_user_grantable_capability_set(requested.capabilities(), capabilities)?; + + for capability in extra_granted_capabilities { + let definition = get_user_capability_definition(capability); + + if !definition.flags().user_grantable() { + anyhow::bail!( + "extra granted capability is not user grantable: {}", + capability.key() + ); + } + + if definition.flags().requestable_by_app() { + anyhow::bail!( + "extra granted capability must not be app-manifest requestable: {}", + capability.key() + ); + } + + capabilities.insert(capability); + } + + let network = SageGrantedNetworkPermissions::new( + requested.network(), + network_whitelist, + network_whitelist_by_network, + )?; + + let effective_capabilities = requested + .capabilities() + .resolve_effective_grants(capabilities.iter().copied()); + + validate_permissions_policy( + effective_capabilities, + network.all_whitelist_entries(), + "granted permissions", + )?; + + Ok(Self { + capabilities, + network, + }) + } + + pub fn from_requested_and_granted( + requested: &SageRequestedPermissions, + granted: SageGrantedPermissions, + ) -> anyhow::Result { + Self::new( + requested, + granted.capabilities, + granted.network.whitelist, + granted.network.whitelist_by_network, + ) + } + + pub fn with_capability_added( + &self, + requested: &SageRequestedPermissions, + capability: UserBridgeCapability, + ) -> anyhow::Result { + Self::new( + requested, + self.capabilities.iter().copied().chain([capability]), + self.network.whitelist_iter().cloned(), + self.network.whitelist_by_network().clone(), + ) + } + + pub fn with_network_whitelist_entry_added( + &self, + requested: &SageRequestedPermissions, + entry: SageNetworkWhitelistEntry, + ) -> anyhow::Result { + Self::new( + requested, + self.capabilities.iter().copied(), + self.network.whitelist_iter().cloned().chain([entry]), + self.network.whitelist_by_network().clone(), + ) + } + + pub fn with_network_whitelist_entry_for_network_added( + &self, + requested: &SageRequestedPermissions, + network_id: impl Into, + entry: SageNetworkWhitelistEntry, + ) -> anyhow::Result { + let network_id = network_id.into(); + let mut by_network = self.network.whitelist_by_network().clone(); + + by_network.entry(network_id).or_default().insert(entry); + + Self::new( + requested, + self.capabilities.iter().copied(), + self.network.whitelist_iter().cloned(), + by_network, + ) + } + + pub fn capabilities(&self) -> impl Iterator { + self.capabilities.iter() + } + + pub fn has_capability(&self, capability: UserBridgeCapability) -> bool { + self.capabilities.contains(&capability) + } + + pub fn capabilities_vec(&self) -> Vec { + self.capabilities.iter().copied().collect() + } + + pub fn network(&self) -> &SageGrantedNetworkPermissions { + &self.network + } + + pub fn network_whitelist_vec(&self) -> Vec { + self.network.whitelist_iter().cloned().collect() + } + + pub fn network_whitelist_by_network(&self) -> &NetworkWhitelistByNetwork { + self.network.whitelist_by_network() + } + + pub fn shared_capabilities(&self) -> Vec { + self.capabilities().copied().shared() + } + + pub fn for_builtin_requested(requested: &SageRequestedPermissions) -> anyhow::Result { + let required_by_network = network_whitelist_by_network_from_iter( + requested + .network() + .whitelist_by_network() + .iter() + .map(|(network_id, whitelist)| { + ( + network_id.clone(), + whitelist.required().cloned().collect::>(), + ) + }), + ); + + Self::new( + requested, + requested.capabilities().user_grantable(), + requested.network().whitelist().required().cloned(), + required_by_network, + ) + } + + #[cfg(test)] + pub fn new_unchecked( + capabilities: impl IntoIterator, + network_whitelist: impl IntoIterator, + network_whitelist_by_network: NetworkWhitelistByNetwork, + ) -> Self { + Self { + capabilities: capabilities.into_iter().collect(), + network: SageGrantedNetworkPermissions { + whitelist: network_whitelist.into_iter().collect(), + whitelist_by_network: network_whitelist_by_network, + }, + } + } +} + +impl<'de> Deserialize<'de> for SageGrantedPermissions { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct RawSageGrantedPermissions { + #[serde(default)] + capabilities: BTreeSet, + + #[serde(default)] + network: SageGrantedNetworkPermissions, + } + + let raw = RawSageGrantedPermissions::deserialize(deserializer)?; + + validate_permissions_policy( + raw.capabilities.iter().copied(), + raw.network.all_whitelist_entries(), + "granted permissions", + ) + .map_err(serde::de::Error::custom)?; + + Ok(Self { + capabilities: raw.capabilities, + network: raw.network, + }) + } +} + +impl<'de> Deserialize<'de> for SageGrantedNetworkPermissions { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + #[serde(rename_all = "camelCase")] + struct Raw { + #[serde(default)] + whitelist: BTreeSet, + + #[serde(default)] + whitelist_by_network: NetworkWhitelistByNetwork, + } + + let raw = Raw::deserialize(deserializer)?; + + Ok(Self { + whitelist: raw.whitelist, + whitelist_by_network: raw.whitelist_by_network, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + SageNetworkWhitelistEntry, SageRequestedCapabilities, SageRequestedNetworkPermissions, + SageRequestedPermissions, + }; + + #[test] + fn granted_permissions_reject_unrequested_shared_network_whitelist_entry() { + let requested = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [SageNetworkWhitelistEntry::new_unchecked( + "https", + "api.example.com", + )], + [], + [], + ) + .unwrap(), + SageRequestedCapabilities::new( + [UserBridgeCapability::StoragePersistentWebview], + [UserBridgeCapability::WalletSendXch], + ), + ) + .unwrap(); + + let err = SageGrantedPermissions::new( + &requested, + [UserBridgeCapability::StoragePersistentWebview], + [SageNetworkWhitelistEntry::new_unchecked( + "https", + "evil.example.com", + )], + BTreeMap::new(), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("granted shared network whitelist entry not requested") + ); + } +} diff --git a/crates/sage-apps/src/types/permissions/requested.rs b/crates/sage-apps/src/types/permissions/requested.rs new file mode 100644 index 000000000..fc73449f8 --- /dev/null +++ b/crates/sage-apps/src/types/permissions/requested.rs @@ -0,0 +1,261 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Deserializer, Serialize}; +use specta::Type; + +use crate::{ + SageNetworkWhitelistEntry, SageRequestedNetworkWhitelist, UserBridgeCapability, + get_user_capability_definition, split_required_optional_set, validate_network_id, + validate_permissions_policy, validate_requested_capabilities_are_requestable, +}; + +#[derive(Debug, Clone, Serialize, Type, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SageRequestedNetworkPermissions { + whitelist: SageRequestedNetworkWhitelist, + whitelist_by_network: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Type, Default, PartialEq, Eq)] +pub struct SageRequestedCapabilities { + required: BTreeSet, + optional: BTreeSet, +} + +#[derive(Debug, Clone, Serialize, Type, Default, PartialEq, Eq)] +pub struct SageRequestedPermissions { + network: SageRequestedNetworkPermissions, + capabilities: SageRequestedCapabilities, +} + +#[derive(Debug, Deserialize, Default)] +struct RawNetworkWhitelistBucket { + #[serde(default)] + required: Vec, + + #[serde(default)] + optional: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct RawRequestedNetworkPermissions { + #[serde(default)] + whitelist: RawNetworkWhitelistBucket, + + #[serde(default)] + whitelist_by_network: BTreeMap, +} + +#[derive(Debug, Deserialize, Default)] +struct RawRequestedPermissions { + #[serde(default)] + network: RawRequestedNetworkPermissions, + + #[serde(default)] + capabilities: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct RawRequestedCapabilities { + #[serde(default)] + required: Vec, + + #[serde(default)] + optional: Vec, +} + +impl<'de> Deserialize<'de> for SageRequestedCapabilities { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawRequestedCapabilities::deserialize(deserializer)?; + Ok(Self::new(raw.required, raw.optional)) + } +} + +impl<'de> Deserialize<'de> for SageRequestedPermissions { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawRequestedPermissions::deserialize(deserializer)?; + + let whitelist_by_network = + raw.network + .whitelist_by_network + .into_iter() + .map(|(network_id, bucket)| { + ( + network_id, + SageRequestedNetworkWhitelist::new(bucket.required, bucket.optional), + ) + }); + + let network = SageRequestedNetworkPermissions::new( + raw.network.whitelist.required, + raw.network.whitelist.optional, + whitelist_by_network, + ) + .map_err(serde::de::Error::custom)?; + + SageRequestedPermissions::new(network, raw.capabilities.unwrap_or_default()) + .map_err(serde::de::Error::custom) + } +} + +impl SageRequestedPermissions { + pub fn new( + network: SageRequestedNetworkPermissions, + capabilities: SageRequestedCapabilities, + ) -> anyhow::Result { + validate_requested_capabilities_are_requestable(&capabilities)?; + + let required_network = network + .whitelist() + .required() + .cloned() + .chain( + network + .whitelist_by_network() + .values() + .flat_map(|whitelist| whitelist.required().cloned()), + ) + .collect::>(); + + validate_permissions_policy( + capabilities.required().copied(), + required_network, + "required requested permissions", + )?; + + Ok(Self { + network, + capabilities, + }) + } + + pub fn empty() -> Self { + Self { + network: SageRequestedNetworkPermissions::empty(), + capabilities: SageRequestedCapabilities::empty(), + } + } + + pub fn network(&self) -> &SageRequestedNetworkPermissions { + &self.network + } + + pub fn capabilities(&self) -> &SageRequestedCapabilities { + &self.capabilities + } +} + +impl SageRequestedCapabilities { + pub fn new( + required: impl IntoIterator, + optional: impl IntoIterator, + ) -> Self { + let (required, optional) = split_required_optional_set(required, optional); + Self { required, optional } + } + + pub fn all(&self) -> impl Iterator { + self.required().chain(self.optional()) + } + + pub fn contains(&self, capability: UserBridgeCapability) -> bool { + self.is_allowed(capability) + } + + pub fn empty() -> Self { + Self::new([], []) + } + + pub fn required(&self) -> impl Iterator { + self.required.iter() + } + + pub fn optional(&self) -> impl Iterator { + self.optional.iter() + } + + pub fn is_required(&self, cap: UserBridgeCapability) -> bool { + self.required.contains(&cap) + } + + pub fn is_optional(&self, cap: UserBridgeCapability) -> bool { + self.optional.contains(&cap) + } + + pub fn is_allowed(&self, cap: UserBridgeCapability) -> bool { + self.is_required(cap) || self.is_optional(cap) + } + + pub fn user_grantable(&self) -> Vec { + self.required() + .chain(self.optional()) + .copied() + .filter(|cap| { + get_user_capability_definition(*cap) + .flags() + .user_grantable() + }) + .collect() + } + + pub fn resolve_effective_grants( + &self, + granted: impl IntoIterator, + ) -> Vec { + let mut effective = granted.into_iter().collect::>(); + + for capability in self.required() { + let definition = get_user_capability_definition(*capability); + + if !definition.flags().user_grantable() { + effective.insert(*capability); + } + } + + effective.into_iter().collect() + } +} + +impl SageRequestedNetworkPermissions { + pub fn new( + required: impl IntoIterator, + optional: impl IntoIterator, + whitelist_by_network: impl IntoIterator, + ) -> anyhow::Result { + let whitelist = SageRequestedNetworkWhitelist::new(required, optional); + + let mut by_network = BTreeMap::new(); + + for (network_id, whitelist) in whitelist_by_network { + let network_id = network_id.trim().to_string(); + + validate_network_id(&network_id)?; + + by_network.insert(network_id, whitelist); + } + + Ok(Self { + whitelist, + whitelist_by_network: by_network, + }) + } + + pub fn empty() -> Self { + Self::new([], [], []).expect("empty requested network permissions should be valid") + } + + pub fn whitelist(&self) -> &SageRequestedNetworkWhitelist { + &self.whitelist + } + + pub fn whitelist_by_network(&self) -> &BTreeMap { + &self.whitelist_by_network + } +} diff --git a/crates/sage-apps/src/types/permissions/tests.rs b/crates/sage-apps/src/types/permissions/tests.rs new file mode 100644 index 000000000..7b4795657 --- /dev/null +++ b/crates/sage-apps/src/types/permissions/tests.rs @@ -0,0 +1,232 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use super::*; +use crate::{SageNetworkWhitelistEntry, SageRequestedNetworkWhitelist, UserBridgeCapability}; + +fn network_entry(scheme: &str, host: &str) -> SageNetworkWhitelistEntry { + SageNetworkWhitelistEntry::new(scheme, host).unwrap() +} + +fn requested_permissions() -> SageRequestedPermissions { + SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [network_entry("https", "required.example.com")], + [network_entry("wss", "optional.example.com")], + [( + "mainnet".to_string(), + SageRequestedNetworkWhitelist::new( + [network_entry("https", "mainnet-required.example.com")], + [network_entry("https", "mainnet-optional.example.com")], + ), + )], + ) + .unwrap(), + SageRequestedCapabilities::new( + [], + [ + UserBridgeCapability::WalletSendXch, + UserBridgeCapability::StoragePersistentWebview, + ], + ), + ) + .unwrap() +} + +#[test] +fn granted_permissions_reject_unrequested_capability() { + let requested = requested_permissions(); + + let err = SageGrantedPermissions::new( + &requested, + [UserBridgeCapability::WalletGetCoins], + [], + BTreeMap::new(), + ) + .unwrap_err(); + + assert!(err.to_string().contains("not requested in manifest")); + assert!( + err.to_string() + .contains(UserBridgeCapability::WalletGetCoins.key()) + ); +} + +#[test] +fn with_capability_added_rejects_unrequested_capability() { + let requested = requested_permissions(); + let granted = SageGrantedPermissions::new(&requested, [], [], BTreeMap::new()).unwrap(); + + let err = granted + .with_capability_added(&requested, UserBridgeCapability::WalletGetCoins) + .unwrap_err(); + + assert!(err.to_string().contains("not requested in manifest")); + assert!( + err.to_string() + .contains(UserBridgeCapability::WalletGetCoins.key()) + ); +} + +#[test] +fn with_network_whitelist_entry_added_rejects_unrequested_entry() { + let requested = requested_permissions(); + let granted = SageGrantedPermissions::new(&requested, [], [], BTreeMap::new()).unwrap(); + + let entry = network_entry("https", "evil.example.com"); + + let err = granted + .with_network_whitelist_entry_added(&requested, entry) + .unwrap_err(); + + assert!( + err.to_string() + .contains("granted shared network whitelist entry not requested in manifest") + ); +} + +#[test] +fn with_network_whitelist_entry_for_network_added_rejects_unrequested_network() { + let requested = requested_permissions(); + let granted = SageGrantedPermissions::new(&requested, [], [], BTreeMap::new()).unwrap(); + + let err = granted + .with_network_whitelist_entry_for_network_added( + &requested, + "testnet11", + network_entry("https", "mainnet-optional.example.com"), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("granted network-specific whitelist entry for unrequested network") + ); +} + +#[test] +fn with_network_whitelist_entry_for_network_added_rejects_unrequested_entry() { + let requested = requested_permissions(); + let granted = SageGrantedPermissions::new(&requested, [], [], BTreeMap::new()).unwrap(); + + let err = granted + .with_network_whitelist_entry_for_network_added( + &requested, + "mainnet", + network_entry("https", "evil.example.com"), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("granted network-specific whitelist entry not requested in manifest") + ); +} + +#[test] +fn effective_whitelist_for_network_merges_shared_and_network_specific_entries() { + let requested = requested_permissions(); + + let granted = SageGrantedPermissions::new( + &requested, + [], + [network_entry("https", "required.example.com")], + BTreeMap::from([( + "mainnet".to_string(), + BTreeSet::from([network_entry("https", "mainnet-required.example.com")]), + )]), + ) + .unwrap(); + + let effective = granted + .network() + .effective_whitelist_for_network("mainnet") + .into_iter() + .collect::>(); + + let expected = BTreeSet::from([ + network_entry("https", "required.example.com"), + network_entry("https", "mainnet-required.example.com"), + ]); + + assert_eq!(effective, expected); +} + +#[test] +fn requested_permissions_reject_secret_capability_with_required_network() { + let err = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [network_entry("https", "required.example.com")], + [], + [], + ) + .unwrap(), + SageRequestedCapabilities::new([UserBridgeCapability::WalletGetSecretKey], []), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("cannot include both external access and sensitive secret access"), + "unexpected error: {err}" + ); +} + +#[test] +fn granted_permissions_reject_secret_capability_with_optional_network_grant() { + let requested = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::new( + [], + [network_entry("https", "optional.example.com")], + [], + ) + .unwrap(), + SageRequestedCapabilities::new([], [UserBridgeCapability::WalletGetSecretKey]), + ) + .unwrap(); + + let err = SageGrantedPermissions::new( + &requested, + [UserBridgeCapability::WalletGetSecretKey], + [network_entry("https", "optional.example.com")], + BTreeMap::new(), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("cannot include both external access and sensitive secret access"), + "unexpected error: {err}" + ); +} + +#[test] +fn granted_permissions_reject_secret_capability_with_external_capability() { + let requested = SageRequestedPermissions::new( + SageRequestedNetworkPermissions::empty(), + SageRequestedCapabilities::new( + [], + [ + UserBridgeCapability::WalletGetSecretKey, + UserBridgeCapability::WalletSendXch, + ], + ), + ) + .unwrap(); + + let err = SageGrantedPermissions::new( + &requested, + [ + UserBridgeCapability::WalletGetSecretKey, + UserBridgeCapability::WalletSendXch, + ], + [], + BTreeMap::new(), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("cannot include both external access and sensitive secret access"), + "unexpected error: {err}" + ); +} diff --git a/crates/sage-apps/src/types/storage.rs b/crates/sage-apps/src/types/storage.rs new file mode 100644 index 000000000..cac910203 --- /dev/null +++ b/crates/sage-apps/src/types/storage.rs @@ -0,0 +1,93 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use specta::Type; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SageAppStorage { + AppleDataStore { + identifier_hex: String, + }, + WindowsProfile { + #[serde(deserialize_with = "deserialize_profile_directory_name")] + directory_name: String, + }, + Unmanaged, +} + +/// Validates a persisted Windows profile directory name before it is joined +/// onto Sage's app-data directory or passed to storage cleanup. +pub(crate) fn validate_profile_directory_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("profile directory name must not be empty".to_string()); + } + + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) + { + return Err(format!("invalid profile directory name: {name}")); + } + + Ok(()) +} + +fn deserialize_profile_directory_name<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let name = String::deserialize(deserializer)?; + validate_profile_directory_name(&name).map_err(serde::de::Error::custom)?; + Ok(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn windows_profile_json(directory_name: &str) -> String { + let serialized = serde_json::to_string(&SageAppStorage::WindowsProfile { + directory_name: "PLACEHOLDER".to_string(), + }) + .unwrap(); + let encoded_name = serde_json::to_string(directory_name).unwrap(); + + serialized.replace("\"PLACEHOLDER\"", &encoded_name) + } + + #[test] + fn windows_profile_accepts_expected_directory_names() { + for name in [ + "profile-8c8532e1-14d8-4d5f-b652-4ff29fc35a19", + "builtin-profile-__sage_test_storage_isolation_persistent", + ] { + let storage: SageAppStorage = + serde_json::from_str(&windows_profile_json(name)).unwrap(); + assert_eq!( + storage, + SageAppStorage::WindowsProfile { + directory_name: name.to_string() + } + ); + } + } + + #[test] + fn windows_profile_rejects_traversal_and_separator_directory_names() { + for name in [ + "..", + "../evil", + "..\\evil", + "C:\\evil", + "profiles/../..", + "a/b", + ".", + "profile-abc.def", + "", + ] { + assert!( + serde_json::from_str::(&windows_profile_json(name)).is_err(), + "expected directory name {name:?} to be rejected" + ); + } + } +} diff --git a/crates/sage-apps/src/types/url.rs b/crates/sage-apps/src/types/url.rs new file mode 100644 index 000000000..2ad1d8dfd --- /dev/null +++ b/crates/sage-apps/src/types/url.rs @@ -0,0 +1,146 @@ +use std::fmt; + +use anyhow::{Context, Result as AnyResult}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use specta::Type; +use url::Url; + +use crate::{normalize_app_url, slugify_app_name}; + +pub const MANIFEST_FILE_NAME: &str = "sage-manifest.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Type)] +pub struct SageAppUrl(Url); + +#[derive(Debug, Clone, PartialEq, Eq, Type)] +pub struct SageAppManifestUrl(Url); + +impl SageAppUrl { + pub fn parse(value: impl AsRef) -> AnyResult { + let value = value.as_ref(); + let url = Url::parse(value).with_context(|| format!("invalid app url: {value}"))?; + Ok(Self(normalize_app_url(url)?)) + } + + pub fn manifest_url(&self) -> SageAppManifestUrl { + SageAppManifestUrl::derive_from_app_url(self) + } + + pub fn slug(&self) -> String { + let host = self.0.host_str().unwrap_or("app"); + + slugify_app_name(host) + } + + pub fn join(&self, relative_path: &str) -> AnyResult { + Ok(self.0.join(relative_path)?.to_string()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn as_bytes(&self) -> &[u8] { + self.as_str().as_bytes() + } + + pub fn into_string(self) -> String { + self.0.to_string() + } +} + +impl SageAppManifestUrl { + fn derive_from_app_url(app_url: &SageAppUrl) -> Self { + let url = app_url + .0 + .join(MANIFEST_FILE_NAME) + .expect("valid app url + static manifest path must always join"); + + Self(url) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn as_bytes(&self) -> &[u8] { + self.as_str().as_bytes() + } +} + +impl Serialize for SageAppUrl { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for SageAppUrl { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::parse(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for SageAppUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Display for SageAppManifestUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_url_keeps_https_and_adds_trailing_slash() { + let out = SageAppUrl::parse("https://example.com/app").unwrap(); + assert_eq!(out.as_str(), "https://example.com/app/"); + } + + #[test] + fn app_url_strips_query_and_fragment() { + let out = SageAppUrl::parse("https://example.com/app?x=1#frag").unwrap(); + assert_eq!(out.as_str(), "https://example.com/app/"); + } + + #[test] + fn app_url_allows_localhost_http() { + let out = SageAppUrl::parse("http://localhost:4173").unwrap(); + assert_eq!(out.as_str(), "http://localhost:4173/"); + } + + #[test] + fn app_url_allows_loopback_http() { + let out = SageAppUrl::parse("http://127.0.0.1:4173").unwrap(); + assert_eq!(out.as_str(), "http://127.0.0.1:4173/"); + } + + #[test] + fn app_url_rejects_non_local_http() { + let err = SageAppUrl::parse("http://example.com/app") + .unwrap_err() + .to_string(); + + assert!(err.contains("requires HTTPS") || err.contains("only https")); + } + + #[test] + fn app_url_rejects_unsupported_scheme() { + let err = SageAppUrl::parse("ftp://example.com/app") + .unwrap_err() + .to_string(); + + assert!(err.contains("unsupported app URL scheme") || err.contains("only https")); + } +} diff --git a/crates/sage-apps/src/utils.rs b/crates/sage-apps/src/utils.rs new file mode 100644 index 000000000..c73475b2f --- /dev/null +++ b/crates/sage-apps/src/utils.rs @@ -0,0 +1,74 @@ +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; + +static BUILTIN_APPS_ROOT: OnceLock = OnceLock::new(); + +pub fn unix_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time is before UNIX_EPOCH") + .as_millis() as i64 +} + +pub fn bytes_sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +pub fn slugify_app_name(name: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + last_dash = false; + } else if !last_dash { + out.push('-'); + last_dash = true; + } + } + + let out = out.trim_matches('-').to_string(); + + if out.is_empty() { + "app".to_string() + } else { + out + } +} + +pub fn set_builtin_apps_root(path: impl Into) { + let path = path.into(); + + if BUILTIN_APPS_ROOT.set(path.clone()).is_err() { + tracing::warn!( + "builtin apps root was already initialized; keeping {}", + builtin_apps_root().display() + ); + } +} + +pub fn builtin_apps_root() -> PathBuf { + if let Some(path) = option_env!("SAGE_BUILTIN_APPS_DIST") { + return PathBuf::from(path); + } + + if let Some(path) = BUILTIN_APPS_ROOT.get() { + return path.clone(); + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + manifest_dir + .parent() + .and_then(|path| path.parent()) + .expect("crates/sage-apps should have workspace root above it") + .join("builtin-apps") + .join("build") + .join("dist") +} diff --git a/crates/sage-assets/src/error.rs b/crates/sage-assets/src/error.rs index 33f68870a..fb96d9c8d 100644 --- a/crates/sage-assets/src/error.rs +++ b/crates/sage-assets/src/error.rs @@ -22,4 +22,7 @@ pub enum UriError { #[error("Failed to create thumbnail: {0}")] Thumbnail(#[from] ThumbnailError), + + #[error("Invalid XCH/USD price response")] + InvalidPriceResponse, } diff --git a/crates/sage-assets/src/lib.rs b/crates/sage-assets/src/lib.rs index c4eb6e57d..835506de2 100644 --- a/crates/sage-assets/src/lib.rs +++ b/crates/sage-assets/src/lib.rs @@ -1,7 +1,9 @@ mod cats; mod error; mod nfts; +mod price; pub use cats::*; pub use error::*; pub use nfts::*; +pub use price::*; diff --git a/crates/sage-assets/src/price.rs b/crates/sage-assets/src/price.rs new file mode 100644 index 000000000..4ee128521 --- /dev/null +++ b/crates/sage-assets/src/price.rs @@ -0,0 +1,53 @@ +use std::time::Duration; + +use reqwest::Client; +use serde::Deserialize; + +use crate::UriError; + +#[derive(Debug, Clone, Copy)] +pub struct XchUsdPrice { + pub usd: f64, +} + +impl XchUsdPrice { + pub async fn fetch() -> Result { + let response = price_client()? + .get("https://api.coingecko.com/api/v3/simple/price") + .query(&[("ids", "chia"), ("vs_currencies", "usd")]) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let usd = response + .chia + .usd + .filter(|price| price.is_finite() && *price > 0.0) + .ok_or(UriError::InvalidPriceResponse)?; + + Ok(Self { usd }) + } +} + +fn price_client() -> Result { + Ok(Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent(format!( + "{}/{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )) + .build()?) +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoSimplePriceResponse { + chia: CoinGeckoUsdPrice, +} + +#[derive(Debug, Deserialize)] +struct CoinGeckoUsdPrice { + usd: Option, +} diff --git a/crates/sage-wallet/src/sync_manager.rs b/crates/sage-wallet/src/sync_manager.rs index 932c7bd9d..e7d918fcc 100644 --- a/crates/sage-wallet/src/sync_manager.rs +++ b/crates/sage-wallet/src/sync_manager.rs @@ -163,6 +163,12 @@ impl SyncManager { self.state.lock().await.reset(); self.abort_wallet_tasks(); self.network = network; + let _ = self + .event_sender + .send(SyncEvent::NetworkChanged { + network_id: self.network.network_id(), + }) + .await; } } SyncCommand::HandleMessage { ip, message } => { diff --git a/crates/sage-wallet/src/sync_manager/sync_event.rs b/crates/sage-wallet/src/sync_manager/sync_event.rs index 534c2b353..bb25a7a35 100644 --- a/crates/sage-wallet/src/sync_manager/sync_event.rs +++ b/crates/sage-wallet/src/sync_manager/sync_event.rs @@ -26,4 +26,7 @@ pub enum SyncEvent { CatInfo, DidInfo, NftData, + NetworkChanged { + network_id: String, + }, } diff --git a/crates/sage/src/endpoints/actions.rs b/crates/sage/src/endpoints/actions.rs index 3e333ade2..9569c9d3a 100644 --- a/crates/sage/src/endpoints/actions.rs +++ b/crates/sage/src/endpoints/actions.rs @@ -6,12 +6,13 @@ use chia_wallet_sdk::{ prelude::*, }; use sage_api::{ - IncreaseDerivationIndex, IncreaseDerivationIndexResponse, RedownloadNft, RedownloadNftResponse, - ResyncCat, ResyncCatResponse, UpdateCat, UpdateCatResponse, UpdateDid, UpdateDidResponse, - UpdateNft, UpdateNftCollection, UpdateNftCollectionResponse, UpdateNftResponse, UpdateOption, + GetXchUsdPrice, GetXchUsdPriceResponse, IncreaseDerivationIndex, + IncreaseDerivationIndexResponse, RedownloadNft, RedownloadNftResponse, ResyncCat, + ResyncCatResponse, UpdateCat, UpdateCatResponse, UpdateDid, UpdateDidResponse, UpdateNft, + UpdateNftCollection, UpdateNftCollectionResponse, UpdateNftResponse, UpdateOption, UpdateOptionResponse, }; -use sage_assets::DexieCat; +use sage_assets::{DexieCat, XchUsdPrice}; use sage_database::{Asset, AssetKind, Derivation}; use sage_wallet::SyncCommand; @@ -254,4 +255,10 @@ impl Sage { Ok(IncreaseDerivationIndexResponse {}) } + + pub async fn get_xch_usd_price(&self, _req: GetXchUsdPrice) -> Result { + let price = XchUsdPrice::fetch().await?; + + Ok(GetXchUsdPriceResponse { usd: price.usd }) + } } diff --git a/docs/apps_readme.md b/docs/apps_readme.md new file mode 100644 index 000000000..1dc2e500d --- /dev/null +++ b/docs/apps_readme.md @@ -0,0 +1,114 @@ +# Sage AppsLaunchpad — Quick Start (WIP) + +This is the fastest way to get a basic Sage App running. + +> ⚠️ WIP: APIs are mostly stable, but expect small changes. + +--- + +## 1. Install SDK + +Right now the SDK is local: + +```json +{ + "dependencies": { + "@sage-app/sdk": "file:../sage/packages/sage-app-sdk" + } +} +``` + +Then: + +```bash +npm install +``` + +--- + +## 2. Create `sage-manifest.json` + +Copy the template from the example app: +https://github.com/Hadamcik/sage-permission-probe + +Place it in your project root: + +``` +sage-manifest.json +``` + +Edit: + +- app name +- permissions (capabilities) +- entry point + +--- + +## 3. Build your app + +Typical frontend build: + +```bash +npm run build +``` + +Output should go to: + +``` +./dist +``` + +--- + +## 4. Finalize manifest + +Generate the final manifest used by Sage: + +```json +{ + "scripts": { + "sage:finalize": "sage-app finalize-manifest --source ./sage-manifest.json --dist ./dist" + } +} +``` + +Run: + +```bash +npm run sage:finalize +``` + +--- + +## 5. Load into Sage + +Install into Sage using URL where your app is running and final `/sage-manifest.json` is accessible. + +--- + +## Capabilities & API + +Available capabilities and bridge methods: + +- `docs/generated/user-*.md` + +These define: + +- what your app can request +- what the bridge allows you to do + +--- + +## Example App + +Full working example: + +https://github.com/Hadamcik/sage-permission-probe + +--- + +## Feedback + +This is early — if something is confusing, missing, or breaks: +open an issue or reach out. diff --git a/docs/generated/system-bridge-capabilities.md b/docs/generated/system-bridge-capabilities.md new file mode 100644 index 000000000..0e598bb31 --- /dev/null +++ b/docs/generated/system-bridge-capabilities.md @@ -0,0 +1,365 @@ +# System bridge capabilities + +## `runtime_manager.list_runtimes` + +**List app runtimes** + +Allows the system app to inspect running Sage app runtimes. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.focus_taskbar_runtime` + +**Focus taskbar app runtime** + +Allows the system app to focus running Sage taskbar app runtime. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.hide_runtime` + +**Hide app runtime** + +Allows the system app to hide running Sage app runtime. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.kill_runtime` + +**Kill app runtime** + +Allows the system app to stop running Sage app runtime. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.get_active_taskbar_runtime` + +**Get active runtime** + +Allows the system app to retrieve the currently active Sage app runtime. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.listen_runtimes_changed` + +**Observe runtime changes** + +Allows the system app to receive events when Sage app runtimes change. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.listen_active_runtime_changed` + +**Observe active runtime changes** + +Allows the system app to receive events when the active Sage app runtime changes. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.hide_self` + +**Hide itself** + +Allows the system app to hide its own runtime. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `runtime_manager.close_self` + +**Close itself** + +Allows the system app to close its own runtime. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `capability_definitions.read` + +**Read capability definitions** + +Allows the system app to read Sage capability definitions. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app_permissions.read` + +**Read app permissions** + +Allows the system app to read app permissions for review. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app_permissions.apply` + +**Apply app permissions** + +Allows the system app to apply reviewed app permission changes. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app_install.preview` + +**Preview app installs** + +Allows the system app to preview URL and ZIP app installations. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app_install.apply` + +**Install apps** + +Allows the system app to install Sage apps after review. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app_update.read` + +**Read app update review context** + +Allows the system app to read update information for installed Sage apps. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app_update.apply` + +**Apply app updates** + +Allows the system app to download and apply approved Sage app updates. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app_registry.listen_listed_apps_changed` + +**Observe listed apps changes** + +Allows the system app to receive events when installed/listed Sage apps change. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `file_system.select_file` + +**Select file** + +Allows the system app to ask the user to select a local file. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `bridge_approval.list` + +**List bridge approvals** + +Allows the system app to list pending bridge approvals. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `bridge_approval.resolve` + +**Resolve bridge approval** + +Allows the system app to resolve a pending bridge approval. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `bridge_approval.listen_changed` + +**Listen for bridge approval changes** + +Allows the system app to listen for changes in pending bridge approvals. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `donation.get_details` + +**Get details for donation** + +Allows the system app to retrieve details to send donation. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `sandbox.get_state` + +**Read sandbox state** + +Allows the system app to read Sage app sandbox test state. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `sandbox.rerun_tests` + +**Re-run sandbox tests** + +Allows the system app to re-run Sage app sandbox tests. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `sandbox.listen_state_changed` + +**Observe sandbox state changes** + +Allows the system app to receive events when sandbox test state changes. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.list_wallets` + +**List wallets** + +Allows the system app to list wallets available in Sage. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | diff --git a/docs/generated/system-bridge-methods.md b/docs/generated/system-bridge-methods.md new file mode 100644 index 000000000..30b9a3f15 --- /dev/null +++ b/docs/generated/system-bridge-methods.md @@ -0,0 +1,139 @@ +# System bridge methods + +## `appInstall.installUrl` + +| Field | Value | +| ---------- | ------------------- | +| Capability | `app_install.apply` | + +## `appInstall.installZip` + +| Field | Value | +| ---------- | ------------------- | +| Capability | `app_install.apply` | + +## `appInstall.previewUrl` + +| Field | Value | +| ---------- | --------------------- | +| Capability | `app_install.preview` | + +## `appInstall.previewZip` + +| Field | Value | +| ---------- | --------------------- | +| Capability | `app_install.preview` | + +## `appPermissions.applyPermissions` + +| Field | Value | +| ---------- | ----------------------- | +| Capability | `app_permissions.apply` | + +## `appPermissions.getReviewContext` + +| Field | Value | +| ---------- | ---------------------- | +| Capability | `app_permissions.read` | + +## `appUpdate.applyUpdate` + +| Field | Value | +| ---------- | ------------------ | +| Capability | `app_update.apply` | + +## `appUpdate.getReviewContext` + +| Field | Value | +| ---------- | ----------------- | +| Capability | `app_update.read` | + +## `bridgeApprovals.listPending` + +| Field | Value | +| ---------- | ---------------------- | +| Capability | `bridge_approval.list` | + +## `bridgeApprovals.resolve` + +| Field | Value | +| ---------- | ------------------------- | +| Capability | `bridge_approval.resolve` | + +## `capabilities.listUserDefinitions` + +| Field | Value | +| ---------- | ----------------------------- | +| Capability | `capability_definitions.read` | + +## `donations.getDetails` + +| Field | Value | +| ---------- | ---------------------- | +| Capability | `donation.get_details` | + +## `fileSystem.selectFile` + +| Field | Value | +| ---------- | ------------------------- | +| Capability | `file_system.select_file` | + +## `runtimeManager.closeSelf` + +| Field | Value | +| ---------- | ---------------------------- | +| Capability | `runtime_manager.close_self` | + +## `runtimeManager.focusTaskbarRuntime` + +| Field | Value | +| ---------- | --------------------------------------- | +| Capability | `runtime_manager.focus_taskbar_runtime` | + +## `runtimeManager.getActiveTaskbarRuntime` + +| Field | Value | +| ---------- | -------------------------------------------- | +| Capability | `runtime_manager.get_active_taskbar_runtime` | + +## `runtimeManager.hideRuntime` + +| Field | Value | +| ---------- | ------------------------------ | +| Capability | `runtime_manager.hide_runtime` | + +## `runtimeManager.hideSelf` + +| Field | Value | +| ---------- | --------------------------- | +| Capability | `runtime_manager.hide_self` | + +## `runtimeManager.killRuntime` + +| Field | Value | +| ---------- | ------------------------------ | +| Capability | `runtime_manager.kill_runtime` | + +## `runtimeManager.listRuntimes` + +| Field | Value | +| ---------- | ------------------------------- | +| Capability | `runtime_manager.list_runtimes` | + +## `sandbox.getState` + +| Field | Value | +| ---------- | ------------------- | +| Capability | `sandbox.get_state` | + +## `sandbox.rerunTests` + +| Field | Value | +| ---------- | --------------------- | +| Capability | `sandbox.rerun_tests` | + +## `wallet.listWallets` + +| Field | Value | +| ---------- | --------------------- | +| Capability | `wallet.list_wallets` | diff --git a/docs/generated/user-bridge-capabilities.md b/docs/generated/user-bridge-capabilities.md new file mode 100644 index 000000000..867463c3c --- /dev/null +++ b/docs/generated/user-bridge-capabilities.md @@ -0,0 +1,379 @@ +# User bridge capabilities + +## `bridge.send` + +**Bridge messaging** + +Allows the app to send messages through the Sage bridge. (Only for sandbox tests) + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app.get_info` + +**Read app information** + +Allows the app to read its Sage app identity and permission information. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app.lifecycle.ready_to_stop` + +**Acknowledge app shutdown** + +Allows the app to acknowledge that it is ready to stop after a lifecycle request. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app.lifecycle.set_before_stop_listener` + +**Listen before app shutdown** + +Allows the app to register a before-stop lifecycle listener. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app.get_capabilities` + +**Read granted capabilities** + +Allows the app to read the capabilities currently visible to it. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app.request_capability_grant` + +**Request additional capability** + +Allows the app to request a capability grant after installation. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `app.request_network_whitelist_grant` + +**Request network access** + +Allows the app to request access to an additional network target after installation. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_key` + +**Read wallet key** + +Allows the app to read public information about a wallet key. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_secret_key` + +**Read wallet secret key** + +Allows the app to read wallet secrets, including the mnemonic or private key when available. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `true` | + +## `wallet.send_xch` + +**Send XCH** + +Allows the app to request XCH transactions from your wallet. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `true` | +| Accesses sensitive secret | `false` | + +## `wallet.send_xch_auto_submit` + +**Automatic XCH send** + +Allows the app to submit XCH transactions without asking for per-transaction approval. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `false` | +| User grantable | `true` | +| Shared with app | `false` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_sync_status` + +**Read sync status** + +Allows the app to read wallet sync status and current wallet balance summary. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_version` + +**Read wallet version** + +Allows the app to read the current Sage wallet version. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_xch_usd_price` + +**Read XCH/USD price** + +Allows the app to read the current estimated XCH price in USD. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.check_address` + +**Check address** + +Allows the app to validate whether an address belongs to this wallet. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_derivations` + +**Read derivations** + +Allows the app to read wallet derivation records and addresses. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_spendable_coin_count` + +**Read spendable coin count** + +Allows the app to read the number of spendable coins in the wallet. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_coins_by_ids` + +**Read coins by IDs** + +Allows the app to read specific wallet coin records by coin ID. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_coins` + +**Read coins** + +Allows the app to list wallet coins. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_pending_transactions` + +**Read pending transactions** + +Allows the app to read pending wallet transactions. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_transaction` + +**Read transaction** + +Allows the app to read a wallet transaction by height. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `wallet.get_transactions` + +**Read transactions** + +Allows the app to list wallet transactions. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `environment.theme.get_current` + +**Read current theme** + +Allows the app to read Sage's current theme. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `environment.theme.css_vars` + +**Use Sage theme CSS variables** + +Allows Sage to inject current theme CSS variables into the app runtime. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `environment.theme.listen_changed` + +**Observe theme changes** + +Allows the app to receive events when Sage's theme changes. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `environment.get_network` + +**Read current network** + +Allows the app to read Sage's currently active network information. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `false` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | + +## `storage.persistent_webview` + +**Persistent storage** + +Allows the app to store data on this device between sessions. + +| Flag | Value | +| ------------------------- | ------- | +| Requestable by app | `true` | +| User grantable | `true` | +| Shared with app | `true` | +| Externally observable | `false` | +| Accesses sensitive secret | `false` | diff --git a/docs/generated/user-bridge-methods.md b/docs/generated/user-bridge-methods.md new file mode 100644 index 000000000..93a878c5a --- /dev/null +++ b/docs/generated/user-bridge-methods.md @@ -0,0 +1,145 @@ +# User bridge methods + +## `app.getCapabilities` + +| Field | Value | +| ---------- | ---------------------- | +| Capability | `app.get_capabilities` | + +## `app.getInfo` + +| Field | Value | +| ---------- | -------------- | +| Capability | `app.get_info` | + +## `app.lifecycle.readyToStop` + +| Field | Value | +| ---------- | ----------------------------- | +| Capability | `app.lifecycle.ready_to_stop` | + +## `app.lifecycle.setBeforeStopListener` + +| Field | Value | +| ---------- | ---------------------------------------- | +| Capability | `app.lifecycle.set_before_stop_listener` | + +## `app.requestCapabilityGrant` + +| Field | Value | +| ---------- | ------------------------------ | +| Capability | `app.request_capability_grant` | + +## `app.requestNetworkWhitelistGrant` + +| Field | Value | +| ---------- | ------------------------------------- | +| Capability | `app.request_network_whitelist_grant` | + +## `bridge.ping` + +| Field | Value | +| ---------- | --------- | +| Capability | `ungated` | + +## `bridge.send` + +| Field | Value | +| ---------- | ------------- | +| Capability | `bridge.send` | + +## `environment.getNetwork` + +| Field | Value | +| ---------- | ------------------------- | +| Capability | `environment.get_network` | + +## `environment.theme.getCurrent` + +| Field | Value | +| ---------- | ------------------------------- | +| Capability | `environment.theme.get_current` | + +## `wallet.checkAddress` + +| Field | Value | +| ---------- | ---------------------- | +| Capability | `wallet.check_address` | + +## `wallet.getCoins` + +| Field | Value | +| ---------- | ------------------ | +| Capability | `wallet.get_coins` | + +## `wallet.getCoinsByIds` + +| Field | Value | +| ---------- | ------------------------- | +| Capability | `wallet.get_coins_by_ids` | + +## `wallet.getDerivations` + +| Field | Value | +| ---------- | ------------------------ | +| Capability | `wallet.get_derivations` | + +## `wallet.getKey` + +| Field | Value | +| ---------- | ---------------- | +| Capability | `wallet.get_key` | + +## `wallet.getPendingTransactions` + +| Field | Value | +| ---------- | --------------------------------- | +| Capability | `wallet.get_pending_transactions` | + +## `wallet.getSecretKey` + +| Field | Value | +| ---------- | ----------------------- | +| Capability | `wallet.get_secret_key` | + +## `wallet.getSpendableCoinCount` + +| Field | Value | +| ---------- | --------------------------------- | +| Capability | `wallet.get_spendable_coin_count` | + +## `wallet.getSyncStatus` + +| Field | Value | +| ---------- | ------------------------ | +| Capability | `wallet.get_sync_status` | + +## `wallet.getTransaction` + +| Field | Value | +| ---------- | ------------------------ | +| Capability | `wallet.get_transaction` | + +## `wallet.getTransactions` + +| Field | Value | +| ---------- | ------------------------- | +| Capability | `wallet.get_transactions` | + +## `wallet.getVersion` + +| Field | Value | +| ---------- | -------------------- | +| Capability | `wallet.get_version` | + +## `wallet.getXchUsdPrice` + +| Field | Value | +| ---------- | -------------------------- | +| Capability | `wallet.get_xch_usd_price` | + +## `wallet.sendXch` + +| Field | Value | +| ---------- | ----------------- | +| Capability | `wallet.send_xch` | diff --git a/package.json b/package.json index ac46613ce..9c407ed5b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,19 @@ "packageManager": "pnpm@10.13.1", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "dev:system-apps": "node scripts/dev-system-apps-watch.mjs", + "generate:bridge-types": "node scripts/generate-bridge-types.mjs", + "generate:app-docs": "cargo run -p sage-apps --bin generate_docs", + "generate:sdk-types": "pnpm --silent --filter @sage-app/sdk run generate:types && pnpm --silent --filter @sage-system-app/sdk run generate:types", + "generate:types": "pnpm run generate:bridge-types && pnpm run generate:sdk-types", + "build:packages": "pnpm run generate:types && pnpm --filter @sage-app/sdk build && pnpm --filter @sage-system-app/sdk build && pnpm --filter @sage-app/ui build", + "build:system-apps": "pnpm --dir builtin-apps/src/system build", + "build:builtin-static": "node scripts/build-builtin-static.mjs", + "build:builtin-apps": "pnpm run generate:app-docs && pnpm run build:packages && pnpm run build:system-apps && pnpm run build:builtin-static", + "build:frontend": "tsc -b && vite build", + "build": "pnpm run build:builtin-apps && pnpm run build:frontend", + "tauri:dev": "pnpm run build && concurrently -k -n SYSTEM,TAURI -c yellow,cyan \"pnpm dev:system-apps\" \"tauri dev\"", + "tauri:build": "pnpm build && tauri build", "lint": "eslint .", "preview": "vite preview", "prettier": "prettier --write .", @@ -40,14 +52,16 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@react-spring/web": "^9.7.5", + "@sage-app/sdk": "workspace:*", + "@sage-app/ui": "workspace:*", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-barcode-scanner": "^2.4.4", "@tauri-apps/plugin-biometric": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", - "@tauri-apps/plugin-dialog": "~2.4.2", - "@tauri-apps/plugin-fs": "~2.4.5", + "@tauri-apps/plugin-dialog": "~2.7.1", + "@tauri-apps/plugin-fs": "~2.5.1", "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-os": "^2.3.2", "@use-gesture/react": "^10.3.1", @@ -102,8 +116,12 @@ "postcss": "^8.5.6", "prettier": "^3.8.1", "tailwindcss": "^3.4.19", + "ts-prune": "^0.10.3", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "chokidar": "^3.6.0", + "ws": "^8.18.0", + "concurrently": "^9.0.1" } } diff --git a/packages/sage-app-sdk/.gitignore b/packages/sage-app-sdk/.gitignore new file mode 100644 index 000000000..16409d768 --- /dev/null +++ b/packages/sage-app-sdk/.gitignore @@ -0,0 +1,15 @@ +/tmp +/out-tsc + +/node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.pnp +.pnp.js + +.idea +.vscode/* +dist + +src/generated-types.ts diff --git a/packages/sage-app-sdk/cli/finalize-manifest.mjs b/packages/sage-app-sdk/cli/finalize-manifest.mjs new file mode 100755 index 000000000..2b6538a6a --- /dev/null +++ b/packages/sage-app-sdk/cli/finalize-manifest.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const MAX_MANIFEST_SIZE_BYTES = 1024 * 1024; +const MAX_FILE_COUNT = 2000; +const MAX_TOTAL_SIZE_BYTES = 50 * 1024 * 1024; + +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} + +function printHelp() { + console.log( + ` +Usage: + sage-app finalize-manifest --source ./sage-manifest.json --dist ./dist [--out ./dist/sage-manifest.json] + +Commands: + finalize-manifest Generate final sage-manifest.json from source manifest and built dist files +`.trim(), + ); +} + +function parseArgs(argv) { + if (argv.length === 0) { + printHelp(); + process.exit(1); + } + + const command = argv[0]; + const rest = argv.slice(1); + + if (command !== 'finalize-manifest') { + fail(`Unknown command: ${command}`); + } + + const args = { + source: null, + dist: null, + out: null, + }; + + for (let i = 0; i < rest.length; i += 1) { + const arg = rest[i]; + + if (arg === '--source') { + args.source = rest[++i] ?? null; + } else if (arg === '--dist') { + args.dist = rest[++i] ?? null; + } else if (arg === '--out') { + args.out = rest[++i] ?? null; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else { + fail(`Unknown argument: ${arg}`); + } + } + + if (!args.source) { + fail('Missing --source'); + } + + if (!args.dist) { + fail('Missing --dist'); + } + + if (!args.out) { + args.out = path.join(args.dist, 'sage-manifest.json'); + } + + return args; +} + +function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + const data = fs.readFileSync(filePath); + hash.update(data); + return hash.digest('hex'); +} + +function walkFiles(rootDir, currentDir = rootDir) { + const out = []; + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const abs = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + out.push(...walkFiles(rootDir, abs)); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + + if (rel === 'manifest.json' || rel === 'sage-manifest.json') { + continue; + } + + out.push({ + abs, + rel, + }); + } + + return out; +} + +function validateSourceManifest(manifest) { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + fail('Source manifest must be an object'); + } + + if (typeof manifest.name !== 'string' || manifest.name.trim() === '') { + fail('Source manifest name must be a non-empty string'); + } + + if (typeof manifest.version !== 'string' || manifest.version.trim() === '') { + fail('Source manifest version must be a non-empty string'); + } + + if ( + manifest.permissions != null && + (typeof manifest.permissions !== 'object' || + Array.isArray(manifest.permissions)) + ) { + fail('Source manifest permissions must be an object if provided'); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + const sourcePath = path.resolve(args.source); + const distDir = path.resolve(args.dist); + const outPath = path.resolve(args.out); + + if (!fs.existsSync(sourcePath)) { + fail(`Source manifest not found: ${sourcePath}`); + } + + if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) { + fail(`Dist directory not found: ${distDir}`); + } + + const sourceStat = fs.statSync(sourcePath); + if (sourceStat.size > MAX_MANIFEST_SIZE_BYTES) { + fail(`Source manifest exceeds ${MAX_MANIFEST_SIZE_BYTES} bytes`); + } + + const sourceManifest = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); + validateSourceManifest(sourceManifest); + + const walked = walkFiles(distDir).sort((a, b) => a.rel.localeCompare(b.rel)); + + if (walked.length === 0) { + fail(`No files found in dist directory: ${distDir}`); + } + + if (walked.length > MAX_FILE_COUNT) { + fail(`File count ${walked.length} exceeds limit of ${MAX_FILE_COUNT}`); + } + + let totalSize = 0; + + const files = walked.map(({ abs, rel }) => { + const stat = fs.statSync(abs); + const size = stat.size; + totalSize += size; + + return { + path: rel, + sha256: sha256File(abs), + size, + }; + }); + + if (totalSize > MAX_TOTAL_SIZE_BYTES) { + fail( + `Total snapshot size ${totalSize} exceeds limit of ${MAX_TOTAL_SIZE_BYTES} bytes`, + ); + } + + const finalManifest = { + ...sourceManifest, + permissions: sourceManifest.permissions ?? {}, + files, + }; + + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, JSON.stringify(finalManifest, null, 2) + '\n'); + + console.log(`Wrote ${outPath}`); + console.log(`Included ${files.length} files`); + console.log(`Total size ${totalSize} bytes`); +} + +main(); diff --git a/packages/sage-app-sdk/package.json b/packages/sage-app-sdk/package.json new file mode 100644 index 000000000..2d946d8c2 --- /dev/null +++ b/packages/sage-app-sdk/package.json @@ -0,0 +1,43 @@ +{ + "name": "@sage-app/sdk", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "sage-app": "./cli/finalize-manifest.mjs" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./runtime-bridge.js": { + "import": "./dist/runtime-bridge.js" + }, + "./bridge-runtime-core": { + "types": "./dist/bridge-runtime-core.d.ts", + "import": "./dist/bridge-runtime-core.js" + }, + "./theme.css": { + "types": "./dist/theme-css.d.ts", + "style": "./dist/theme.css", + "default": "./dist/theme.css" + } + }, + "files": [ + "dist", + "cli" + ], + "scripts": { + "generate:types": "node ./scripts/export-types.mjs", + "build": "pnpm run generate:types && tsup && cp src/theme/theme.css dist/theme.css && cp src/theme/theme-css.d.ts dist/theme-css.d.ts", + "dev": "pnpm run generate:types && tsup --watch", + "clean": "rm -rf dist" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.6.3" + } +} diff --git a/packages/sage-app-sdk/pnpm-lock.yaml b/packages/sage-app-sdk/pnpm-lock.yaml new file mode 100644 index 000000000..a4ab1c75b --- /dev/null +++ b/packages/sage-app-sdk/pnpm-lock.yaml @@ -0,0 +1,897 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + tsup: + specifier: ^8.2.4 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/estree@1.0.8': {} + + acorn@8.16.0: {} + + any-promise@1.3.0: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.1 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1: + dependencies: + lilconfig: 3.1.3 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + source-map@0.7.6: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1 + resolve-from: 5.0.0 + rollup: 4.60.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.3: {} diff --git a/packages/sage-app-sdk/scripts/export-types.mjs b/packages/sage-app-sdk/scripts/export-types.mjs new file mode 100644 index 000000000..139d232ac --- /dev/null +++ b/packages/sage-app-sdk/scripts/export-types.mjs @@ -0,0 +1,20 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const packageDir = process.cwd(); +const repoRoot = path.resolve(packageDir, '../..'); +const outPath = path.join(packageDir, 'src', 'generated-types.ts'); + +const stdout = execFileSync( + 'cargo', + ['run', '-p', 'sage-apps', '--bin', 'export_bridge_types', '--', 'user'], + { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }, +); + +fs.writeFileSync(outPath, stdout); +console.log(`Wrote ${path.relative(packageDir, outPath)}`); diff --git a/packages/sage-app-sdk/src/bridge/core.ts b/packages/sage-app-sdk/src/bridge/core.ts new file mode 100644 index 000000000..0529d9b94 --- /dev/null +++ b/packages/sage-app-sdk/src/bridge/core.ts @@ -0,0 +1,218 @@ +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { + RustBridgeErrorResponse, + RustBridgeInvokeResult, + RustBridgeSuccessResponse, +} from '../generated-types'; +import { debugComms } from '../debug'; + +export type GenericBridgeRequest = { + bridgeVersion?: string; + id: string; + method: string; + params?: unknown; +}; + +export type GenericBridgeSuccessResponse = { + bridgeVersion: string; + id: string; + ok: true; + result: unknown; +}; + +export type GenericBridgeErrorResponse = { + bridgeVersion: string; + id: string; + ok: false; + error: { + code: string; + message: string; + }; +}; + +type ListenEvent = { + payload: T; +}; + +type Unlisten = () => void; + +export type BridgeRuntimeWebviewHandle = { + label: string; + listen( + event: string, + handler: (event: ListenEvent) => void, + ): Promise; +}; + +type PendingBridgeRequest = { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + timeoutId: number; + method: string; +}; + +export type BridgeRuntimeCoreConfig = { + version: string; + invokeCommand: string; + requestIdPrefix: string; + timeoutMs?: number; +}; + +export type BridgeRuntimeCore = { + webview: BridgeRuntimeWebviewHandle; + pendingRequests: Map; + callHost(method: string, params?: unknown): Promise; + rejectAllPending(reason: string): void; +}; + +function tryGetCurrentWebview(): BridgeRuntimeWebviewHandle | null { + try { + return getCurrentWebview() as BridgeRuntimeWebviewHandle; + } catch { + return null; + } +} + +export function parseJsonOrNull(value: string | null | undefined): unknown { + if (value == null) { + return null; + } + + try { + return JSON.parse(value); + } catch (err) { + console.error('Failed to parse JSON payload from Rust bridge:', err, value); + return null; + } +} + +export function toSdkBridgeSuccessResponse( + version: string, + response: RustBridgeSuccessResponse, +): GenericBridgeSuccessResponse { + return { + bridgeVersion: version, + id: response.id, + ok: true, + result: parseJsonOrNull(response.resultJson), + }; +} + +export function toSdkBridgeErrorResponse( + version: string, + response: RustBridgeErrorResponse, +): GenericBridgeErrorResponse { + return { + bridgeVersion: version, + id: response.id, + ok: false, + error: response.error, + }; +} + +export function createBridgeRuntimeCore( + config: BridgeRuntimeCoreConfig, +): BridgeRuntimeCore | null { + const maybeWebview = tryGetCurrentWebview(); + if (!maybeWebview) { + return null; + } + + const webview = maybeWebview; + const pendingRequests = new Map(); + const timeoutMs = config.timeoutMs ?? 30000; + + function rejectAllPending(reason: string) { + for (const [id, pending] of pendingRequests.entries()) { + window.clearTimeout(pending.timeoutId); + pending.reject(new Error(reason)); + pendingRequests.delete(id); + } + } + + async function callHost(method: string, params?: unknown): Promise { + const id = `${config.requestIdPrefix}-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + debugComms('request', { id, method, params }); + + return await new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + const pending = pendingRequests.get(id); + if (!pending) { + return; + } + + pendingRequests.delete(id); + reject(new Error(`timeout for ${method}`)); + }, timeoutMs); + + pendingRequests.set(id, { + resolve: (value) => resolve(value as T), + reject, + timeoutId, + method, + }); + + void (async () => { + try { + const request: GenericBridgeRequest = { + bridgeVersion: config.version, + id, + method, + params, + }; + + const result = await invoke( + config.invokeCommand, + { + request: { + bridgeVersion: request.bridgeVersion ?? null, + id: request.id, + method: request.method, + paramsJson: + request.params === undefined + ? null + : JSON.stringify(request.params), + }, + }, + ); + debugComms('invoke-result', result); + + if (result.kind === 'success' || result.kind === 'error') { + pendingRequests.delete(id); + window.clearTimeout(timeoutId); + if (result.kind === 'success') { + const response = toSdkBridgeSuccessResponse( + config.version, + result, + ); + resolve(response.result as T); + } else { + const response = toSdkBridgeErrorResponse(config.version, result); + reject(new Error(response.error.message)); + } + } + } catch (error: unknown) { + debugComms('request-error', { id, method, error }); + const pending = pendingRequests.get(id); + if (!pending) { + return; + } + + pendingRequests.delete(id); + window.clearTimeout(timeoutId); + reject(error instanceof Error ? error : new Error(String(error))); + } + })(); + }); + } + + return { + webview, + pendingRequests, + callHost, + rejectAllPending, + }; +} diff --git a/packages/sage-app-sdk/src/client.ts b/packages/sage-app-sdk/src/client.ts new file mode 100644 index 000000000..b40b18be8 --- /dev/null +++ b/packages/sage-app-sdk/src/client.ts @@ -0,0 +1,47 @@ +import { initSageRuntimeBridge } from './runtime'; +import type { SageClient } from './types'; +import { bootstrapTheme } from './theme/bootstrap'; +import { formatSageError } from './client/errors'; + +type SageGlobal = typeof globalThis & { + __TAURI__?: unknown; +}; + +export { formatSageError }; + +export function isSageRuntimeAvailable(): boolean { + return !!(globalThis as SageGlobal).__TAURI__; +} + +function getClientFromWindow(): SageClient | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return window.__SAGE__; +} + +export function isSageBridgeInitialized(): boolean { + return !!getClientFromWindow(); +} + +export function hasSageBridge(): boolean { + return !!getClientFromWindow(); +} + +export async function getSageClient(): Promise { + let client = getClientFromWindow(); + + if (!client) { + initSageRuntimeBridge(); + client = getClientFromWindow(); + } + + if (!client) { + throw new Error('Sage bridge is unavailable in this runtime.'); + } + + bootstrapTheme(client); + + return client; +} diff --git a/packages/sage-app-sdk/src/client/errors.ts b/packages/sage-app-sdk/src/client/errors.ts new file mode 100644 index 000000000..8badb0c2d --- /dev/null +++ b/packages/sage-app-sdk/src/client/errors.ts @@ -0,0 +1,21 @@ +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +export function formatSageError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + + if (isObject(err)) { + if (typeof err.message === 'string') return err.message; + if (typeof err.reason === 'string') return err.reason; + + try { + return JSON.stringify(err, null, 2); + } catch { + return 'Unknown Sage error'; + } + } + + return String(err); +} diff --git a/packages/sage-app-sdk/src/debug.ts b/packages/sage-app-sdk/src/debug.ts new file mode 100644 index 000000000..1ed0cd95f --- /dev/null +++ b/packages/sage-app-sdk/src/debug.ts @@ -0,0 +1,21 @@ +type SageDebugWindow = Window & + typeof globalThis & { + __SAGE_APPS_COMMS_DEBUG__?: boolean; + }; + +export function sageAppsCommsDebugEnabled(): boolean { + if (typeof window === 'undefined') return false; + + return (window as SageDebugWindow).__SAGE_APPS_COMMS_DEBUG__ === true; +} + +export function debugComms(label: string, payload?: unknown) { + if (!sageAppsCommsDebugEnabled()) return; + + if (payload === undefined) { + console.debug(`[Sage Comms] ${label}`); + return; + } + + console.debug(`[Sage Comms] ${label}`, payload); +} diff --git a/packages/sage-app-sdk/src/global.d.ts b/packages/sage-app-sdk/src/global.d.ts new file mode 100644 index 000000000..48f0be47f --- /dev/null +++ b/packages/sage-app-sdk/src/global.d.ts @@ -0,0 +1,11 @@ +import type { SageAppInfo, SageClient } from './types'; + +declare global { + interface Window { + __SAGE__?: SageClient; + __SAGE_APP_INFO__?: SageAppInfo; + __SAGE_RUNTIME_BRIDGE_INITIALIZED__?: boolean; + } +} + +export {}; diff --git a/packages/sage-app-sdk/src/hooks.ts b/packages/sage-app-sdk/src/hooks.ts new file mode 100644 index 000000000..ccf2c7337 --- /dev/null +++ b/packages/sage-app-sdk/src/hooks.ts @@ -0,0 +1,18 @@ +import { getSageClient, hasSageBridge } from './client'; +import type { SageClient } from './types'; + +let promise: Promise | null = null; + +export function useSageClient(): SageClient { + if (typeof window !== 'undefined' && window.__SAGE__) { + return window.__SAGE__; + } + + if (!hasSageBridge()) { + throw new Error('Sage bridge is not available'); + } + + promise ??= getSageClient(); + + throw promise; +} diff --git a/packages/sage-app-sdk/src/index.ts b/packages/sage-app-sdk/src/index.ts new file mode 100644 index 000000000..3d210c4f3 --- /dev/null +++ b/packages/sage-app-sdk/src/index.ts @@ -0,0 +1,16 @@ +export { initSageRuntimeBridge, SAGE_BRIDGE_VERSION } from './runtime'; +export * from './theme'; + +export { + isSageRuntimeAvailable, + isSageBridgeInitialized, + formatSageError, + getSageClient, + hasSageBridge, +} from './client'; + +export { createBridgeRuntimeCore, parseJsonOrNull } from './bridge/core'; + +export * from './types'; +export * from './hooks'; +export { debugComms } from './debug'; diff --git a/packages/sage-app-sdk/src/runtime-bridge-entry.ts b/packages/sage-app-sdk/src/runtime-bridge-entry.ts new file mode 100644 index 000000000..1506547b4 --- /dev/null +++ b/packages/sage-app-sdk/src/runtime-bridge-entry.ts @@ -0,0 +1,3 @@ +import { initSageRuntimeBridge } from './runtime'; + +initSageRuntimeBridge(); diff --git a/packages/sage-app-sdk/src/runtime.ts b/packages/sage-app-sdk/src/runtime.ts new file mode 100644 index 000000000..6d764da62 --- /dev/null +++ b/packages/sage-app-sdk/src/runtime.ts @@ -0,0 +1 @@ +export * from './runtime/index'; diff --git a/packages/sage-app-sdk/src/runtime/create-client.ts b/packages/sage-app-sdk/src/runtime/create-client.ts new file mode 100644 index 000000000..e4211a1e0 --- /dev/null +++ b/packages/sage-app-sdk/src/runtime/create-client.ts @@ -0,0 +1,284 @@ +import type * as Generated from '../generated-types'; +import type { BridgeRuntimeCore } from '../bridge/core'; +import type { SageBridgeSendPayload, SageClient } from '../types'; +import { applySageThemeCssVars, clearSageThemeCssVars } from '../theme'; +import { onRuntimeEventType } from './events'; +import { handleBeforeStopEvent } from './lifecycle'; + +type SageWindow = Window & + typeof globalThis & { + __SAGE_APP_INFO__?: Generated.AppGetInfoResult; + }; + +function getSageWindow(): SageWindow { + return window as SageWindow; +} + +function buildFallbackAppInfo(): Generated.AppGetInfoResult { + return { + id: 'unknown', + name: 'Unknown App', + version: '0.0.0', + requestedPermissions: { + network: { + whitelist: { + required: [], + optional: [], + }, + whitelistByNetwork: {}, + }, + capabilities: { + required: [], + optional: [], + }, + }, + capabilities: [], + network: [], + }; +} + +export function createSageClient(core: BridgeRuntimeCore): SageClient { + const w = getSageWindow(); + const callHost = core.callHost; + const rejectAllPending = core.rejectAllPending; + + const beforeStopHandlers = new Set< + (event: Generated.BeforeStopEvent) => void | Promise + >(); + + let beforeStopRegistered = false; + + async function syncBeforeStopRegistration() { + const shouldBeRegistered = beforeStopHandlers.size > 0; + if (beforeStopRegistered === shouldBeRegistered) { + return; + } + + beforeStopRegistered = shouldBeRegistered; + + try { + await callHost( + 'app.lifecycle.setBeforeStopListener', + { + active: shouldBeRegistered, + } satisfies Generated.SetBeforeStopListenerParams, + ); + } catch (error) { + console.error('Failed to sync before-stop listener registration:', error); + } + } + + onRuntimeEventType( + 'lifecycle.beforeStop', + (detail) => { + handleBeforeStopEvent( + detail, + beforeStopHandlers, + callHost, + rejectAllPending, + ); + }, + ); + + return { + initialAppInfo: w.__SAGE_APP_INFO__ ?? buildFallbackAppInfo(), + + app: { + async bridgePing() { + return await callHost('bridge.ping'); + }, + + async bridgeSend(input: SageBridgeSendPayload) { + return await callHost('bridge.send', input); + }, + + async getInfo() { + return await callHost('app.getInfo'); + }, + + async getCapabilities() { + return await callHost('app.getCapabilities'); + }, + + async requestCapabilityGrant( + input: Generated.RequestCapabilityGrantParams, + ) { + return await callHost( + 'app.requestCapabilityGrant', + input, + ); + }, + + async requestNetworkWhitelistGrant( + input: Generated.RequestNetworkWhitelistGrantParams, + ) { + return await callHost( + 'app.requestNetworkWhitelistGrant', + input, + ); + }, + + onGrantedCapabilitiesChange(handler) { + return onRuntimeEventType( + 'grantedCapabilitiesChange', + handler, + ); + }, + + onGrantedNetworkWhitelistChange(handler) { + return onRuntimeEventType( + 'grantedNetworkWhitelistChange', + handler, + ); + }, + + lifecycle: { + onBeforeStop(handler) { + beforeStopHandlers.add(handler); + void syncBeforeStopRegistration(); + + return () => { + beforeStopHandlers.delete(handler); + void syncBeforeStopRegistration(); + }; + }, + }, + }, + + wallet: { + async getKey(input: Generated.GetKey) { + return await callHost('wallet.getKey', input); + }, + + async getSecretKey(input: Generated.GetSecretKey) { + return await callHost( + 'wallet.getSecretKey', + input, + ); + }, + + async getSyncStatus() { + return await callHost( + 'wallet.getSyncStatus', + ); + }, + + async getVersion() { + return await callHost( + 'wallet.getVersion', + ); + }, + + async getPendingTransactions() { + return await callHost( + 'wallet.getPendingTransactions', + ); + }, + async getXchUsdPrice() { + return await callHost( + 'wallet.getXchUsdPrice', + ); + }, + + async checkAddress(input: Generated.CheckAddress) { + return await callHost( + 'wallet.checkAddress', + input, + ); + }, + + async getDerivations(input: Generated.GetDerivations) { + return await callHost( + 'wallet.getDerivations', + input, + ); + }, + + async getSpendableCoinCount(input: Generated.GetSpendableCoinCount) { + return await callHost( + 'wallet.getSpendableCoinCount', + input, + ); + }, + + async getCoinsByIds(input: Generated.GetCoinsByIds) { + return await callHost( + 'wallet.getCoinsByIds', + input, + ); + }, + + async getCoins(input: Generated.GetCoins) { + return await callHost( + 'wallet.getCoins', + input, + ); + }, + + async getTransaction(input: Generated.GetTransaction) { + return await callHost( + 'wallet.getTransaction', + input, + ); + }, + + async getTransactions(input: Generated.GetTransactions) { + return await callHost( + 'wallet.getTransactions', + input, + ); + }, + + async sendXch(input: Generated.WalletSendXchParams) { + return await callHost( + 'wallet.sendXch', + input, + ); + }, + }, + + environment: { + theme: { + async getCurrent() { + return await callHost( + 'environment.theme.getCurrent', + ); + }, + + onChanged(handler) { + return onRuntimeEventType( + 'environment.theme.changed', + handler, + ); + }, + + async mountCssVars() { + const current = + await callHost( + 'environment.theme.getCurrent', + ); + + applySageThemeCssVars(current.theme); + + const unlisten = + onRuntimeEventType( + 'environment.theme.changed', + (event) => { + applySageThemeCssVars(event.theme); + }, + ); + + return () => { + unlisten(); + clearSageThemeCssVars(); + }; + }, + }, + getNetwork() { + return callHost( + 'environment.getNetwork', + ); + }, + }, + }; +} diff --git a/packages/sage-app-sdk/src/runtime/events.ts b/packages/sage-app-sdk/src/runtime/events.ts new file mode 100644 index 000000000..e451c0cd5 --- /dev/null +++ b/packages/sage-app-sdk/src/runtime/events.ts @@ -0,0 +1,46 @@ +export type RuntimeEventEnvelope = { + type: string; + payload: T; +}; + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +export function isRuntimeEventEnvelope( + value: unknown, +): value is RuntimeEventEnvelope { + return ( + isObject(value) && typeof value.type === 'string' && 'payload' in value + ); +} + +export function dispatchRuntimeEvent(data: RuntimeEventEnvelope) { + window.dispatchEvent( + new CustomEvent('sage:event', { + detail: data, + }), + ); + + window.dispatchEvent( + new CustomEvent(`sage:event:${data.type}`, { + detail: data, + }), + ); +} + +export function onRuntimeEventType( + type: string, + handler: (event: T) => void, +): () => void { + const listener = (event: Event) => { + const custom = event as CustomEvent>; + handler(custom.detail.payload); + }; + + window.addEventListener(`sage:event:${type}`, listener as EventListener); + + return () => { + window.removeEventListener(`sage:event:${type}`, listener as EventListener); + }; +} diff --git a/packages/sage-app-sdk/src/runtime/index.ts b/packages/sage-app-sdk/src/runtime/index.ts new file mode 100644 index 000000000..400f84462 --- /dev/null +++ b/packages/sage-app-sdk/src/runtime/index.ts @@ -0,0 +1,150 @@ +import type { SageBridgeVersion } from '../types'; +import { createBridgeRuntimeCore, parseJsonOrNull } from '../bridge/core'; +import type { SageClient } from '../types'; +import { createSageClient } from './create-client'; +import { debugComms } from '../debug'; +import { dispatchRuntimeEvent, isRuntimeEventEnvelope } from './events'; + +export const SAGE_BRIDGE_VERSION: SageBridgeVersion = 'v1'; + +type SageListenEvent = { + payload: T; +}; + +type SageUnlisten = () => void; + +type SageWebviewHandle = { + label: string; + listen( + event: string, + handler: (event: SageListenEvent) => void, + ): Promise; +}; + +type SageWindow = Window & + typeof globalThis & { + __SAGE__?: SageClient; + __SAGE_RUNTIME_BRIDGE_INITIALIZED__?: boolean; + }; + +type RustLikeBridgeSuccessResponse = { + bridgeVersion: SageBridgeVersion; + id: string; + ok: true; + result?: unknown; + resultJson?: string; +}; + +type RustLikeBridgeErrorResponse = { + bridgeVersion: SageBridgeVersion; + id: string; + ok: false; + error: { + code: string; + message: string; + }; +}; + +type RustLikeBridgeResponse = + | RustLikeBridgeSuccessResponse + | RustLikeBridgeErrorResponse; + +function getSageWindow(): SageWindow { + return window as SageWindow; +} + +function bridgeResponseResult(data: RustLikeBridgeSuccessResponse): unknown { + if ('result' in data) { + return data.result; + } + + return parseJsonOrNull(data.resultJson); +} + +export function initSageRuntimeBridge(): boolean { + const w = getSageWindow(); + + if (w.__SAGE__) { + return true; + } + + if (w.__SAGE_RUNTIME_BRIDGE_INITIALIZED__) { + return true; + } + + const core = createBridgeRuntimeCore({ + version: SAGE_BRIDGE_VERSION, + invokeCommand: 'apps_invoke_bridge', + requestIdPrefix: 'sage', + }); + + if (!core) { + return false; + } + + const webview = core.webview as SageWebviewHandle; + + w.__SAGE_RUNTIME_BRIDGE_INITIALIZED__ = true; + + webview + .listen('sage-bridge:event', (event: SageListenEvent) => { + const data = event.payload; + debugComms('user:event:raw', data); + + if (!isRuntimeEventEnvelope(data)) { + console.warn('[Sage SDK] Dropped malformed runtime event:', data); + return; + } + + try { + dispatchRuntimeEvent(data); + } catch (error: unknown) { + console.error('Failed to dispatch Sage bridge runtime event:', error); + } + }) + .catch((error: unknown) => { + console.error('Failed to subscribe to sage-bridge:event:', error); + }); + + webview + .listen( + 'sage-bridge:response', + (event: SageListenEvent) => { + const data = event.payload; + debugComms('user:response:raw', data); + + if (!data || data.bridgeVersion !== SAGE_BRIDGE_VERSION) { + console.warn('[Sage SDK] Dropped malformed bridge response:', data); + return; + } + + const pending = core.pendingRequests.get(data.id); + if (!pending) { + console.warn( + '[Sage SDK] Response for unknown request id:', + data.id, + data, + ); + return; + } + + core.pendingRequests.delete(data.id); + window.clearTimeout(pending.timeoutId); + + if (data.ok) { + pending.resolve(bridgeResponseResult(data)); + } else { + pending.reject( + new Error(data.error?.message || 'Unknown Sage bridge error'), + ); + } + }, + ) + .catch((error: unknown) => { + console.error('Failed to subscribe to sage-bridge:response:', error); + }); + + w.__SAGE__ = createSageClient(core); + + return true; +} diff --git a/packages/sage-app-sdk/src/runtime/lifecycle.ts b/packages/sage-app-sdk/src/runtime/lifecycle.ts new file mode 100644 index 000000000..866c33d5b --- /dev/null +++ b/packages/sage-app-sdk/src/runtime/lifecycle.ts @@ -0,0 +1,28 @@ +import type * as Generated from '../generated-types'; + +export function handleBeforeStopEvent( + event: Generated.BeforeStopEvent, + beforeStopHandlers: Set< + (event: Generated.BeforeStopEvent) => void | Promise + >, + callHost: (method: string, params?: unknown) => Promise, + rejectAllPending: (reason: string) => void, +) { + rejectAllPending('Sage runtime is stopping'); + + if (!event?.requestId || beforeStopHandlers.size === 0) { + return; + } + + const handlers = Array.from(beforeStopHandlers); + + void Promise.allSettled( + handlers.map((handler) => Promise.resolve(handler(event))), + ).finally(() => { + void callHost('app.lifecycle.readyToStop', { + requestId: event.requestId, + } satisfies Generated.ReadyToStopParams).catch((error: unknown) => { + console.error('Failed to acknowledge before-stop:', error); + }); + }); +} diff --git a/packages/sage-app-sdk/src/theme/bootstrap.ts b/packages/sage-app-sdk/src/theme/bootstrap.ts new file mode 100644 index 000000000..388190416 --- /dev/null +++ b/packages/sage-app-sdk/src/theme/bootstrap.ts @@ -0,0 +1,22 @@ +import type { SageClient } from '../types'; + +let started = false; + +export function bootstrapTheme(client: SageClient) { + if (started) return; + started = true; + + void client.environment.theme.mountCssVars().catch((err) => { + console.debug('[Sage SDK] Theme CSS vars not mounted:', err); + }); + + try { + client.environment.theme.onChanged?.(() => { + void client.environment.theme.mountCssVars().catch((err) => { + console.debug('[Sage SDK] Theme CSS vars refresh failed:', err); + }); + }); + } catch (err) { + console.debug('[Sage SDK] Theme change listener not available:', err); + } +} diff --git a/packages/sage-app-sdk/src/theme/index.ts b/packages/sage-app-sdk/src/theme/index.ts new file mode 100644 index 000000000..2ad6089b2 --- /dev/null +++ b/packages/sage-app-sdk/src/theme/index.ts @@ -0,0 +1,78 @@ +import type * as Generated from '../generated-types'; + +export const sageThemeVars = { + background: '--background', + foreground: '--foreground', + card: '--card', + cardForeground: '--card-foreground', + popover: '--popover', + popoverForeground: '--popover-foreground', + primary: '--primary', + primaryForeground: '--primary-foreground', + secondary: '--secondary', + secondaryForeground: '--secondary-foreground', + muted: '--muted', + mutedForeground: '--muted-foreground', + accent: '--accent', + accentForeground: '--accent-foreground', + destructive: '--destructive', + destructiveForeground: '--destructive-foreground', + border: '--border', + input: '--input', + ring: '--ring', + radius: '--radius', +} as const; + +export type SageThemeVar = keyof typeof sageThemeVars; + +export function cssVar(name: SageThemeVar): string { + return `var(${sageThemeVars[name]})`; +} + +export function rawCssVar(name: SageThemeVar): string { + return `var(${sageThemeVars[name]})`; +} + +function normalizeCssVarValue(value: string): string { + const trimmed = value.trim(); + + const hslTuple = + /^-?\d+(\.\d+)?(?:deg|rad|turn)?\s+-?\d+(\.\d+)?%\s+-?\d+(\.\d+)?%(?:\s*\/\s*(?:\d+(\.\d+)?%?|\.\d+))?$/; + + if (hslTuple.test(trimmed)) { + return `hsl(${trimmed})`; + } + + return trimmed; +} + +export function applySageThemeCssVars( + theme: Generated.EnvironmentThemeView, +): void { + let el = document.getElementById('sage-environment-theme-vars'); + + if (!el) { + el = document.createElement('style'); + el.id = 'sage-environment-theme-vars'; + document.head.appendChild(el); + } + + const vars = Object.entries(theme.cssVars) + .filter((entry): entry is [string, string] => { + const [key, value] = entry; + + return ( + key.startsWith('--') && + typeof value === 'string' && + value.trim().length > 0 + ); + }) + .map(([key, value]) => `${key}: ${normalizeCssVarValue(value)};`) + .join(' '); + + el.textContent = vars ? `:root { ${vars} }` : ''; +} + +export function clearSageThemeCssVars(): void { + document.getElementById('sage-environment-theme-vars')?.remove(); +} diff --git a/packages/sage-app-sdk/src/theme/theme-css.d.ts b/packages/sage-app-sdk/src/theme/theme-css.d.ts new file mode 100644 index 000000000..3b7af61fc --- /dev/null +++ b/packages/sage-app-sdk/src/theme/theme-css.d.ts @@ -0,0 +1,4 @@ +declare module '@sage-app/sdk/theme.css' { + const css: string; + export default css; +} diff --git a/packages/sage-app-sdk/src/theme/theme.css b/packages/sage-app-sdk/src/theme/theme.css new file mode 100644 index 000000000..e7f68f0cb --- /dev/null +++ b/packages/sage-app-sdk/src/theme/theme.css @@ -0,0 +1,21 @@ +html, +body, +#root { + background: transparent; +} + +body { + margin: 0; + color: hsl(var(--foreground)); +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + + body { + background: transparent; + color: hsl(var(--foreground)); + } +} diff --git a/packages/sage-app-sdk/src/types.ts b/packages/sage-app-sdk/src/types.ts new file mode 100644 index 000000000..ef4dc62fb --- /dev/null +++ b/packages/sage-app-sdk/src/types.ts @@ -0,0 +1,117 @@ +import type * as Generated from './generated-types'; + +export * from './generated-types'; + +export type SageBridgeVersion = 'v1'; + +export type SageBridgeSendPayload = { + kind: string; + [key: string]: unknown; +}; + +export type SageBridgeSuccessResponse = { + bridgeVersion: SageBridgeVersion; + id: string; + ok: true; + result: unknown; +}; + +export type SageBridgeErrorResponse = { + bridgeVersion: SageBridgeVersion; + id: string; + ok: false; + error: { + code: string; + message: string; + }; +}; + +export type SageWalletClient = { + getKey(input: Generated.GetKey): Promise; + getSecretKey( + input: Generated.GetSecretKey, + ): Promise; + + getSyncStatus(): Promise; + getVersion(): Promise; + getPendingTransactions(): Promise; + getXchUsdPrice(): Promise; + + checkAddress( + input: Generated.CheckAddress, + ): Promise; + getDerivations( + input: Generated.GetDerivations, + ): Promise; + getSpendableCoinCount( + input: Generated.GetSpendableCoinCount, + ): Promise; + getCoinsByIds( + input: Generated.GetCoinsByIds, + ): Promise; + getCoins(input: Generated.GetCoins): Promise; + getTransaction( + input: Generated.GetTransaction, + ): Promise; + getTransactions( + input: Generated.GetTransactions, + ): Promise; + + sendXch( + input: Generated.WalletSendXchParams, + ): Promise; +}; + +export type SageAppLifecycleClient = { + onBeforeStop( + handler: (event: Generated.BeforeStopEvent) => void | Promise, + ): () => void; +}; + +export type SageAppClient = { + bridgePing(): Promise; + bridgeSend(input: SageBridgeSendPayload): Promise; + getInfo(): Promise; + getCapabilities(): Promise; + requestCapabilityGrant( + input: Generated.RequestCapabilityGrantParams, + ): Promise; + requestNetworkWhitelistGrant( + input: Generated.RequestNetworkWhitelistGrantParams, + ): Promise; + onGrantedCapabilitiesChange( + handler: (event: Generated.GrantedCapabilitiesChangeEvent) => void, + ): () => void; + onGrantedNetworkWhitelistChange( + handler: (event: Generated.GrantedNetworkWhitelistChangeEvent) => void, + ): () => void; + lifecycle: SageAppLifecycleClient; +}; + +export type SageEnvironmentThemeClient = { + getCurrent(): Promise; + + onChanged( + handler: (event: Generated.EnvironmentThemeChangedEvent) => void, + ): () => void; + + mountCssVars(): Promise<() => void>; +}; + +export type SageEnvironmentClient = { + theme: SageEnvironmentThemeClient; + getNetwork(): Promise; +}; + +export type SageClient = { + initialAppInfo: Generated.AppGetInfoResult; + app: SageAppClient; + wallet: SageWalletClient; + environment: SageEnvironmentClient; +}; + +export type { + RuntimeAckResult, + ReadyToStopParams, + SetBeforeStopListenerParams, +} from './generated-types'; diff --git a/packages/sage-app-sdk/tsconfig.json b/packages/sage-app-sdk/tsconfig.json new file mode 100644 index 000000000..db068b8a8 --- /dev/null +++ b/packages/sage-app-sdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM"], + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/sage-app-sdk/tsup.config.ts b/packages/sage-app-sdk/tsup.config.ts new file mode 100644 index 000000000..1bf5dc1a1 --- /dev/null +++ b/packages/sage-app-sdk/tsup.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: { + index: 'src/index.ts', + }, + format: ['esm'], + dts: true, + splitting: false, + outDir: 'dist', + clean: true, + }, + { + entry: { + 'runtime-bridge': 'src/runtime-bridge-entry.ts', + }, + format: ['esm'], + dts: false, + splitting: false, + outDir: 'dist', + clean: false, + }, +]); diff --git a/packages/sage-app-ui/package.json b/packages/sage-app-ui/package.json new file mode 100644 index 000000000..5d5bfe85c --- /dev/null +++ b/packages/sage-app-ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "@sage-app/ui", + "private": true, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "dependencies": { + "@sage-system-app/sdk": "workspace:*", + "lucide-react": "^0.468.0", + "react": "^18.3.1" + }, + "devDependencies": { + "typescript": "^5.9.3", + "tsup": "^8.5.0" + }, + "scripts": { + "build": "tsup" + } +} diff --git a/packages/sage-app-ui/src/components/AppIcon.tsx b/packages/sage-app-ui/src/components/AppIcon.tsx new file mode 100644 index 000000000..7515eea42 --- /dev/null +++ b/packages/sage-app-ui/src/components/AppIcon.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from 'react'; +import { SageAppCommonView } from '@sage-system-app/sdk'; + +export type AppIcon = + | { kind: 'url'; iconUrl: string } + | { kind: 'bytes'; icon: AppIconBytes }; + +export type AppIconBytes = { + bytes: number[]; + mime: string; +}; + +export function AppIcon({ + appName, + appIcon, +}: { + appName: string; + appIcon: AppIcon | null; +}) { + if (!appIcon) { + return ; + } + if (appIcon.kind === 'url') { + return ( + + ); + } + if (appIcon.kind === 'bytes') { + return ( + + ); + } + + return ; +} + +export function AppIconFallback({ + name, + className, +}: { + name: string; + className?: string; +}) { + return ( +
+ {name.trim().charAt(0).toUpperCase() || 'A'} +
+ ); +} + +export function AppIconFromUrl({ + name, + iconUrl, + className, +}: { + name: string; + iconUrl: string | null; + className?: string; +}) { + if (iconUrl) { + return ( + + ); + } + + return ; +} + +export function AppIconFromBytes({ + name, + icon, + className, +}: { + name: string; + icon: AppIconBytes | null; + className?: string; +}) { + const iconUrl = useMemo(() => { + if (!icon) return null; + + return URL.createObjectURL( + new Blob([new Uint8Array(icon.bytes)], { type: icon.mime }), + ); + }, [icon]); + + useEffect(() => { + return () => { + if (iconUrl) URL.revokeObjectURL(iconUrl); + }; + }, [iconUrl]); + + return ; +} + +export function AppIconFromCommonView({ + common, +}: { + common: SageAppCommonView; +}) { + const icon = common.icon; + + if (!icon) { + return ( + + ); + } + + return ( + + ); +} + +export function appIconFromCommonView( + common: SageAppCommonView, +): AppIcon | null { + const icon = common.icon; + + if (!icon) { + return null; + } + + return { + kind: 'bytes', + icon: { + bytes: icon.bytes, + mime: icon.mime, + }, + }; +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/CapabilityHelp.tsx b/packages/sage-app-ui/src/components/PermissionsEditor/CapabilityHelp.tsx new file mode 100644 index 000000000..657bda001 --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/CapabilityHelp.tsx @@ -0,0 +1,33 @@ +import { CircleHelp } from 'lucide-react'; +import { resolveBackgroundTintWithAlpha } from '../../presentation'; + +export function CapabilityHelp({ + description, +}: { + description: string | null; +}) { + if (!description) return null; + + return ( + + + + + {description} + + + ); +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/PermissionGroupBlock.tsx b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionGroupBlock.tsx new file mode 100644 index 000000000..59192ed2f --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionGroupBlock.tsx @@ -0,0 +1,89 @@ +import { Globe, HardDrive, KeyRound, Radio, Shield } from 'lucide-react'; +import type { PermissionEntry, PermissionGroupNode } from './types'; +import { normalizeKey } from './utils'; +import { PermissionRow } from './PermissionRow'; + +function groupIcon(node: PermissionGroupNode) { + const normalized = normalizeKey(node.id); + + if (normalized === 'network') return ; + + if (normalized === 'storage.persistent_webview') { + return ; + } + + if (normalized.includes('secret') || normalized.includes('wallet')) { + return ; + } + + if (normalized.includes('send') || normalized.includes('submit')) { + return ; + } + + return ; +} + +export function PermissionGroupBlock({ + node, + editable, + onToggleEntry, +}: { + node: PermissionGroupNode; + editable: boolean; + onToggleEntry: (entry: PermissionEntry, nextGranted: boolean) => void; +}) { + const items = [ + ...node.entries.map((entry) => ({ + kind: 'entry' as const, + id: entry.id, + entry, + })), + ...node.children.map((child) => ({ + kind: 'child' as const, + id: child.id, + child, + })), + ]; + + return ( +
+
+
{groupIcon(node)}
+
{node.label}
+
+ +
+ {items.map((item, index) => { + const isLast = index === items.length - 1; + + return ( +
+
+
+ + {item.kind === 'entry' ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/PermissionRow.tsx b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionRow.tsx new file mode 100644 index 000000000..e0d5d4a6d --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionRow.tsx @@ -0,0 +1,123 @@ +import type { NetworkPermissionScheme, PermissionEntry } from './types'; +import { CapabilityHelp } from './CapabilityHelp'; + +function NetworkSchemeButton({ + scheme, + checked, + disabled, + onClick, +}: { + scheme: NetworkPermissionScheme; + checked: boolean; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function PermissionRow({ + entry, + editable, + onToggle, +}: { + entry: PermissionEntry; + editable: boolean; + onToggle: ( + entry: PermissionEntry, + nextGranted: boolean, + scheme?: NetworkPermissionScheme, + ) => void; +}) { + if (entry.kind === 'network') { + const visibleSchemes = (['https', 'wss'] as const).filter( + (scheme) => entry.schemes[scheme].visible, + ); + + const primaryScheme = visibleSchemes.includes('wss') ? 'wss' : 'https'; + const primaryState = entry.schemes[primaryScheme]; + const checkboxDisabled = !editable || primaryState.disabled; + + return ( +
+ { + onToggle(entry, event.target.checked, primaryScheme); + }} + className='h-4 w-4 shrink-0' + /> + +
+ {entry.host} + +
+ {visibleSchemes.map((scheme, index) => { + const state = entry.schemes[scheme]; + + return ( + + ); + })} +
+
+
+ ); + } + + return ( + + ); +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/PermissionSection.tsx b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionSection.tsx new file mode 100644 index 000000000..b77bcfe9d --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionSection.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react'; +import type { + NetworkPermissionScheme, + PermissionEntry, + PermissionGroupNode, +} from './types'; +import { PermissionGroupBlock } from './PermissionGroupBlock'; + +export function PermissionSection({ + title, + groups, + editable, + separated, + trailingAction, + onToggleEntry, +}: { + title: string; + groups: PermissionGroupNode[]; + editable: boolean; + separated?: boolean; + trailingAction?: ReactNode; + onToggleEntry: ( + entry: PermissionEntry, + nextGranted: boolean, + scheme?: NetworkPermissionScheme, + ) => void; +}) { + if (groups.length === 0 && !trailingAction) return null; + + return ( +
+ {separated ?
: null} + +
+

{title}

+
+ + {groups.length > 0 ? ( +
+ {groups.map((group) => ( + + ))} +
+ ) : null} + + {trailingAction ?
{trailingAction}
: null} +
+ ); +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/PermissionsEditor.tsx b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionsEditor.tsx new file mode 100644 index 000000000..968dba6a5 --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/PermissionsEditor.tsx @@ -0,0 +1,546 @@ +import { useMemo, useState } from 'react'; +import type { + SageAppCapabilityDefinitionView, + SageGrantedPermissionsInput, + SageGrantedPermissionsView, + SageNetworkWhitelistEntry, + SystemSageAppView, + UserBridgeCapability, + UserSageAppView, +} from '@sage-system-app/sdk'; +import type { NetworkPermissionScheme, PermissionEntry } from './types'; +import { + buildCapabilityEntries, + buildNetworkEntries, + capabilityDefinitionMap, + isUserGrantableCapability, + sortPermissionEntries, +} from './permissionEntries'; +import { buildGroupedPermissionTree } from './permissionTree'; +import { networkKey, sortNetworkEntries } from './utils'; +import { PermissionSection } from './PermissionSection'; + +interface AppPermissionEditorProps { + app: UserSageAppView | SystemSageAppView; + grantedPermissions: SageGrantedPermissionsView; + capabilityDefinitions: SageAppCapabilityDefinitionView[]; + onGrantedPermissionsChange?: (next: SageGrantedPermissionsInput) => void; + editable?: boolean; + emptyText?: string; +} + +interface UpdateDecisionPermissionEditorProps { + app: UserSageAppView; + capabilityDefinitions: SageAppCapabilityDefinitionView[]; + emptyText?: string; +} + +interface SharedPermissionEditorProps { + requestedPermissions: RequestedPermissionsView; + grantedPermissions: SageGrantedPermissionsView; + capabilityDefinitions: SageAppCapabilityDefinitionView[]; + onGrantedPermissionsChange?: (next: SageGrantedPermissionsInput) => void; + editable?: boolean; + emptyText?: string; +} + +type RequestedPermissionsView = { + capabilities?: { + required?: UserBridgeCapability[]; + optional?: UserBridgeCapability[]; + }; + network?: { + whitelist?: { + required?: SageNetworkWhitelistEntry[]; + optional?: SageNetworkWhitelistEntry[]; + }; + whitelistByNetwork?: Partial< + Record< + string, + { + required?: SageNetworkWhitelistEntry[]; + optional?: SageNetworkWhitelistEntry[]; + } + > + >; + }; +}; + +type NetworkWhitelistByNetwork = Partial< + Record +>; + +export function AppPermissionEditor({ + app, + grantedPermissions, + capabilityDefinitions, + onGrantedPermissionsChange, + editable = true, + emptyText, +}: AppPermissionEditorProps) { + return ( + + ); +} + +export function UpdateDecisionPermissionEditor({ + app, + capabilityDefinitions, + emptyText = 'This update does not require any new permissions.', +}: UpdateDecisionPermissionEditorProps) { + const decision = app.pendingUpdate?.decision; + + const grantedPermissions = useMemo(() => { + if (decision?.kind !== 'review') { + return emptyGrantedPermissions(); + } + + return { + capabilities: decision.requiredUserGrantableCapabilities ?? [], + network: { + whitelist: decision.requiredNetworkWhitelist ?? [], + whitelistByNetwork: decision.requiredNetworkWhitelistByNetwork ?? {}, + }, + }; + }, [decision]); + + const requestedPermissions = useMemo(() => { + if (decision?.kind !== 'review') { + return {}; + } + + return { + capabilities: { + required: decision.requiredUserGrantableCapabilities ?? [], + optional: [], + }, + network: { + whitelist: { + required: decision.requiredNetworkWhitelist ?? [], + optional: [], + }, + whitelistByNetwork: Object.fromEntries( + Object.entries(decision.requiredNetworkWhitelistByNetwork ?? {}).map( + ([networkId, entries]) => [ + networkId, + { + required: entries, + optional: [], + }, + ], + ), + ), + }, + }; + }, [decision]); + + return ( + + ); +} + +function SharedPermissionEditor({ + requestedPermissions, + grantedPermissions, + capabilityDefinitions, + onGrantedPermissionsChange, + editable = true, + emptyText = 'This app does not request any permissions.', +}: SharedPermissionEditorProps) { + const definitionsByKey = useMemo( + () => capabilityDefinitionMap(capabilityDefinitions), + [capabilityDefinitions], + ); + + const grantedCapabilities = useMemo( + () => grantedPermissions.capabilities ?? [], + [grantedPermissions.capabilities], + ); + + const grantedNetworkWhitelist = useMemo( + () => grantedPermissions.network.whitelist ?? [], + [grantedPermissions.network.whitelist], + ); + + const grantedNetworkWhitelistByNetwork = useMemo( + () => grantedPermissions.network.whitelistByNetwork ?? {}, + [grantedPermissions.network.whitelistByNetwork], + ); + + const requestedRequiredCapabilities = + requestedPermissions.capabilities?.required ?? []; + + const requestedOptionalCapabilities = + requestedPermissions.capabilities?.optional ?? []; + + const requestedRequiredNetwork = + requestedPermissions.network?.whitelist?.required ?? []; + + const requestedOptionalNetwork = + requestedPermissions.network?.whitelist?.optional ?? []; + + const requestedNetworkByNetwork = + requestedPermissions.network?.whitelistByNetwork ?? {}; + + const userGrantableRequiredCapabilities = useMemo( + () => + requestedRequiredCapabilities.filter((capability) => + isUserGrantableCapability(capability, definitionsByKey), + ), + [requestedRequiredCapabilities, definitionsByKey], + ); + + const requiredEntries = useMemo(() => { + const capabilityEntries = buildCapabilityEntries( + requestedRequiredCapabilities, + [], + grantedCapabilities, + definitionsByKey, + ); + + const sharedNetworkEntries = buildNetworkEntries( + requestedRequiredNetwork, + requestedOptionalNetwork, + grantedNetworkWhitelist, + 'required', + null, + ); + + const networkSpecificEntries = Object.entries( + requestedNetworkByNetwork, + ).flatMap(([networkId, whitelist]) => + buildNetworkEntries( + whitelist?.required ?? [], + whitelist?.optional ?? [], + grantedNetworkWhitelistByNetwork[networkId] ?? [], + 'required', + networkId, + ), + ); + + return sortPermissionEntries([ + ...capabilityEntries, + ...sharedNetworkEntries, + ...networkSpecificEntries, + ]); + }, [ + requestedRequiredCapabilities, + grantedCapabilities, + requestedRequiredNetwork, + requestedOptionalNetwork, + grantedNetworkWhitelist, + requestedNetworkByNetwork, + grantedNetworkWhitelistByNetwork, + definitionsByKey, + ]); + + const optionalEntries = useMemo(() => { + const capabilityEntries = buildCapabilityEntries( + [], + requestedOptionalCapabilities, + grantedCapabilities, + definitionsByKey, + ); + + const sharedNetworkEntries = buildNetworkEntries( + requestedRequiredNetwork, + requestedOptionalNetwork, + grantedNetworkWhitelist, + 'optional', + null, + ); + + const networkSpecificEntries = Object.entries( + requestedNetworkByNetwork, + ).flatMap(([networkId, whitelist]) => + buildNetworkEntries( + whitelist?.required ?? [], + whitelist?.optional ?? [], + grantedNetworkWhitelistByNetwork[networkId] ?? [], + 'optional', + networkId, + ), + ); + + return sortPermissionEntries([ + ...capabilityEntries, + ...sharedNetworkEntries, + ...networkSpecificEntries, + ]); + }, [ + requestedOptionalCapabilities, + grantedCapabilities, + requestedRequiredNetwork, + requestedOptionalNetwork, + grantedNetworkWhitelist, + requestedNetworkByNetwork, + grantedNetworkWhitelistByNetwork, + definitionsByKey, + ]); + + function emitGrantedPermissions(next: SageGrantedPermissionsInput) { + onGrantedPermissionsChange?.(next); + } + + function keyToNetworkEntry(key: string): SageNetworkWhitelistEntry | null { + const [scheme, host] = key.split('://'); + + if (!scheme || !host) { + return null; + } + + return { scheme, host }; + } + + function handleToggleEntry( + entry: PermissionEntry, + nextGranted: boolean, + scheme?: NetworkPermissionScheme, + ) { + if (!editable) { + return; + } + + if (entry.kind === 'capability') { + if (entry.required) { + return; + } + + const nextSet = new Set(grantedCapabilities); + + if (nextGranted) { + nextSet.add(entry.capability); + } else { + nextSet.delete(entry.capability); + } + + for (const requiredCapability of userGrantableRequiredCapabilities) { + nextSet.add(requiredCapability); + } + + emitGrantedPermissions({ + capabilities: [...nextSet].sort((a, b) => a.localeCompare(b)), + network: { + whitelist: grantedNetworkWhitelist, + whitelistByNetwork: grantedNetworkWhitelistByNetwork, + }, + }); + + return; + } + + if (!scheme) { + return; + } + + const networkId = entry.networkId; + + const currentWhitelist: SageNetworkWhitelistEntry[] = + networkId === null + ? grantedNetworkWhitelist + : (grantedNetworkWhitelistByNetwork[networkId] ?? []); + + const requiredNetwork: SageNetworkWhitelistEntry[] = + networkId === null + ? requestedRequiredNetwork + : (requestedNetworkByNetwork[networkId]?.required ?? []); + + const nextKeys = new Set( + currentWhitelist.map((item) => networkKey(item)), + ); + + for (const requiredEntry of requiredNetwork) { + nextKeys.add(networkKey(requiredEntry)); + } + + const httpsKey = `https://${entry.host}`; + const wssKey = `wss://${entry.host}`; + + if (scheme === 'https') { + if (nextGranted) { + nextKeys.add(httpsKey); + } else if (!nextKeys.has(wssKey)) { + nextKeys.delete(httpsKey); + } + } + + if (scheme === 'wss') { + if (nextGranted) { + nextKeys.add(wssKey); + } else { + nextKeys.delete(wssKey); + } + } + + const requestedKeys = + networkId === null + ? new Set([ + ...requestedRequiredNetwork.map(networkKey), + ...requestedOptionalNetwork.map(networkKey), + ]) + : new Set([ + ...(requestedNetworkByNetwork[networkId]?.required ?? []).map( + networkKey, + ), + ...(requestedNetworkByNetwork[networkId]?.optional ?? []).map( + networkKey, + ), + ]); + + for (const key of Array.from(nextKeys)) { + if (!requestedKeys.has(key)) { + nextKeys.delete(key); + } + } + + const nextWhitelist = Array.from(nextKeys) + .map(keyToNetworkEntry) + .filter((item): item is SageNetworkWhitelistEntry => item !== null); + + if (networkId !== null) { + emitGrantedPermissions({ + capabilities: grantedCapabilities, + network: { + whitelist: grantedNetworkWhitelist, + whitelistByNetwork: { + ...grantedNetworkWhitelistByNetwork, + [networkId]: sortNetworkEntries(nextWhitelist), + }, + }, + }); + + return; + } + + emitGrantedPermissions({ + capabilities: grantedCapabilities, + network: { + whitelist: sortNetworkEntries(nextWhitelist), + whitelistByNetwork: grantedNetworkWhitelistByNetwork, + }, + }); + } + + return ( + + ); +} + +function PermissionSections({ + requiredEntries, + optionalEntries, + editable, + emptyText, + onToggleEntry, +}: { + requiredEntries: PermissionEntry[]; + optionalEntries: PermissionEntry[]; + editable: boolean; + emptyText: string; + onToggleEntry: ( + entry: PermissionEntry, + nextGranted: boolean, + scheme?: NetworkPermissionScheme, + ) => void; +}) { + const [showAllOptional, setShowAllOptional] = useState(false); + + const grantedOptionalEntries = useMemo( + () => optionalEntries.filter((entry) => entry.granted), + [optionalEntries], + ); + + const ungrantedOptionalEntries = useMemo( + () => optionalEntries.filter((entry) => !entry.granted), + [optionalEntries], + ); + + const visibleOptionalEntries = useMemo( + () => (showAllOptional ? optionalEntries : grantedOptionalEntries), + [showAllOptional, optionalEntries, grantedOptionalEntries], + ); + + const requiredGroups = useMemo( + () => buildGroupedPermissionTree(requiredEntries), + [requiredEntries], + ); + + const optionalGroups = useMemo( + () => buildGroupedPermissionTree(visibleOptionalEntries), + [visibleOptionalEntries], + ); + + if (requiredEntries.length === 0 && optionalEntries.length === 0) { + return ( +
+ {emptyText} +
+ ); + } + + return ( +
+ {requiredGroups.length > 0 ? ( + + ) : null} + + {optionalEntries.length > 0 ? ( + 0} + onToggleEntry={onToggleEntry} + trailingAction={ + !showAllOptional && ungrantedOptionalEntries.length > 0 ? ( + + ) : null + } + /> + ) : null} +
+ ); +} + +function emptyGrantedPermissions(): SageGrantedPermissionsView { + return { + capabilities: [], + network: { + whitelist: [], + whitelistByNetwork: {}, + }, + }; +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/index.ts b/packages/sage-app-ui/src/components/PermissionsEditor/index.ts new file mode 100644 index 000000000..549a9b758 --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/index.ts @@ -0,0 +1,9 @@ +export { + AppPermissionEditor, + UpdateDecisionPermissionEditor, +} from './PermissionsEditor'; +export { + emptyGrantedPermissionsInput, + initialGrantedPermissionsInput, + inputToGrantedPermissionsView, +} from './utils'; diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/permissionEntries.ts b/packages/sage-app-ui/src/components/PermissionsEditor/permissionEntries.ts new file mode 100644 index 000000000..022d3552f --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/permissionEntries.ts @@ -0,0 +1,200 @@ +import type { + SageAppCapabilityDefinitionView, + SageNetworkWhitelistEntry, + UserBridgeCapability, +} from '@sage-system-app/sdk'; +import type { + NetworkPermissionScheme, + NetworkPermissionSchemeState, + PermissionEntry, +} from './types'; +import { formatCapabilityLeafLabel, networkKey } from './utils'; + +export function capabilitySensitivityRank(key: string): number { + if (key.includes('secret')) return 0; + if (key === 'storage.persistent_webview') return 2; + if (key.includes('send') || key.includes('network')) return 3; + return 4; +} + +export function capabilityDefinitionMap( + definitions: SageAppCapabilityDefinitionView[], +): Map { + return new Map( + definitions.map((definition) => [ + definition.key as UserBridgeCapability, + definition, + ]), + ); +} + +export function isUserGrantableCapability( + capability: UserBridgeCapability, + definitionsByKey: Map, +): boolean { + return definitionsByKey.get(capability)?.flags.userGrantable === true; +} + +export function buildCapabilityEntries( + requestedRequired: UserBridgeCapability[], + requestedOptional: UserBridgeCapability[], + grantedCapabilities: UserBridgeCapability[], + definitionsByKey: Map, +): PermissionEntry[] { + const grantedSet = new Set(grantedCapabilities); + + const requiredEntries: PermissionEntry[] = requestedRequired + .filter((capability) => + isUserGrantableCapability(capability, definitionsByKey), + ) + .map((capability) => { + const definition = definitionsByKey.get(capability); + const key = capability; + + return { + id: `capability:${key}`, + kind: 'capability', + key, + capability, + label: definition?.label ?? formatCapabilityLeafLabel(key), + description: definition?.description ?? null, + required: true, + granted: true, + sensitivityRank: capabilitySensitivityRank(key), + }; + }); + + const optionalEntries: PermissionEntry[] = requestedOptional + .filter((capability) => + isUserGrantableCapability(capability, definitionsByKey), + ) + .map((capability) => { + const definition = definitionsByKey.get(capability); + const key = capability; + + return { + id: `capability:${key}`, + kind: 'capability', + key, + capability, + label: definition?.label ?? formatCapabilityLeafLabel(key), + description: definition?.description ?? null, + required: false, + granted: grantedSet.has(capability), + sensitivityRank: capabilitySensitivityRank(key), + }; + }); + + return [...requiredEntries, ...optionalEntries]; +} + +export function buildNetworkEntries( + requestedRequired: SageNetworkWhitelistEntry[], + requestedOptional: SageNetworkWhitelistEntry[], + grantedNetworkWhitelist: SageNetworkWhitelistEntry[], + section: 'required' | 'optional', + networkId: string | null = null, +): PermissionEntry[] { + const requiredKeys = new Set(requestedRequired.map(networkKey)); + const optionalKeys = new Set(requestedOptional.map(networkKey)); + const grantedKeys = new Set(grantedNetworkWhitelist.map(networkKey)); + const hosts = new Set(); + for (const entry of [...requestedRequired, ...requestedOptional]) { + if (isSupportedNetworkScheme(entry.scheme)) { + hosts.add(entry.host); + } + } + const entries: PermissionEntry[] = []; + + for (const host of hosts) { + const httpsKey = schemeKey('https', host); + const wssKey = schemeKey('wss', host); + const httpsRequested = + requiredKeys.has(httpsKey) || optionalKeys.has(httpsKey); + const wssRequested = requiredKeys.has(wssKey) || optionalKeys.has(wssKey); + const hostHasRequired = + requiredKeys.has(httpsKey) || requiredKeys.has(wssKey); + + if (section === 'required' && !hostHasRequired) continue; + if (section === 'optional' && hostHasRequired) continue; + const wssRequired = requiredKeys.has(wssKey); + const wssGranted = wssRequired || grantedKeys.has(wssKey); + const httpsRequired = requiredKeys.has(httpsKey); + const httpsGranted = + httpsRequired || wssGranted || grantedKeys.has(httpsKey); + const httpsVisible = httpsRequested || wssRequested; + + const schemes: Record< + NetworkPermissionScheme, + NetworkPermissionSchemeState + > = { + https: { + scheme: 'https', + key: httpsKey, + required: httpsRequired || wssRequired, + granted: httpsGranted, + disabled: httpsRequired || wssGranted, + visible: httpsVisible, + }, + wss: { + scheme: 'wss', + key: wssKey, + required: wssRequired, + granted: wssGranted, + disabled: wssRequired, + visible: wssRequested, + }, + }; + + entries.push({ + id: + networkId === null ? `network:${host}` : `network:${networkId}:${host}`, + kind: 'network', + key: networkId === null ? host : `${networkId}:${host}`, + host, + networkId, + label: host, + description: null, + required: hostHasRequired, + granted: httpsGranted || wssGranted, + sensitivityRank: 1, + schemes, + }); + } + + return entries; +} + +export function sortPermissionEntries( + entries: PermissionEntry[], +): PermissionEntry[] { + return [...entries].sort((a, b) => { + if (a.sensitivityRank !== b.sensitivityRank) { + return a.sensitivityRank - b.sensitivityRank; + } + + if (a.kind !== b.kind) { + return a.kind.localeCompare(b.kind); + } + + return a.key.localeCompare(b.key); + }); +} + +function isSupportedNetworkScheme( + scheme: string, +): scheme is NetworkPermissionScheme { + return scheme === 'https' || scheme === 'wss'; +} + +function makeNetworkEntry( + scheme: NetworkPermissionScheme, + + host: string, +): SageNetworkWhitelistEntry { + return { scheme, host }; +} + +function schemeKey(scheme: NetworkPermissionScheme, host: string): string { + return networkKey(makeNetworkEntry(scheme, host)); +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/permissionTree.ts b/packages/sage-app-ui/src/components/PermissionsEditor/permissionTree.ts new file mode 100644 index 000000000..64f35ba70 --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/permissionTree.ts @@ -0,0 +1,166 @@ +import type { PermissionEntry, PermissionGroupNode } from './types'; +import { sortPermissionEntries } from './permissionEntries'; +import { normalizeKey, segmentLabel } from './utils'; + +function makeNode(id: string, label: string): PermissionGroupNode { + return { + id, + label, + children: [], + entries: [], + sensitivityRank: 999, + }; +} + +function updateNodeSensitivity( + node: PermissionGroupNode, + rank: number, +): PermissionGroupNode { + node.sensitivityRank = Math.min(node.sensitivityRank, rank); + return node; +} + +export function buildGroupedPermissionTree( + entries: PermissionEntry[], +): PermissionGroupNode[] { + const roots: PermissionGroupNode[] = []; + + const networkEntries = entries.filter((entry) => entry.kind === 'network'); + + if (networkEntries.length > 0) { + const networkNode = makeNode('network', 'Network access'); + networkNode.sensitivityRank = 1; + + const sharedEntries = networkEntries.filter( + (entry) => entry.networkId === null, + ); + const networkSpecificEntries = networkEntries.filter( + (entry) => entry.networkId !== null, + ); + + if (sharedEntries.length > 0) { + networkNode.entries = sortPermissionEntries(sharedEntries); + } + + const entriesByNetwork = new Map(); + + for (const entry of networkSpecificEntries) { + const networkId = entry.networkId; + if (networkId === null) continue; + + entriesByNetwork.set(networkId, [ + ...(entriesByNetwork.get(networkId) ?? []), + entry, + ]); + } + + for (const [networkId, entries] of entriesByNetwork) { + const child = makeNode(`network:${networkId}`, networkId); + child.entries = sortPermissionEntries(entries); + child.sensitivityRank = 1; + networkNode.children.push(child); + } + + networkNode.children.sort((a, b) => a.label.localeCompare(b.label)); + + roots.push(networkNode); + } + + const storageEntries = entries.filter( + (entry) => + entry.kind === 'capability' && + normalizeKey(entry.key).startsWith('storage'), + ); + + if (storageEntries.length > 0) { + const storageNode = makeNode('storage', 'Storage'); + storageNode.entries = sortPermissionEntries(storageEntries); + storageNode.sensitivityRank = 2; + roots.push(storageNode); + } + + const generalCapabilityEntries = entries.filter( + (entry) => + entry.kind === 'capability' && + normalizeKey(entry.key) !== 'storage.persistent_webview', + ); + + const capabilityRoot = makeNode('capabilities_root', 'Capabilities'); + + for (const entry of generalCapabilityEntries) { + const parts = entry.key.split('.'); + const leafParentParts = parts.slice(0, -1); + + if (leafParentParts.length === 0) { + capabilityRoot.entries.push(entry); + updateNodeSensitivity(capabilityRoot, entry.sensitivityRank); + continue; + } + + let current = capabilityRoot; + updateNodeSensitivity(current, entry.sensitivityRank); + + for (let index = 0; index < leafParentParts.length; index += 1) { + const segment = leafParentParts[index]; + const fullPath = leafParentParts.slice(0, index + 1).join('.'); + let child = current.children.find((node) => node.id === fullPath); + + if (!child) { + child = makeNode(fullPath, segmentLabel(segment)); + current.children.push(child); + } + + updateNodeSensitivity(child, entry.sensitivityRank); + current = child; + } + + current.entries.push(entry); + updateNodeSensitivity(current, entry.sensitivityRank); + } + + function sortNode(node: PermissionGroupNode) { + node.entries = sortPermissionEntries(node.entries); + + node.children.sort((a, b) => { + if (a.sensitivityRank !== b.sensitivityRank) { + return a.sensitivityRank - b.sensitivityRank; + } + + return a.label.localeCompare(b.label); + }); + + for (const child of node.children) { + sortNode(child); + } + } + + sortNode(capabilityRoot); + + if (capabilityRoot.entries.length > 0 || capabilityRoot.children.length > 0) { + roots.push(...capabilityRoot.children); + + if (capabilityRoot.entries.length > 0) { + const miscNode = makeNode('misc', 'Other capabilities'); + miscNode.entries = capabilityRoot.entries; + miscNode.sensitivityRank = capabilityRoot.sensitivityRank; + roots.push(miscNode); + } + } + + roots.sort((a, b) => { + if (a.sensitivityRank !== b.sensitivityRank) { + return a.sensitivityRank - b.sensitivityRank; + } + + return a.label.localeCompare(b.label); + }); + + return roots; +} + +export function countNodeEntries(node: PermissionGroupNode): number { + return ( + node.entries.length + + node.children.reduce((sum, child) => sum + countNodeEntries(child), 0) + ); +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/types.ts b/packages/sage-app-ui/src/components/PermissionsEditor/types.ts new file mode 100644 index 000000000..863cbdd13 --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/types.ts @@ -0,0 +1,46 @@ +import type { UserBridgeCapability } from '@sage-system-app/sdk'; + +export type NetworkPermissionScheme = 'https' | 'wss'; + +export interface NetworkPermissionSchemeState { + scheme: NetworkPermissionScheme; + key: string; + required: boolean; + granted: boolean; + disabled: boolean; + visible: boolean; +} + +export type PermissionEntry = + | { + id: string; + kind: 'capability'; + key: string; + capability: UserBridgeCapability; + label: string; + description: string | null; + required: boolean; + granted: boolean; + sensitivityRank: number; + } + | { + id: string; + kind: 'network'; + key: string; + host: string; + networkId: string | null; + label: string; + description: string | null; + required: boolean; + granted: boolean; + sensitivityRank: number; + schemes: Record; + }; + +export interface PermissionGroupNode { + id: string; + label: string; + children: PermissionGroupNode[]; + entries: PermissionEntry[]; + sensitivityRank: number; +} diff --git a/packages/sage-app-ui/src/components/PermissionsEditor/utils.ts b/packages/sage-app-ui/src/components/PermissionsEditor/utils.ts new file mode 100644 index 000000000..58a4f50b5 --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/utils.ts @@ -0,0 +1,135 @@ +import type { + SageGrantedPermissionsInput, + SageGrantedPermissionsView, + SageNetworkWhitelistEntry, + UserBridgeCapability, + SageAppCapabilityDefinitionView, + SageAppPackageManifest, +} from '@sage-system-app/sdk'; + +type RequestedWhitelistByNetwork = Record< + string, + { + required?: SageNetworkWhitelistEntry[]; + optional?: SageNetworkWhitelistEntry[]; + } +>; + +function sortCapabilities( + values: Iterable, +): UserBridgeCapability[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +export function isUserGrantableCapabilityDefinition( + definitions: SageAppCapabilityDefinitionView[], + capability: UserBridgeCapability, +): boolean { + return ( + definitions.find((definition) => definition.key === capability)?.flags + .userGrantable === true + ); +} + +export function requiredNetworkWhitelistByNetworkInput( + value: unknown, +): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(value as RequestedWhitelistByNetwork) + .map(([networkId, whitelist]) => [ + networkId, + sortNetworkEntries(whitelist?.required ?? []), + ]) + .filter(([, entries]) => entries.length > 0), + ); +} + +export function initialGrantedPermissionsInput( + manifest: SageAppPackageManifest, + definitions: SageAppCapabilityDefinitionView[], +): SageGrantedPermissionsInput { + return { + capabilities: sortCapabilities( + (manifest.permissions.capabilities.required ?? []).filter((capability) => + isUserGrantableCapabilityDefinition(definitions, capability), + ), + ), + network: { + whitelist: sortNetworkEntries( + manifest.permissions.network.whitelist.required ?? [], + ), + whitelistByNetwork: requiredNetworkWhitelistByNetworkInput( + manifest.permissions.network.whitelistByNetwork, + ), + }, + }; +} + +export function emptyGrantedPermissionsInput(): SageGrantedPermissionsInput { + return { + capabilities: [], + network: { + whitelist: [], + whitelistByNetwork: {}, + }, + }; +} + +export function cn(...classes: Array) { + return classes.filter(Boolean).join(' '); +} + +export function networkKey(entry: SageNetworkWhitelistEntry): string { + return `${entry.scheme}://${entry.host}`; +} + +export function sortNetworkEntries( + entries: SageNetworkWhitelistEntry[], +): SageNetworkWhitelistEntry[] { + return [...entries].sort((a, b) => + networkKey(a).localeCompare(networkKey(b)), + ); +} + +export function titleCasePart(value: string): string { + if (!value) return value; + return value.charAt(0).toUpperCase() + value.slice(1); +} + +export function segmentLabel(segment: string): string { + return segment.split('_').filter(Boolean).map(titleCasePart).join(' '); +} + +export function formatCapabilityLeafLabel(key: string): string { + const parts = key.split('.'); + return segmentLabel(parts[parts.length - 1] ?? key); +} + +export function normalizeKey(key: string): string { + return key.trim().toLowerCase(); +} + +export function inputToGrantedPermissionsView( + permissions: SageGrantedPermissionsInput, +): SageGrantedPermissionsView { + return { + capabilities: [...new Set(permissions.capabilities ?? [])].sort((a, b) => + a.localeCompare(b), + ) as UserBridgeCapability[], + network: { + whitelist: sortNetworkEntries(permissions.network?.whitelist ?? []), + whitelistByNetwork: Object.fromEntries( + Object.entries(permissions.network?.whitelistByNetwork ?? {}).map( + ([networkId, whitelist]) => [ + networkId, + sortNetworkEntries(whitelist ?? []), + ], + ), + ), + }, + }; +} diff --git a/packages/sage-app-ui/src/components/WalletScopeEditor.tsx b/packages/sage-app-ui/src/components/WalletScopeEditor.tsx new file mode 100644 index 000000000..723ceac17 --- /dev/null +++ b/packages/sage-app-ui/src/components/WalletScopeEditor.tsx @@ -0,0 +1,138 @@ +import type { SageAppWalletScope } from '@sage-system-app/sdk'; + +export type WalletScopeEditorWallet = { + fingerprint: number; + name: string; + emoji?: string | null; +}; + +interface WalletScopeEditorProps { + wallets: WalletScopeEditorWallet[]; + walletScope: SageAppWalletScope; + disabled?: boolean; + onWalletScopeChange: (scope: SageAppWalletScope) => void; +} + +function selectedFingerprints(scope: SageAppWalletScope): number[] { + return scope.kind === 'selectedWallets' ? scope.fingerprints : []; +} + +function walletLabel(wallet: WalletScopeEditorWallet): string { + return wallet.name || `Wallet ${wallet.fingerprint}`; +} + +export function WalletScopeEditor({ + wallets, + walletScope, + disabled = false, + onWalletScopeChange, +}: WalletScopeEditorProps) { + const selected = selectedFingerprints(walletScope); + + function toggleFingerprint(fingerprint: number) { + if (disabled || walletScope.kind !== 'selectedWallets') { + return; + } + + const next = selected.includes(fingerprint) + ? selected.filter((value) => value !== fingerprint) + : [...selected, fingerprint].sort((a, b) => a - b); + + onWalletScopeChange({ + kind: 'selectedWallets', + fingerprints: next, + }); + } + + return ( +
+
+
Wallet availability
+

+ Choose which wallets can use this app. +

+
+ +
+ + + +
+ + {walletScope.kind === 'selectedWallets' ? ( +
+ {wallets.length === 0 ? ( +
+ No wallets found. +
+ ) : ( + wallets.map((wallet, index) => { + const checked = selected.includes(wallet.fingerprint); + + return ( + + ); + }) + )} +
+ ) : null} +
+ ); +} diff --git a/packages/sage-app-ui/src/components/index.ts b/packages/sage-app-ui/src/components/index.ts new file mode 100644 index 000000000..51ccf231c --- /dev/null +++ b/packages/sage-app-ui/src/components/index.ts @@ -0,0 +1,3 @@ +export * from './AppIcon'; +export * from './PermissionsEditor'; +export * from './WalletScopeEditor'; diff --git a/packages/sage-app-ui/src/index.ts b/packages/sage-app-ui/src/index.ts new file mode 100644 index 000000000..a70ffbc4e --- /dev/null +++ b/packages/sage-app-ui/src/index.ts @@ -0,0 +1,2 @@ +export * from './components'; +export * from './presentation'; diff --git a/packages/sage-app-ui/src/presentation/index.ts b/packages/sage-app-ui/src/presentation/index.ts new file mode 100644 index 000000000..6db8ad2ae --- /dev/null +++ b/packages/sage-app-ui/src/presentation/index.ts @@ -0,0 +1,2 @@ +export * from './modal'; +export * from './utils'; diff --git a/packages/sage-app-ui/src/presentation/modal/AppModalShell.tsx b/packages/sage-app-ui/src/presentation/modal/AppModalShell.tsx new file mode 100644 index 000000000..c409e3e4d --- /dev/null +++ b/packages/sage-app-ui/src/presentation/modal/AppModalShell.tsx @@ -0,0 +1,137 @@ +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { SystemModalShell } from './SystemModalShell'; +import { AppIcon } from '../../components'; +import { resolveBackgroundTintWithAlpha } from '../utils'; + +interface AppModalShellProps { + title: string; + appName: string; + children: ReactNode; + appIcon?: AppIcon | null; + description?: string; + footer?: ReactNode; + className?: string; + bodyClassName?: string; + contentClassName?: string; + bodyPadded?: boolean; + requireScrollEnd?: boolean; + onScrollEndChange?: (reached: boolean) => void; +} + +export function AppModalShell({ + title, + appName, + appIcon, + footer, + children, + className = '', + bodyClassName = '', + contentClassName = '', + bodyPadded = true, + requireScrollEnd = false, + onScrollEndChange, +}: AppModalShellProps) { + const bodyRef = useRef(null); + const [scrolledToEnd, setScrolledToEnd] = useState(!requireScrollEnd); + + const updateScrolledToEnd = useCallback(() => { + if (!requireScrollEnd) { + setScrolledToEnd(true); + onScrollEndChange?.(true); + return; + } + + const body = bodyRef.current; + if (!body) return; + + const next = + body.scrollHeight <= body.clientHeight + 2 || + body.scrollTop + body.clientHeight >= body.scrollHeight - 2; + + setScrolledToEnd(next); + onScrollEndChange?.(next); + }, [requireScrollEnd, onScrollEndChange]); + + useEffect(() => { + setScrolledToEnd(!requireScrollEnd); + onScrollEndChange?.(!requireScrollEnd); + + const frame = requestAnimationFrame(updateScrolledToEnd); + + return () => cancelAnimationFrame(frame); + }, [requireScrollEnd, updateScrolledToEnd, onScrollEndChange]); + + return ( + +
+
+
+
+ +
+
+ +
+
+ {appName} +
+ +

+ {title} +

+
+
+ +
+ {children} +
+ + {footer ? ( +
+
+ + ↓ Scroll for more ↓ + +
+ + {footer} +
+ ) : null} +
+
+ ); +} diff --git a/packages/sage-app-ui/src/presentation/modal/SystemModalShell.tsx b/packages/sage-app-ui/src/presentation/modal/SystemModalShell.tsx new file mode 100644 index 000000000..39f4178d6 --- /dev/null +++ b/packages/sage-app-ui/src/presentation/modal/SystemModalShell.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; +import { resolveBackgroundTintWithAlpha } from '../utils'; + +interface SystemModalShellProps { + children: ReactNode; + className?: string; + contentClassName?: string; +} + +export function SystemModalShell({ + children, + className = '', + contentClassName = '', +}: SystemModalShellProps) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/packages/sage-app-ui/src/presentation/modal/index.ts b/packages/sage-app-ui/src/presentation/modal/index.ts new file mode 100644 index 000000000..bb446def6 --- /dev/null +++ b/packages/sage-app-ui/src/presentation/modal/index.ts @@ -0,0 +1,2 @@ +export * from './SystemModalShell'; +export * from './AppModalShell'; diff --git a/packages/sage-app-ui/src/presentation/utils.ts b/packages/sage-app-ui/src/presentation/utils.ts new file mode 100644 index 000000000..6d1905754 --- /dev/null +++ b/packages/sage-app-ui/src/presentation/utils.ts @@ -0,0 +1,38 @@ +export function resolveBackgroundTintWithAlpha(alpha: number = 0.85): string { + const tint = resolveBackgroundTint(); + + return colorWithAlpha(tint, alpha); +} + +function resolveBackgroundTint(): string { + const root = getComputedStyle(document.documentElement); + + const candidates = [ + root.getPropertyValue('--background').trim(), + root.getPropertyValue('--secondary').trim(), + root.getPropertyValue('--muted').trim(), + root.getPropertyValue('--card').trim(), + ]; + + return ( + candidates.find( + (value) => + value.length > 0 && + value !== 'transparent' && + value !== 'rgba(0, 0, 0, 0)', + ) ?? '#1d2530' + ); +} + +function colorWithAlpha(color: string, alpha: number): string { + if (color.startsWith('#')) { + return `color-mix(in srgb, ${color} ${alpha * 100}%, transparent)`; + } + + if (color.startsWith('rgb') || color.startsWith('hsl')) { + return `color-mix(in srgb, ${color} ${alpha * 100}%, transparent)`; + } + + // HSL channel format, e.g. "222 47% 11%" + return `hsl(${color} / ${alpha})`; +} diff --git a/packages/sage-app-ui/tsconfig.json b/packages/sage-app-ui/tsconfig.json new file mode 100644 index 000000000..702000f72 --- /dev/null +++ b/packages/sage-app-ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true + }, + "include": ["src"] +} diff --git a/packages/sage-app-ui/tsup.config.ts b/packages/sage-app-ui/tsup.config.ts new file mode 100644 index 000000000..541a8295e --- /dev/null +++ b/packages/sage-app-ui/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + external: ['react', 'react/jsx-runtime'], +}); diff --git a/packages/sage-system-app-sdk/.gitignore b/packages/sage-system-app-sdk/.gitignore new file mode 100644 index 000000000..16409d768 --- /dev/null +++ b/packages/sage-system-app-sdk/.gitignore @@ -0,0 +1,15 @@ +/tmp +/out-tsc + +/node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.pnp +.pnp.js + +.idea +.vscode/* +dist + +src/generated-types.ts diff --git a/packages/sage-system-app-sdk/package.json b/packages/sage-system-app-sdk/package.json new file mode 100644 index 000000000..98d27ca85 --- /dev/null +++ b/packages/sage-system-app-sdk/package.json @@ -0,0 +1,33 @@ +{ + "name": "@sage-system-app/sdk", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./runtime-bridge.js": { + "import": "./dist/runtime-bridge.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "generate:types": "node ./scripts/export-types.mjs", + "build": "pnpm run clean && pnpm run generate:types && tsup && tsc -p tsconfig.build.json", + "dev": "pnpm run generate:types && tsup --watch", + "clean": "rm -rf dist" + }, + "dependencies": { + "@sage-app/sdk": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.6.3" + } +} diff --git a/packages/sage-system-app-sdk/pnpm-lock.yaml b/packages/sage-system-app-sdk/pnpm-lock.yaml new file mode 100644 index 000000000..a4ab1c75b --- /dev/null +++ b/packages/sage-system-app-sdk/pnpm-lock.yaml @@ -0,0 +1,897 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + tsup: + specifier: ^8.2.4 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/estree@1.0.8': {} + + acorn@8.16.0: {} + + any-promise@1.3.0: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.1 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1: + dependencies: + lilconfig: 3.1.3 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + source-map@0.7.6: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1 + resolve-from: 5.0.0 + rollup: 4.60.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.3: {} diff --git a/packages/sage-system-app-sdk/scripts/export-types.mjs b/packages/sage-system-app-sdk/scripts/export-types.mjs new file mode 100644 index 000000000..bccac74c6 --- /dev/null +++ b/packages/sage-system-app-sdk/scripts/export-types.mjs @@ -0,0 +1,150 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const packageDir = process.cwd(); +const repoRoot = path.resolve(packageDir, '../..'); + +const systemOutPath = path.join(packageDir, 'src', 'generated-types.ts'); +const userTypesPath = path.join( + repoRoot, + 'packages', + 'sage-app-sdk', + 'src', + 'generated-types.ts', +); + +const systemSource = execFileSync( + 'cargo', + ['run', '-p', 'sage-apps', '--bin', 'export_bridge_types', '--', 'system'], + { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }, +); + +const userSource = fs.existsSync(userTypesPath) + ? fs.readFileSync(userTypesPath, 'utf8') + : ''; + +function collectExportedNames(source, fileName) { + const file = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + ); + + const names = new Set(); + + for (const node of file.statements) { + const modifiers = ts.getModifiers(node) ?? []; + const exported = modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + + if (!exported) continue; + + if ( + (ts.isTypeAliasDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isFunctionDeclaration(node)) && + node.name + ) { + names.add(node.name.text); + } + } + + return names; +} + +function stripDuplicateExportedDeclarations(source, fileName, duplicateNames) { + const file = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + ); + + const ranges = []; + + for (const node of file.statements) { + const modifiers = ts.getModifiers(node) ?? []; + const exported = modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + + if (!exported) continue; + + const name = + (ts.isTypeAliasDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isFunctionDeclaration(node)) && + node.name + ? node.name.text + : null; + + if (!name || !duplicateNames.has(name)) continue; + + ranges.push([node.getFullStart(), node.getEnd()]); + } + + let next = source; + + for (const [start, end] of ranges.sort((a, b) => b[0] - a[0])) { + next = next.slice(0, start) + next.slice(end); + } + + return next.replace(/\n{3,}/g, '\n\n').trimStart(); +} + +function collectReferencedDuplicateNames(source, duplicateNames) { + const referenced = []; + + for (const name of duplicateNames) { + const regex = new RegExp(`\\b${name}\\b`, 'g'); + + if (regex.test(source)) { + referenced.push(name); + } + } + + return referenced.sort(); +} + +function prependUserSdkTypeImports(source, names) { + if (names.length === 0) { + return source; + } + + return `import type {\n${names + .map((name) => ` ${name},`) + .join('\n')}\n} from '@sage-app/sdk';\n\n${source}`; +} + +const userNames = collectExportedNames(userSource, userTypesPath); + +let cleanedSystemSource = stripDuplicateExportedDeclarations( + systemSource, + 'system-generated-types.ts', + userNames, +); + +const importsFromUserSdk = collectReferencedDuplicateNames( + cleanedSystemSource, + userNames, +); + +cleanedSystemSource = prependUserSdkTypeImports( + cleanedSystemSource, + importsFromUserSdk, +); + +fs.writeFileSync(systemOutPath, cleanedSystemSource); +console.log(`Wrote ${path.relative(packageDir, systemOutPath)}`); diff --git a/packages/sage-system-app-sdk/src/client.ts b/packages/sage-system-app-sdk/src/client.ts new file mode 100644 index 000000000..7156ee293 --- /dev/null +++ b/packages/sage-system-app-sdk/src/client.ts @@ -0,0 +1,58 @@ +import type { SageSystemClient } from './types'; +import { + getSageSystemClient as getRuntimeSageSystemClient, + initSageSystemRuntimeBridge, +} from './runtime'; + +type SageSystemGlobal = typeof globalThis & { + __TAURI__?: unknown; + __SAGE_SYSTEM__?: SageSystemClient; +}; + +export function isSageSystemRuntimeAvailable(): boolean { + return !!(globalThis as SageSystemGlobal).__TAURI__; +} + +function getClientFromWindow(): SageSystemClient | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as SageSystemGlobal).__SAGE_SYSTEM__; +} + +export function isSageSystemBridgeInitialized(): boolean { + return !!getClientFromWindow(); +} + +export function hasSageSystemBridge(): boolean { + return !!getClientFromWindow(); +} + +export async function getSageSystemClient(): Promise { + return await getRuntimeSageSystemClient(); +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +export function formatSageError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + + if (isObject(err)) { + if (typeof err.message === 'string') return err.message; + if (typeof err.reason === 'string') return err.reason; + + try { + return JSON.stringify(err, null, 2); + } catch { + return 'Unknown Sage error'; + } + } + + return String(err); +} + +export { initSageSystemRuntimeBridge }; diff --git a/packages/sage-system-app-sdk/src/global.d.ts b/packages/sage-system-app-sdk/src/global.d.ts new file mode 100644 index 000000000..11c57332b --- /dev/null +++ b/packages/sage-system-app-sdk/src/global.d.ts @@ -0,0 +1,10 @@ +import type { SageSystemClient } from './types'; + +declare global { + interface Window { + __SAGE_SYSTEM__?: SageSystemClient; + __SAGE_SYSTEM_RUNTIME_BRIDGE_INITIALIZED__?: boolean; + } +} + +export {}; diff --git a/packages/sage-system-app-sdk/src/hooks.ts b/packages/sage-system-app-sdk/src/hooks.ts new file mode 100644 index 000000000..4738e3635 --- /dev/null +++ b/packages/sage-system-app-sdk/src/hooks.ts @@ -0,0 +1,17 @@ +import { getSageSystemClient } from './runtime'; + +type SageSystemClientResolved = Awaited>; + +let sageSystemClient: SageSystemClientResolved | null = null; +let sageSystemClientPromise: Promise | null = null; + +export function useSageSystemClient(): SageSystemClientResolved { + if (sageSystemClient) return sageSystemClient; + + sageSystemClientPromise ??= getSageSystemClient().then((client) => { + sageSystemClient = client; + return client; + }); + + throw sageSystemClientPromise; +} diff --git a/packages/sage-system-app-sdk/src/index.ts b/packages/sage-system-app-sdk/src/index.ts new file mode 100644 index 000000000..0b8e3f7a0 --- /dev/null +++ b/packages/sage-system-app-sdk/src/index.ts @@ -0,0 +1,15 @@ +export { + initSageSystemRuntimeBridge, + SAGE_SYSTEM_BRIDGE_VERSION, +} from './runtime'; + +export { + isSageSystemRuntimeAvailable, + isSageSystemBridgeInitialized, + formatSageError, + getSageSystemClient, + hasSageSystemBridge, +} from './client'; + +export * from './types'; +export * from './hooks'; diff --git a/packages/sage-system-app-sdk/src/runtime-bridge-entry.ts b/packages/sage-system-app-sdk/src/runtime-bridge-entry.ts new file mode 100644 index 000000000..2a35ae27e --- /dev/null +++ b/packages/sage-system-app-sdk/src/runtime-bridge-entry.ts @@ -0,0 +1,3 @@ +import { initSageSystemRuntimeBridge } from './runtime'; + +initSageSystemRuntimeBridge(); diff --git a/packages/sage-system-app-sdk/src/runtime.ts b/packages/sage-system-app-sdk/src/runtime.ts new file mode 100644 index 000000000..b6a7e23b0 --- /dev/null +++ b/packages/sage-system-app-sdk/src/runtime.ts @@ -0,0 +1,489 @@ +import type * as Generated from './generated-types'; +import type { SageSystemBridgeVersion, SageSystemClient } from './types'; +import { + createBridgeRuntimeCore, + debugComms, + getSageClient, + parseJsonOrNull, +} from '@sage-app/sdk'; + +export const SAGE_SYSTEM_BRIDGE_VERSION: SageSystemBridgeVersion = 'v1'; + +type SageSystemListenEvent = { + payload: T; +}; + +type SageUnlisten = () => void; + +type SageWebviewHandle = { + label: string; + listen( + event: string, + handler: (event: SageSystemListenEvent) => void, + ): Promise; +}; + +type SageSystemWindow = Window & + typeof globalThis & { + __SAGE_SYSTEM__?: SageSystemClient; + }; + +type SystemRuntimeEventEnvelope = { + type: string; + payload: T; +}; + +type RustLikeSystemBridgeSuccessResponse = { + bridgeVersion: SageSystemBridgeVersion; + id: string; + ok: true; + result?: unknown; + resultJson?: string; +}; + +type RustLikeSystemBridgeErrorResponse = { + bridgeVersion: SageSystemBridgeVersion; + id: string; + ok: false; + error: { + code: string; + message: string; + }; +}; + +type RustLikeSystemBridgeResponse = + | RustLikeSystemBridgeSuccessResponse + | RustLikeSystemBridgeErrorResponse; + +function getSageWindow(): SageSystemWindow { + return window as SageSystemWindow; +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function mergeClients(path: string, base: unknown, extension: unknown): T { + if (!isObject(base) || !isObject(extension)) { + throw new Error( + `Cannot merge non-object client namespace: ${path || 'root'}`, + ); + } + + const result: Record = { ...base }; + + for (const [key, extensionValue] of Object.entries(extension)) { + const nextPath = path ? `${path}.${key}` : key; + const baseValue = result[key]; + + if (baseValue === undefined) { + result[key] = extensionValue; + continue; + } + + const bothObjects = isObject(baseValue) && isObject(extensionValue); + const eitherFunction = + typeof baseValue === 'function' || typeof extensionValue === 'function'; + + if (bothObjects && !eitherFunction) { + result[key] = mergeClients(nextPath, baseValue, extensionValue); + continue; + } + + throw new Error(`Duplicate Sage client method: ${nextPath}`); + } + + return result as T; +} + +function isSystemRuntimeEventEnvelope( + value: unknown, +): value is SystemRuntimeEventEnvelope { + return ( + isObject(value) && typeof value.type === 'string' && 'payload' in value + ); +} + +function dispatchSystemRuntimeEvent(data: SystemRuntimeEventEnvelope) { + window.dispatchEvent( + new CustomEvent('sage-system:event', { + detail: data, + }), + ); + + window.dispatchEvent( + new CustomEvent( + `sage-system:event:${data.type}`, + { detail: data }, + ), + ); +} + +function onSystemRuntimeEventType( + type: string, + handler: (event: T) => void, +): () => void { + const listener = (event: Event) => { + const custom = event as CustomEvent>; + handler(custom.detail.payload); + }; + + window.addEventListener( + `sage-system:event:${type}`, + listener as EventListener, + ); + + return () => { + window.removeEventListener( + `sage-system:event:${type}`, + listener as EventListener, + ); + }; +} + +function bridgeResponseResult( + data: RustLikeSystemBridgeSuccessResponse, +): unknown { + if ('result' in data) { + return data.result; + } + + return parseJsonOrNull(data.resultJson); +} + +export function initSageSystemRuntimeBridge(): boolean { + const w = getSageWindow(); + + if (w.__SAGE_SYSTEM__) { + return true; + } + + const core = createBridgeRuntimeCore({ + version: SAGE_SYSTEM_BRIDGE_VERSION, + invokeCommand: 'apps_invoke_system_bridge', + requestIdPrefix: 'sage-system', + }); + + if (!core) { + return false; + } + + const webview = core.webview as SageWebviewHandle; + const callHost = core.callHost; + + webview + .listen( + 'sage-system-bridge:response', + (event: SageSystemListenEvent) => { + const data = event.payload; + debugComms('system:response:raw', data); + + if (!data || data.bridgeVersion !== SAGE_SYSTEM_BRIDGE_VERSION) { + console.warn( + '[Sage System SDK] Dropped malformed bridge response:', + data, + ); + return; + } + + const pending = core.pendingRequests.get(data.id); + if (!pending) { + console.warn( + '[Sage System SDK] Response for unknown request id:', + data.id, + data, + ); + return; + } + + core.pendingRequests.delete(data.id); + window.clearTimeout(pending.timeoutId); + + if (data.ok) { + pending.resolve(bridgeResponseResult(data)); + } else { + pending.reject( + new Error( + data.error?.message || 'Unknown Sage system bridge error', + ), + ); + } + }, + ) + .catch((error: unknown) => { + console.error('Failed to subscribe to system bridge response:', error); + }); + + webview + .listen('sage-system-bridge:event', (event: SageSystemListenEvent) => { + const data = event.payload; + debugComms('system:event:raw', data); + + if (!isSystemRuntimeEventEnvelope(data)) { + console.warn( + '[Sage System SDK] Dropped malformed runtime event:', + data, + ); + return; + } + + try { + debugComms('system:event:dispatch', data); + dispatchSystemRuntimeEvent(data); + } catch (error: unknown) { + console.error( + 'Failed to dispatch Sage system bridge runtime event:', + error, + ); + } + }) + .catch((error: unknown) => { + console.error('Failed to subscribe to system bridge event:', error); + }); + + void getSageClient() + .then((userClient) => { + const systemExtension = { + runtimeManager: { + async listRuntimes() { + return await callHost( + 'runtimeManager.listRuntimes', + ); + }, + + async focusRuntime(input: Generated.RuntimeTargetParams) { + return await callHost( + 'runtimeManager.focusTaskbarRuntime', + input, + ); + }, + + async hideRuntime(input: Generated.RuntimeTargetParams) { + return await callHost( + 'runtimeManager.hideRuntime', + input, + ); + }, + + async killRuntime(input: Generated.RuntimeTargetParams) { + return await callHost( + 'runtimeManager.killRuntime', + input, + ); + }, + + async getActiveTaskbarRuntime(): Promise { + return await callHost( + 'runtimeManager.getActiveTaskbarRuntime', + ); + }, + + async hideSelf() { + return await callHost('runtimeManager.hideSelf'); + }, + + async closeSelf() { + return await callHost('runtimeManager.closeSelf'); + }, + + onRuntimesChanged( + handler: ( + event: Generated.RuntimeManagerRuntimesChangedEvent, + ) => void, + ) { + return onSystemRuntimeEventType( + 'runtimeManager.runtimesChanged', + handler, + ); + }, + + onActiveTaskbarRuntimeChanged( + handler: ( + event: Generated.RuntimeManagerActiveTaskbarRuntimeChangedEvent, + ) => void, + ) { + return onSystemRuntimeEventType( + 'runtimeManager.activeTaskbarRuntimeChanged', + handler, + ); + }, + }, + + appInstall: { + async previewUrl(input: Generated.AppInstallPreviewUrlParams) { + return await callHost( + 'appInstall.previewUrl', + input, + ); + }, + + async previewZip(input: Generated.AppInstallPreviewZipParams) { + return await callHost( + 'appInstall.previewZip', + input, + ); + }, + + async installUrl(input: Generated.AppInstallInstallUrlParams) { + return await callHost( + 'appInstall.installUrl', + input, + ); + }, + + async installZip(input: Generated.AppInstallInstallZipParams) { + return await callHost( + 'appInstall.installZip', + input, + ); + }, + }, + + appUpdate: { + async getReviewContext( + input: Generated.AppUpdateGetReviewContextParams, + ) { + return await callHost( + 'appUpdate.getReviewContext', + input, + ); + }, + + async applyUpdate(input: Generated.AppUpdateApplyUpdateParams) { + return await callHost( + 'appUpdate.applyUpdate', + input, + ); + }, + }, + + capabilities: { + async listUserDefinitions() { + return await callHost( + 'capabilities.listUserDefinitions', + ); + }, + }, + + appPermissions: { + async getReviewContext( + input: Generated.AppPermissionsGetReviewContextParams, + ) { + return await callHost( + 'appPermissions.getReviewContext', + input, + ); + }, + + async applyPermissions( + input: Generated.AppPermissionsApplyPermissionsParams, + ) { + return await callHost( + 'appPermissions.applyPermissions', + input, + ); + }, + }, + + fileSystem: { + async selectFile(input: Generated.FileSystemSelectFileParams) { + return await callHost( + 'fileSystem.selectFile', + input, + ); + }, + }, + + bridgeApprovals: { + async listPending() { + return await callHost( + 'bridgeApprovals.listPending', + ); + }, + + async resolve(input: Generated.ResolveBridgeApprovalArgs) { + return await callHost('bridgeApprovals.resolve', input); + }, + + onChanged( + handler: (event: Generated.BridgeApprovalsChangedEvent) => void, + ) { + return onSystemRuntimeEventType( + 'bridgeApprovals.changed', + handler, + ); + }, + }, + + donations: { + async getDetails(input: Generated.DonationGetDetailsParams) { + return await callHost( + 'donations.getDetails', + input, + ); + }, + }, + + sandbox: { + async getState() { + return await callHost( + 'sandbox.getState', + ); + }, + + async rerunTests() { + return await callHost( + 'sandbox.rerunTests', + ); + }, + + onStateChanged(handler: (state: Generated.SandboxStateView) => void) { + return onSystemRuntimeEventType( + 'sandbox.stateChanged', + (event) => handler(event.state), + ); + }, + }, + wallet: { + async listWallets() { + return await callHost( + 'wallet.listWallets', + ); + }, + }, + }; + + w.__SAGE_SYSTEM__ = mergeClients( + '', + userClient, + systemExtension, + ); + }) + .catch((error: unknown) => { + console.error('Failed to initialize Sage system client:', error); + }); + + return true; +} + +export async function getSageSystemClient(): Promise { + initSageSystemRuntimeBridge(); + + const w = getSageWindow(); + + if (w.__SAGE_SYSTEM__) { + return w.__SAGE_SYSTEM__; + } + + const started = Date.now(); + + while (!w.__SAGE_SYSTEM__) { + if (Date.now() - started > 5000) { + throw new Error('Sage system bridge failed to initialize'); + } + + await new Promise((resolve) => window.setTimeout(resolve, 10)); + } + + return w.__SAGE_SYSTEM__; +} diff --git a/packages/sage-system-app-sdk/src/types.ts b/packages/sage-system-app-sdk/src/types.ts new file mode 100644 index 000000000..9286f3e47 --- /dev/null +++ b/packages/sage-system-app-sdk/src/types.ts @@ -0,0 +1,112 @@ +import type * as Generated from './generated-types'; + +export * from '@sage-app/sdk'; +export * from './generated-types'; + +import type { SageClient } from '@sage-app/sdk'; + +export type SageSystemBridgeVersion = 'v1'; + +export type SageSystemRuntimeManagerClient = { + listRuntimes(): Promise; + focusRuntime( + input: Generated.RuntimeTargetParams, + ): Promise; + hideRuntime( + input: Generated.RuntimeTargetParams, + ): Promise; + killRuntime( + input: Generated.RuntimeTargetParams, + ): Promise; + getActiveTaskbarRuntime(): Promise; + onRuntimesChanged( + handler: (event: Generated.RuntimeManagerRuntimesChangedEvent) => void, + ): () => void; + onActiveTaskbarRuntimeChanged( + handler: ( + event: Generated.RuntimeManagerActiveTaskbarRuntimeChangedEvent, + ) => void, + ): () => void; + hideSelf(): Promise; + closeSelf(): Promise; +}; + +export type SageSystemCapabilitiesClient = { + listUserDefinitions(): Promise; +}; + +export type SageSystemAppPermissionsClient = { + getReviewContext( + input: Generated.AppPermissionsGetReviewContextParams, + ): Promise; + applyPermissions( + input: Generated.AppPermissionsApplyPermissionsParams, + ): Promise; +}; + +export type SageSystemAppInstallClient = { + previewUrl( + input: Generated.AppInstallPreviewUrlParams, + ): Promise; + previewZip( + input: Generated.AppInstallPreviewZipParams, + ): Promise; + installUrl( + input: Generated.AppInstallInstallUrlParams, + ): Promise; + installZip( + input: Generated.AppInstallInstallZipParams, + ): Promise; +}; + +export type SageSystemAppUpdateClient = { + getReviewContext( + input: Generated.AppUpdateGetReviewContextParams, + ): Promise; + applyUpdate( + input: Generated.AppUpdateApplyUpdateParams, + ): Promise; +}; + +export type SageSystemFileSystemClient = { + selectFile( + input: Generated.FileSystemSelectFileParams, + ): Promise; +}; + +export type SageSystemBridgeApprovalsClient = { + listPending(): Promise; + resolve(input: Generated.ResolveBridgeApprovalArgs): Promise; + onChanged( + handler: (event: Generated.BridgeApprovalsChangedEvent) => void, + ): () => void; +}; + +export type SageSystemDonationsClient = { + getDetails( + input: Generated.DonationGetDetailsParams, + ): Promise; +}; +export type SageSandboxClient = { + getState(): Promise; + rerunTests(): Promise; + onStateChanged( + handler: (state: Generated.SandboxStateView) => void, + ): () => void; +}; +export type SageWalletClient = { + listWallets(): Promise; +}; + +export type SageSystemClient = SageClient & { + runtimeManager: SageSystemRuntimeManagerClient; + capabilities: SageSystemCapabilitiesClient; + appPermissions: SageSystemAppPermissionsClient; + appInstall: SageSystemAppInstallClient; + appUpdate: SageSystemAppUpdateClient; + fileSystem: SageSystemFileSystemClient; + bridgeApprovals: SageSystemBridgeApprovalsClient; + donations: SageSystemDonationsClient; + sandbox: SageSandboxClient; + wallet: SageWalletClient; +}; diff --git a/packages/sage-system-app-sdk/tsconfig.build.json b/packages/sage-system-app-sdk/tsconfig.build.json new file mode 100644 index 000000000..65f737621 --- /dev/null +++ b/packages/sage-system-app-sdk/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": false, + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/sage-system-app-sdk/tsconfig.json b/packages/sage-system-app-sdk/tsconfig.json new file mode 100644 index 000000000..db068b8a8 --- /dev/null +++ b/packages/sage-system-app-sdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "strict": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM"], + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/sage-system-app-sdk/tsup.config.ts b/packages/sage-system-app-sdk/tsup.config.ts new file mode 100644 index 000000000..2f4645a6f --- /dev/null +++ b/packages/sage-system-app-sdk/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'runtime-bridge': 'src/runtime-bridge-entry.ts', + }, + format: ['esm'], + dts: false, + splitting: false, + outDir: 'dist', + clean: true, + noExternal: ['@sage-app/sdk'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index add7f9590..1a7f90124 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,12 @@ importers: '@react-spring/web': specifier: ^9.7.5 version: 9.7.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sage-app/sdk': + specifier: workspace:* + version: link:packages/sage-app-sdk + '@sage-app/ui': + specifier: workspace:* + version: link:packages/sage-app-ui '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -99,11 +105,11 @@ importers: specifier: ^2.3.2 version: 2.3.2 '@tauri-apps/plugin-dialog': - specifier: ~2.4.2 - version: 2.4.2 + specifier: ~2.7.1 + version: 2.7.1 '@tauri-apps/plugin-fs': - specifier: ~2.4.5 - version: 2.4.5 + specifier: ~2.5.1 + version: 2.5.1 '@tauri-apps/plugin-opener': specifier: ^2.5.3 version: 2.5.3 @@ -237,6 +243,12 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) + chokidar: + specifier: ^3.6.0 + version: 3.6.0 + concurrently: + specifier: ^9.0.1 + version: 9.2.1 eslint: specifier: ^8.57.1 version: 8.57.1 @@ -261,6 +273,9 @@ importers: tailwindcss: specifier: ^3.4.19 version: 3.4.19(yaml@2.5.1) + ts-prune: + specifier: ^0.10.3 + version: 0.10.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -270,6 +285,93 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + ws: + specifier: ^8.18.0 + version: 8.20.0 + + builtin-apps/src/system: + dependencies: + '@sage-app/ui': + specifier: workspace:* + version: link:../../../packages/sage-app-ui + '@sage-system-app/sdk': + specifier: workspace:* + version: link:../../../packages/sage-system-app-sdk + '@tauri-apps/api': + specifier: ^2.10.1 + version: 2.10.1 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.28 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.2.0(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(yaml@2.5.1)) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.24(postcss@8.5.6) + postcss: + specifier: ^8.4.49 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19(yaml@2.5.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@22.19.10)(jiti@2.6.1)(yaml@2.5.1) + + packages/sage-app-sdk: + devDependencies: + tsup: + specifier: ^8.5.1 + version: 8.5.1(@swc/core@1.15.11)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.5.1) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + packages/sage-app-ui: + dependencies: + '@sage-system-app/sdk': + specifier: workspace:* + version: link:../sage-system-app-sdk + lucide-react: + specifier: ^0.468.0 + version: 0.468.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + devDependencies: + tsup: + specifier: ^8.5.0 + version: 8.5.1(@swc/core@1.15.11)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.5.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/sage-system-app-sdk: + dependencies: + '@sage-app/sdk': + specifier: workspace:* + version: link:../sage-app-sdk + devDependencies: + tsup: + specifier: ^8.5.1 + version: 8.5.1(@swc/core@1.15.11)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.5.1) + typescript: + specifier: ^5.6.3 + version: 5.9.3 packages: @@ -314,6 +416,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -335,6 +441,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} @@ -1396,6 +1514,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -1635,6 +1756,9 @@ packages: '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + '@tauri-apps/cli-darwin-arm64@2.10.0': resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} engines: {node: '>= 10'} @@ -1715,11 +1839,11 @@ packages: '@tauri-apps/plugin-clipboard-manager@2.3.2': resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} - '@tauri-apps/plugin-dialog@2.4.2': - resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} - '@tauri-apps/plugin-fs@2.4.5': - resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==} + '@tauri-apps/plugin-fs@2.5.1': + resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -1727,6 +1851,21 @@ packages: '@tauri-apps/plugin-os@2.3.2': resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + '@ts-morph/common@0.12.3': + resolution: {integrity: sha512-4tUmeLyXJnJWvTFOKtcNJ1yh0a3SsTLi2MUoyj8iUNznFRN1ZquaNe7Oukqrnki2FzZkm0J9adCNLDZxUzvj+w==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1895,6 +2034,12 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@walletconnect/core@2.23.4': resolution: {integrity: sha512-qkzNvRfibl+r2GoPqKl+2MJLYA7ApEWyCmECJoK6IExeWyjKawAUC6Eo4cN0geCBefk9VSFRFEIVQ17vYWp0jQ==} engines: {node: '>=18.20.8'} @@ -1979,6 +2124,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2116,6 +2266,16 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2155,6 +2315,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2174,6 +2338,10 @@ packages: resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} engines: {node: '>= 0.2.0'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -2188,6 +2356,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + code-block-writer@11.0.3: + resolution: {integrity: sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2210,9 +2381,25 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2325,6 +2512,9 @@ packages: emoji-mart@5.6.0: resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2490,6 +2680,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -2548,6 +2741,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2714,6 +2911,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2820,6 +3021,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-sha256@0.10.1: resolution: {integrity: sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==} @@ -2877,6 +3082,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2887,6 +3096,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -2907,6 +3119,14 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + lucide-react@0.468.0: + resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2938,6 +3158,14 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} @@ -3065,6 +3293,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3088,6 +3319,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3117,6 +3351,9 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pofile@1.1.4: resolution: {integrity: sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g==} @@ -3242,6 +3479,10 @@ packages: react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3310,6 +3551,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -3326,10 +3571,18 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -3360,6 +3613,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -3411,6 +3667,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -3466,6 +3726,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -3505,6 +3769,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -3553,6 +3821,9 @@ packages: tiny-worker@2.3.0: resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -3561,6 +3832,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + true-myth@4.1.1: + resolution: {integrity: sha512-rqy30BSpxPznbbTcAcci90oZ1YR4DqvKcNXNerG5gQBU2v4jk0cygheiul5J6ExIMrgDVuanv/MkGfqZbKrNNg==} + engines: {node: 10.* || >= 12.*} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -3576,12 +3855,38 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-morph@13.0.3: + resolution: {integrity: sha512-pSOfUMx8Ld/WUreoSzvMFQG5i9uEiWIsBYjpU9+TTASOeUa89j5HykomeqVULm1oqWtBdleI3KEFRLrlA3zGIw==} + + ts-prune@0.10.3: + resolution: {integrity: sha512-iS47YTbdIcvN8Nh/1BFyziyUqmjXz7GVzWu02RaZXqb+e/3Qe1B7IQ4860krOeCGUeJmterAlaM2FRH0Ue0hjw==} + hasBin: true + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3807,6 +4112,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3822,6 +4131,22 @@ packages: utf-8-validate: optional: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3834,6 +4159,14 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3924,6 +4257,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -3939,6 +4274,16 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} '@babel/template@7.28.6': @@ -4894,6 +5239,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -5056,6 +5403,8 @@ snapshots: '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.0': {} + '@tauri-apps/cli-darwin-arm64@2.10.0': optional: true @@ -5115,13 +5464,13 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-dialog@2.4.2': + '@tauri-apps/plugin-dialog@2.7.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.0 - '@tauri-apps/plugin-fs@2.4.5': + '@tauri-apps/plugin-fs@2.5.1': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.0 '@tauri-apps/plugin-opener@2.5.3': dependencies: @@ -5131,6 +5480,34 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@ts-morph/common@0.12.3': + dependencies: + fast-glob: 3.3.3 + minimatch: 3.1.2 + mkdirp: 1.0.4 + path-browserify: 1.0.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + '@types/estree@1.0.8': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -5147,8 +5524,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/parse-json@4.0.2': - optional: true + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.15': {} @@ -5356,6 +5732,18 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(yaml@2.5.1))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@22.19.10)(jiti@2.6.1)(yaml@2.5.1) + transitivePeerDependencies: + - supports-color + '@walletconnect/core@2.23.4(typescript@5.9.3)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 @@ -5622,6 +6010,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5787,6 +6177,13 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-require@5.1.0(esbuild@0.27.3): + dependencies: + esbuild: 0.27.3 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5841,6 +6238,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -5859,6 +6260,12 @@ snapshots: dependencies: colors: 1.0.3 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: {} clsx@2.1.1: {} @@ -5875,6 +6282,8 @@ snapshots: - '@types/react' - '@types/react-dom' + code-block-writer@11.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5889,8 +6298,23 @@ snapshots: commander@4.1.1: {} + commander@6.2.1: {} + concat-map@0.0.1: {} + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + confbox@0.1.8: {} + + consola@3.4.2: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} @@ -5902,7 +6326,6 @@ snapshots: parse-json: 5.2.0 path-type: 4.0.0 yaml: 1.10.2 - optional: true cosmiconfig@8.3.6(typescript@5.9.3): dependencies: @@ -6003,6 +6426,8 @@ snapshots: emoji-mart@5.6.0: {} + emoji-regex@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -6323,6 +6748,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.57.1 + flat-cache@3.2.0: dependencies: flatted: 3.3.3 @@ -6374,6 +6805,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6564,6 +6997,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -6666,6 +7101,8 @@ snapshots: jiti@2.6.1: {} + joycon@3.1.1: {} + js-sha256@0.10.1: {} js-tokens@4.0.0: {} @@ -6710,6 +7147,8 @@ snapshots: lines-and-columns@1.2.4: {} + load-tsconfig@0.2.5: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6718,6 +7157,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.18.1: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -6737,6 +7178,14 @@ snapshots: dependencies: react: 18.3.1 + lucide-react@0.468.0(react@18.3.1): + dependencies: + react: 18.3.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -6762,6 +7211,15 @@ snapshots: minipass@7.1.2: {} + mkdirp@1.0.4: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + moo@0.5.2: {} motion-dom@12.33.0: @@ -6911,6 +7369,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -6926,6 +7386,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6956,6 +7418,12 @@ snapshots: pirates@4.0.7: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + pofile@1.1.4: {} possible-typed-array-names@1.1.0: {} @@ -6980,6 +7448,14 @@ snapshots: postcss: 8.5.6 yaml: 2.5.1 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.5.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.6 + yaml: 2.5.1 + postcss-nested@6.2.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -7055,6 +7531,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-refresh@0.18.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1): dependencies: react: 18.3.1 @@ -7122,6 +7600,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + readdirp@5.0.0: {} real-require@0.2.0: {} @@ -7146,8 +7626,12 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -7206,6 +7690,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -7265,6 +7753,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -7322,6 +7812,12 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -7390,6 +7886,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} tailwind-merge@2.6.1: {} @@ -7470,6 +7970,8 @@ snapshots: esm: 3.2.25 optional: true + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -7479,6 +7981,10 @@ snapshots: dependencies: is-number: 7.0.0 + tree-kill@1.2.2: {} + + true-myth@4.1.1: {} + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -7489,10 +7995,53 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-morph@13.0.3: + dependencies: + '@ts-morph/common': 0.12.3 + code-block-writer: 11.0.3 + + ts-prune@0.10.3: + dependencies: + commander: 6.2.1 + cosmiconfig: 7.1.0 + json5: 2.2.3 + lodash: 4.18.1 + true-myth: 4.1.1 + ts-morph: 13.0.3 + tslib@1.14.1: {} tslib@2.8.1: {} + tsup@8.5.1(@swc/core@1.15.11)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.5.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.3) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.3 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.5.1) + resolve-from: 5.0.0 + rollup: 4.57.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.15.11 + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -7625,6 +8174,20 @@ snapshots: jiti: 1.21.7 yaml: 2.5.1 + vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(yaml@2.5.1): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.10 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.5.1 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -7676,18 +8239,39 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@7.5.10: {} + ws@8.20.0: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} - yaml@1.10.2: - optional: true + yaml@1.10.2: {} yaml@2.5.1: optional: true + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..1a8dd3d9a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'packages/*' + - 'builtin-apps/src/system' diff --git a/scripts/build-builtin-static.mjs b/scripts/build-builtin-static.mjs new file mode 100644 index 000000000..2d86ed6c4 --- /dev/null +++ b/scripts/build-builtin-static.mjs @@ -0,0 +1,138 @@ +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + rmSync, + statSync, +} from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; + +const repoRoot = resolve(import.meta.dirname, '..'); + +const runtimeSrc = join(repoRoot, 'builtin-apps/src/runtime'); +const sandboxTestSrc = join(repoRoot, 'builtin-apps/src/sandbox-test'); + +const outRoot = join(repoRoot, 'builtin-apps/build/dist'); +const runtimeOut = join(outRoot, 'runtime'); +const testOut = join(outRoot, 'sandbox-test'); + +const userSdkDist = join(repoRoot, 'packages/sage-app-sdk/dist'); +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +function copyDirFresh(src, dst) { + rmSync(dst, { recursive: true, force: true }); + mkdirSync(dst, { recursive: true }); + cpSync(src, dst, { recursive: true }); +} + +function copyRuntimeBridge(outDir) { + cpSync(join(userSdkDist, 'runtime-bridge.js'), join(outDir, 'bridge.js')); + cpSync(join(userSdkDist, 'index.js'), join(outDir, 'sdk.js')); +} + +function finalizeManifest(source, dist) { + execFileSync( + pnpm, + [ + 'exec', + 'sage-app', + 'finalize-manifest', + '--source', + source, + '--dist', + dist, + ], + { stdio: 'inherit', cwd: repoRoot }, + ); +} + +function buildRuntimeApp(name) { + const src = join(runtimeSrc, name); + const out = join(runtimeOut, name); + + copyDirFresh(src, out); + copyRuntimeBridge(out); + finalizeManifest(join(src, 'sage-manifest.json'), out); +} + +function buildSandboxTestVariant({ + sourceDirName, + outDirName, + manifestFileName, +}) { + const shared = join(sandboxTestSrc, '_shared'); + const src = join(sandboxTestSrc, sourceDirName); + const out = join(testOut, outDirName); + + rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + + cpSync(shared, out, { recursive: true }); + cpSync(src, out, { recursive: true }); + + copyRuntimeBridge(out); + finalizeManifest(join(src, manifestFileName), out); +} + +if (!existsSync(userSdkDist)) { + throw new Error(`missing user SDK dist at ${userSdkDist}`); +} + +mkdirSync(runtimeOut, { recursive: true }); +mkdirSync(testOut, { recursive: true }); + +for (const name of readdirSync(runtimeSrc)) { + const src = join(runtimeSrc, name); + + if (!statSync(src).isDirectory()) { + continue; + } + + console.log(`\n==> Building builtin runtime app: ${name}`); + buildRuntimeApp(name); +} + +const sandboxTests = [ + { + sourceDirName: 'sage-storage-isolation', + outDirName: 'sage-storage-isolation-persistent', + manifestFileName: 'sage-manifest.persistent.json', + }, + { + sourceDirName: 'sage-storage-isolation', + outDirName: 'sage-storage-isolation-incognito', + manifestFileName: 'sage-manifest.incognito.json', + }, + { + sourceDirName: 'storage-persistence', + outDirName: 'storage-persistence-persistent', + manifestFileName: 'sage-manifest.persistent.json', + }, + { + sourceDirName: 'storage-persistence', + outDirName: 'storage-persistence-incognito', + manifestFileName: 'sage-manifest.incognito.json', + }, + { + sourceDirName: 'storage-persistence', + outDirName: 'storage-clear-persistent', + manifestFileName: 'sage-manifest.persistent.json', + }, + { + sourceDirName: 'network-allow-a', + outDirName: 'network-allow-a', + manifestFileName: 'sage-manifest.json', + }, + { + sourceDirName: 'network-allow-b', + outDirName: 'network-allow-b', + manifestFileName: 'sage-manifest.json', + }, +]; + +for (const test of sandboxTests) { + console.log(`\n==> Building builtin sandbox test: ${test.outDirName}`); + buildSandboxTestVariant(test); +} diff --git a/scripts/dev-system-apps-watch.mjs b/scripts/dev-system-apps-watch.mjs new file mode 100644 index 000000000..d4f4596a9 --- /dev/null +++ b/scripts/dev-system-apps-watch.mjs @@ -0,0 +1,180 @@ +import chokidar from 'chokidar'; +import { WebSocketServer } from 'ws'; +import { spawn } from 'node:child_process'; + +const PORT = 1421; + +const watchPaths = [ + 'builtin-apps/src/system/apps/**/*', + 'builtin-apps/src/system/*.ts', + 'builtin-apps/src/system/*.js', + 'packages/sage-app-sdk/src/**/*', + 'packages/sage-system-app-sdk/src/**/*', + 'packages/sage-app-ui/src/**/*', +]; + +const ignored = [ + '**/node_modules/**', + '**/dist/**', + 'builtin-apps/build/**', + 'packages/*/src/generated-types.ts', +]; + +const wss = new WebSocketServer({ port: PORT }); + +let timer = null; +let running = false; +let queuedPackages = false; +let queuedSystemApps = false; +let scheduledNeedsPackages = false; +let scheduledApps = new Set(); + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + child.on('exit', (code) => { + if (code === 0) resolve(); + else + reject(new Error(`${command} ${args.join(' ')} failed with ${code}`)); + }); + }); +} + +function broadcast(payload) { + const text = JSON.stringify(payload); + + for (const client of wss.clients) { + if (client.readyState === client.OPEN) { + client.send(text); + } + } +} + +function systemAppNameFromPath(path) { + const match = path.match(/^builtin-apps\/src\/system\/apps\/([^/]+)\//); + return match?.[1] ?? null; +} + +async function rebuild({ packages = false, apps = [] } = {}) { + if (running) { + queuedSystemApps = true; + queuedPackages ||= packages; + + for (const app of apps) { + scheduledApps.add(app); + } + + return; + } + + running = true; + + try { + if (packages) { + console.log('\n[system-apps-dev] rebuilding shared packages...'); + await run('pnpm', ['run', 'build:packages']); + } + + const buildArgs = + apps.length > 0 + ? ['run', 'build:system-apps', '--', ...apps] + : ['run', 'build:system-apps']; + + console.log( + apps.length > 0 + ? `\n[system-apps-dev] rebuilding system apps: ${apps.join(', ')}` + : '\n[system-apps-dev] rebuilding all system apps...', + ); + + await run('pnpm', buildArgs); + + broadcast({ + type: 'system-apps-built', + ok: true, + apps, + at: Date.now(), + }); + + console.log('\n[system-apps-dev] done'); + } catch (err) { + console.error('\n[system-apps-dev] build failed:', err); + + broadcast({ + type: 'system-apps-built', + ok: false, + error: err instanceof Error ? err.message : String(err), + apps, + at: Date.now(), + }); + } finally { + running = false; + + if (queuedSystemApps || queuedPackages || scheduledApps.size > 0) { + const nextPackages = queuedPackages; + const nextApps = [...scheduledApps]; + + queuedSystemApps = false; + queuedPackages = false; + scheduledApps = new Set(); + + void rebuild({ + packages: nextPackages, + apps: nextPackages ? [] : nextApps, + }); + } + } +} + +function schedule({ packages = false, appName = null } = {}) { + scheduledNeedsPackages ||= packages; + + if (appName) { + scheduledApps.add(appName); + } + + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(() => { + const packages = scheduledNeedsPackages; + const apps = [...scheduledApps]; + + timer = null; + scheduledNeedsPackages = false; + scheduledApps = new Set(); + + void rebuild({ + packages, + apps: packages ? [] : apps, + }); + }, 150); +} + +console.log(`[system-apps-dev] websocket listening on ws://127.0.0.1:${PORT}`); +console.log('[system-apps-dev] watching system apps + SDK/UI packages'); + +chokidar + .watch(watchPaths, { + ignored, + ignoreInitial: true, + }) + .on('all', (event, path) => { + const packages = path.startsWith('packages/'); + const appName = packages ? null : systemAppNameFromPath(path); + + console.log( + `[system-apps-dev] ${event}: ${path}${ + packages ? ' [packages]' : appName ? ` [${appName}]` : ' [all]' + }`, + ); + + schedule({ + packages, + appName, + }); + }); diff --git a/scripts/generate-bridge-types.mjs b/scripts/generate-bridge-types.mjs new file mode 100644 index 000000000..f8430363c --- /dev/null +++ b/scripts/generate-bridge-types.mjs @@ -0,0 +1,13 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const repoRoot = resolve(import.meta.dirname, '..'); + +execFileSync( + 'cargo', + ['run', '-p', 'sage-apps', '--bin', 'export_bridge_types'], + { + cwd: repoRoot, + stdio: ['ignore', 'ignore', 'inherit'], + }, +); diff --git a/src-tauri/.taurignore b/src-tauri/.taurignore new file mode 100644 index 000000000..13485a8c5 --- /dev/null +++ b/src-tauri/.taurignore @@ -0,0 +1,2 @@ +../crates/sage-apps/builtin-apps/dist/** +../docs/generated/** diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2ebd70c4e..d75f950f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,7 +42,11 @@ reqwest = { workspace = true } aws-lc-rs = { version = "1", features = ["bindgen"] } tauri-plugin-sharesheet = "0.0.1" +[dev-dependencies] +tempfile = "3" + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +sage-apps = { workspace = true } tauri-plugin-window-state = { workspace = true } tauri-plugin-dialog = "2" tauri-plugin-fs = "2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 6ce22e224..af33e9017 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,14 +1,13 @@ use glob::glob; use std::env; -/// Adds a temporary workaround for an issue with the Rust compiler and Android -/// in `x86_64` devices: . -/// The workaround comes from: fn setup_x86_64_android_workaround() { let target_os = env::var("CARGO_CFG_TARGET_OS").expect("CARGO_CFG_TARGET_OS not set"); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").expect("CARGO_CFG_TARGET_ARCH not set"); + if target_arch == "x86_64" && target_os == "android" { let android_ndk_home = env::var("ANDROID_NDK_HOME").expect("ANDROID_NDK_HOME not set"); + let build_os = match env::consts::OS { "linux" => "linux", "macos" => "darwin", @@ -17,9 +16,11 @@ fn setup_x86_64_android_workaround() { "Unsupported OS. You must use either Linux, MacOS or Windows to build the crate." ), }; + let linux_x86_64_lib_pattern = format!( "{android_ndk_home}/toolchains/llvm/prebuilt/{build_os}-x86_64/lib*/clang/**/lib/linux/" ); + match glob(&linux_x86_64_lib_pattern).expect("glob failed").last() { Some(Ok(path)) => { println!("cargo:rustc-link-search={}", path.to_string_lossy()); @@ -34,5 +35,6 @@ fn setup_x86_64_android_workaround() { fn main() { setup_x86_64_android_workaround(); + tauri_build::build(); } diff --git a/src-tauri/capabilities/apps.json b/src-tauri/capabilities/apps.json new file mode 100644 index 000000000..bd360371b --- /dev/null +++ b/src-tauri/capabilities/apps.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "app-webview", + "description": "Minimal IPC for installed app windows and embedded user app webviews", + "webviews": ["app-*"], + "permissions": ["core:event:allow-listen", "allow-apps-invoke-bridge"] +} diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/sage-desktop.json similarity index 93% rename from src-tauri/capabilities/desktop.json rename to src-tauri/capabilities/sage-desktop.json index bdb030db1..726c78db3 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/sage-desktop.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "desktop-capability", "platforms": ["macOS", "windows", "linux"], - "windows": ["main"], + "webviews": ["main"], "permissions": [ "window-state:default", "core:menu:default", diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/sage-mobile.json similarity index 92% rename from src-tauri/capabilities/mobile.json rename to src-tauri/capabilities/sage-mobile.json index 07f0f17b4..e3fcc3d8c 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/sage-mobile.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/mobile-schema.json", "identifier": "mobile-capability", - "windows": ["main"], + "webviews": ["main"], "platforms": ["android", "iOS"], "permissions": [ "safe-area-insets:default", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/sage.json similarity index 50% rename from src-tauri/capabilities/default.json rename to src-tauri/capabilities/sage.json index 67c5e4fe7..99b15c512 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/sage.json @@ -2,18 +2,28 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main"], + "webviews": ["main"], "permissions": [ "core:path:default", "core:event:default", "core:window:default", "core:webview:default", + "core:webview:allow-clear-all-browsing-data", + "core:webview:allow-create-webview", + "core:webview:allow-webview-hide", + "core:webview:allow-webview-show", + "core:webview:allow-set-webview-focus", + "core:webview:allow-set-webview-position", + "core:webview:allow-set-webview-size", + "core:webview:allow-webview-close", "core:app:default", + "core:app:allow-remove-data-store", "core:resources:default", "core:image:default", "clipboard-manager:default", "clipboard-manager:allow-write-text", "clipboard-manager:allow-read-text", - "opener:default" + "opener:default", + "main-host" ] } diff --git a/src-tauri/capabilities/system-apps.json b/src-tauri/capabilities/system-apps.json new file mode 100644 index 000000000..1c89755da --- /dev/null +++ b/src-tauri/capabilities/system-apps.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "system-app-webview", + "description": "Minimal IPC for embedded system app webviews", + "webviews": ["system-app-*"], + "permissions": [ + "core:event:allow-listen", + "allow-apps-invoke-bridge", + "allow-apps-invoke-system-bridge" + ] +} diff --git a/src-tauri/permissions/bridge.toml b/src-tauri/permissions/bridge.toml new file mode 100644 index 000000000..c2759c223 --- /dev/null +++ b/src-tauri/permissions/bridge.toml @@ -0,0 +1,9 @@ +[[permission]] +identifier = "allow-apps-invoke-bridge" +description = "Allows embedded user app webviews to invoke the Sage bridge entrypoint." +commands.allow = ["apps_invoke_bridge"] + +[[permission]] +identifier = "allow-apps-invoke-system-bridge" +description = "Allows embedded system app webviews to invoke the system Sage bridge entrypoint." +commands.allow = ["apps_invoke_system_bridge"] diff --git a/src-tauri/permissions/main-host.toml b/src-tauri/permissions/main-host.toml new file mode 100644 index 000000000..b12e2a5cb --- /dev/null +++ b/src-tauri/permissions/main-host.toml @@ -0,0 +1,144 @@ +[[permission]] +identifier = "main-host" +description = "Allows the main Sage webview to call Sage host commands." +commands.allow = [ + "initialize", + "login", + "logout", + "resync", + "generate_mnemonic", + "import_key", + "delete_key", + "delete_database", + "rename_key", + "get_keys", + "set_wallet_emoji", + "get_key", + "get_secret_key", + "send_xch", + "bulk_send_xch", + "combine", + "split", + "auto_combine_xch", + "send_cat", + "bulk_send_cat", + "auto_combine_cat", + "issue_cat", + "create_did", + "bulk_mint_nfts", + "transfer_nfts", + "transfer_dids", + "normalize_dids", + "mint_option", + "transfer_options", + "exercise_options", + "add_nft_uri", + "assign_nfts_to_did", + "finalize_clawback", + "create_transaction", + "sign_coin_spends", + "view_coin_spends", + "submit_transaction", + "get_sync_status", + "get_version", + "get_database_stats", + "perform_database_maintenance", + "check_address", + "get_derivations", + "get_are_coins_spendable", + "get_spendable_coin_count", + "get_coins_by_ids", + "get_coins", + "get_cats", + "get_all_cats", + "get_token", + "get_dids", + "get_minter_did_ids", + "get_options", + "get_option", + "get_nft_collections", + "get_nft_collection", + "get_nfts", + "get_nft", + "get_nft_data", + "get_nft_icon", + "get_nft_thumbnail", + "get_pending_transactions", + "get_transaction", + "get_transactions", + "validate_address", + "make_offer", + "take_offer", + "combine_offers", + "view_offer", + "import_offer", + "get_offers", + "get_offers_for_asset", + "get_offer", + "delete_offer", + "cancel_offer", + "cancel_offers", + "network_config", + "set_discover_peers", + "set_target_peers", + "set_network", + "set_network_override", + "wallet_config", + "default_wallet_config", + "get_networks", + "get_network", + "set_delta_sync", + "set_delta_sync_override", + "set_change_address", + "update_cat", + "resync_cat", + "update_did", + "update_option", + "update_nft", + "update_nft_collection", + "redownload_nft", + "increase_derivation_index", + "get_peers", + "get_user_theme", + "get_user_themes", + "save_user_theme", + "delete_user_theme", + "add_peer", + "remove_peer", + "filter_unlocked_coins", + "get_asset_coins", + "sign_message_with_public_key", + "sign_message_by_address", + "send_transaction_immediately", + "is_rpc_running", + "start_rpc_server", + "stop_rpc_server", + "get_rpc_run_on_startup", + "set_rpc_run_on_startup", + "switch_wallet", + "move_key", + "download_cni_offercode", + "get_logs", + "is_asset_owned", + "get_xch_usd_price", + "apps_enter_workspace", + "apps_leave_workspace", + "apps_start_user_app", + "apps_start_system_app", + "apps_clear_runtime_browsing_data", + "apps_set_environment_theme", + "apps_get_sandbox_state", + "apps_get_app_launch_gate", + "apps_rerun_sandbox_tests", + "apps_list_installed_apps", + "apps_uninstall_app", + "apps_check_app_update", + "apps_apply_app_update", + "apps_list_runtimes", + "apps_focus_taskbar_runtime", + "apps_clear_active_taskbar_runtime", + "apps_kill_taskbar_runtime", + "apps_dev_reload_runtime", + "apps_get_auto_update_enabled", + "apps_set_auto_update_enabled", +] diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index ed455ea49..6fc0231bb 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -2,7 +2,11 @@ use std::sync::Arc; use sage::{Result, Sage}; use sage_api::SyncEvent as ApiEvent; +#[cfg(not(mobile))] +use sage_apps::{AppsHostState, process_sage_network_change}; use sage_wallet::SyncEvent; +#[cfg(not(mobile))] +use tauri::Manager; use tauri::{AppHandle, Emitter}; use tokio::{sync::Mutex, task::JoinHandle}; @@ -17,6 +21,12 @@ pub async fn initialize(app_handle: AppHandle, sage: &mut Sage) -> Result<()> { tokio::spawn(async move { while let Some(event) = receiver.recv().await { + #[cfg(not(mobile))] + if let SyncEvent::NetworkChanged { .. } = &event { + let apps_state = app_handle.state::(); + + process_sage_network_change(&app_handle, &apps_state).await; + } let event = match event { SyncEvent::Start(ip) => ApiEvent::Start { ip: ip.to_string() }, SyncEvent::Stop => ApiEvent::Stop, @@ -37,6 +47,7 @@ pub async fn initialize(app_handle: AppHandle, sage: &mut Sage) -> Result<()> { SyncEvent::CatInfo => ApiEvent::CatInfo, SyncEvent::DidInfo => ApiEvent::DidInfo, SyncEvent::NftData => ApiEvent::NftData, + SyncEvent::NetworkChanged { .. } => continue, }; if app_handle.emit("sync-event", event).is_err() { break; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fa140bf6e..016532255 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,10 +1,16 @@ use std::{fs, time::Duration}; +use crate::{ + app_state::{self, AppState, Initialized, RpcTask}, + error::Result, +}; use chia_wallet_sdk::utils::Address; use reqwest::StatusCode; use sage::Error; use sage_api::{wallet_connect::*, *}; use sage_api_macro::impl_endpoints_tauri; +#[cfg(not(mobile))] +use sage_apps::ensure_initial_sandbox_run; use sage_config::{NetworkConfig, Wallet, WalletDefaults}; use sage_rpc::start_rpc; use serde::{Deserialize, Serialize}; @@ -13,11 +19,6 @@ use tauri::{AppHandle, State, command}; use tokio::time::sleep; use tracing::error; -use crate::{ - app_state::{self, AppState, Initialized, RpcTask}, - error::Result, -}; - #[command] #[specta] pub async fn initialize( @@ -35,7 +36,7 @@ pub async fn initialize( *initialized = true; let mut sage = state.lock().await; - app_state::initialize(app_handle, &mut sage).await?; + app_state::initialize(app_handle.clone(), &mut sage).await?; drop(sage); let app_state = (*state).clone(); @@ -154,8 +155,23 @@ pub async fn set_rpc_run_on_startup( #[command] #[specta] -pub async fn switch_wallet(state: State<'_, AppState>) -> Result<()> { - state.lock().await.switch_wallet().await?; +pub async fn switch_wallet(app_handle: AppHandle, state: State<'_, AppState>) -> Result<()> { + { + state.lock().await.switch_wallet().await?; + } + + #[cfg(not(mobile))] + { + if let Err(err) = ensure_initial_sandbox_run(app_handle).await { + eprintln!("failed to start initial sandbox run: {err}"); + } + } + + #[cfg(mobile)] + { + let _ = app_handle; + } + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4f2bd12a..f9d5a189e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ use rustls::crypto::aws_lc_rs::default_provider; use sage::Sage; use sage_api::SyncEvent; use tauri::Manager; +#[cfg(not(mobile))] +use tauri::path::BaseDirectory; use tauri_specta::{Builder, ErrorHandlingMode, collect_commands, collect_events}; use tokio::sync::Mutex; @@ -10,19 +12,20 @@ mod app_state; mod commands; mod error; +#[cfg(not(mobile))] +use sage_apps as apps; + +#[cfg(not(mobile))] +use sage_apps::{ + AppsHostState, handle_system_app_protocol_request, handle_user_app_protocol_request, +}; + #[cfg(all(debug_assertions, not(mobile)))] use specta_typescript::{BigIntExportBehavior, Typescript}; -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - default_provider() - .install_default() - .expect("could not install AWS LC provider"); - - let builder = Builder::::new() - .error_handling(ErrorHandlingMode::Throw) - // Then register them (separated by a comma) - .commands(collect_commands![ +macro_rules! sage_commands { + ($($extra_command:tt)*) => { + collect_commands![ commands::initialize, commands::login, commands::logout, @@ -141,10 +144,53 @@ pub fn run() { commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, + commands::get_xch_usd_price, + $($extra_command)* + ] + }; +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + default_provider() + .install_default() + .expect("could not install AWS LC provider"); + + #[cfg(not(mobile))] + let builder = Builder::::new() + .error_handling(ErrorHandlingMode::Throw) + .commands(sage_commands![ + apps::apps_enter_workspace, + apps::apps_leave_workspace, + apps::apps_invoke_bridge, + apps::apps_invoke_system_bridge, + apps::apps_set_environment_theme, + apps::apps_get_sandbox_state, + apps::apps_get_app_launch_gate, + apps::apps_rerun_sandbox_tests, + apps::apps_list_installed_apps, + apps::apps_uninstall_app, + apps::apps_check_app_update, + apps::apps_apply_app_update, + apps::apps_clear_runtime_browsing_data, + apps::apps_start_system_app, + apps::apps_start_user_app, + apps::apps_list_runtimes, + apps::apps_focus_taskbar_runtime, + apps::apps_clear_active_taskbar_runtime, + apps::apps_kill_taskbar_runtime, + apps::apps_dev_reload_runtime, + apps::apps_get_auto_update_enabled, + apps::apps_set_auto_update_enabled, ]) .events(collect_events![SyncEvent]); - // On mobile or release mode we should not export the TypeScript bindings + #[cfg(mobile)] + let builder = Builder::::new() + .error_handling(ErrorHandlingMode::Throw) + .commands(sage_commands![]) + .events(collect_events![SyncEvent]); + #[cfg(all(debug_assertions, not(mobile)))] builder .export( @@ -176,15 +222,94 @@ pub fn run() { .plugin(tauri_plugin_sage::init()); } + #[cfg(not(mobile))] + { + tauri_builder = tauri_builder + .register_asynchronous_uri_scheme_protocol( + "sage-app", + move |ctx, request, responder| { + let app_handle = ctx.app_handle().clone(); + let webview_label = ctx.webview_label().to_string(); + + tauri::async_runtime::spawn(async move { + let response = + handle_user_app_protocol_request(app_handle, webview_label, request) + .await; + + responder.respond(response); + }); + }, + ) + .register_asynchronous_uri_scheme_protocol( + "sage-system-app", + move |ctx, request, responder| { + let app_handle = ctx.app_handle().clone(); + let webview_label = ctx.webview_label().to_string(); + + tauri::async_runtime::spawn(async move { + let response = + handle_system_app_protocol_request(app_handle, webview_label, request) + .await; + + responder.respond(response); + }); + }, + ); + } + tauri_builder .invoke_handler(builder.invoke_handler()) .setup(move |app| { builder.mount_events(app); + let path = app.path().app_data_dir()?; let app_state = AppState::new(Mutex::new(Sage::new(&path, false))); + app.manage(Initialized(Mutex::new(false))); app.manage(RpcTask(Mutex::new(None))); app.manage(app_state); + + #[cfg(not(mobile))] + { + let bundled_builtin_apps = app + .path() + .resolve("builtin-apps", BaseDirectory::Resource)?; + + if bundled_builtin_apps.is_dir() { + apps::set_builtin_apps_root(bundled_builtin_apps); + } else { + tracing::warn!( + "bundled builtin apps directory not found at {}; using development path", + bundled_builtin_apps.display() + ); + } + + let apps_db = tauri::async_runtime::block_on(apps::AppsDb::initialize(&path)) + .expect("failed to initialize Sage apps database"); + + app.manage(AppsHostState::new(apps_db)); + + apps::start_background_app_update_checker(app.handle().clone()); + + let app_handle = app.handle().clone(); + let cleanup_base_path = path.clone(); + + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(5000)).await; + tracing::info!("starting pending storage cleanup task"); + + if let Err(err) = + apps::process_pending_storage_cleanup(&app_handle, &cleanup_base_path).await + { + tracing::error!( + "failed to retry pending storage cleanup on startup: {err}" + ); + } + + tracing::info!("pending storage cleanup task finished"); + }); + } + Ok(()) }) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json new file mode 100644 index 000000000..01b2bb381 --- /dev/null +++ b/src-tauri/tauri.android.conf.json @@ -0,0 +1,8 @@ +{ + "build": { + "beforeBuildCommand": "pnpm run build:frontend" + }, + "bundle": { + "resources": [] + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e2abeffe8..af6d4fe13 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,6 +9,7 @@ "beforeBuildCommand": "pnpm run build" }, "app": { + "withGlobalTauri": true, "windows": [ { "title": "Sage", @@ -16,7 +17,8 @@ "height": 750, "resizable": true, "maximized": false, - "visible": true + "visible": true, + "titleBarStyle": "Transparent" } ], "security": { @@ -26,6 +28,9 @@ "bundle": { "active": true, "targets": "all", + "resources": { + "../builtin-apps/build/dist": "builtin-apps" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 000000000..01b2bb381 --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,8 @@ +{ + "build": { + "beforeBuildCommand": "pnpm run build:frontend" + }, + "bundle": { + "resources": [] + } +} diff --git a/src/App.tsx b/src/App.tsx index 6fefeeff2..2ec577815 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'; import { createHashRouter, createRoutesFromElements, + Navigate, Route, RouterProvider, } from 'react-router-dom'; @@ -57,6 +58,10 @@ import { TokenList } from './pages/TokenList'; import Transaction from './pages/Transaction'; import { Transactions } from './pages/Transactions'; import Wallet from './pages/Wallet'; +import { AppsProvider } from '@/contexts/AppsContext.tsx'; +import { RustThemeSync } from '@/components/RustThemeSync.tsx'; +import { Apps } from '@/pages/Apps.tsx'; +import { platform } from '@tauri-apps/plugin-os'; // Theme-aware toast container component function ThemeAwareToastContainer() { @@ -87,6 +92,10 @@ function ThemeAwareToastContainer() { ); } +const currentPlatform = platform(); +const supportsSageApps = + currentPlatform !== 'android' && currentPlatform !== 'ios'; + const router = createHashRouter( createRoutesFromElements( <> @@ -136,6 +145,14 @@ const router = createHashRouter( }> } /> + }> + : + } + /> + } /> } /> } /> @@ -174,7 +191,6 @@ function AppInner() { const { locale } = useLanguage(); const [isLocaleInitialized, setIsLocaleInitialized] = useState(false); - // Enable global transaction failure handling useTransactionFailures(); useEffect(() => { @@ -182,7 +198,7 @@ function AppInner() { await loadCatalog(locale); setIsLocaleInitialized(true); }; - initLocale(); + void initLocale(); }, [locale]); return ( @@ -190,12 +206,15 @@ function AppInner() { isLocaleInitialized && ( + - - - - - + + + + + + + diff --git a/src/bindings.ts b/src/bindings.ts index 1393211c1..e6ffc5735 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -358,6 +358,75 @@ async getLogs() : Promise { }, async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); +}, +async getXchUsdPrice(req: GetXchUsdPrice) : Promise { + return await TAURI_INVOKE("get_xch_usd_price", { req }); +}, +async appsEnterWorkspace() : Promise { + return await TAURI_INVOKE("apps_enter_workspace"); +}, +async appsLeaveWorkspace() : Promise { + return await TAURI_INVOKE("apps_leave_workspace"); +}, +async appsInvokeBridge(request: RustBridgeRequest) : Promise { + return await TAURI_INVOKE("apps_invoke_bridge", { request }); +}, +async appsInvokeSystemBridge(request: RustBridgeRequest) : Promise { + return await TAURI_INVOKE("apps_invoke_system_bridge", { request }); +}, +async appsSetEnvironmentTheme(theme: EnvironmentThemeView) : Promise { + return await TAURI_INVOKE("apps_set_environment_theme", { theme }); +}, +async appsGetSandboxState() : Promise { + return await TAURI_INVOKE("apps_get_sandbox_state"); +}, +async appsGetAppLaunchGate(appId: string) : Promise { + return await TAURI_INVOKE("apps_get_app_launch_gate", { appId }); +}, +async appsRerunSandboxTests() : Promise { + return await TAURI_INVOKE("apps_rerun_sandbox_tests"); +}, +async appsListInstalledApps() : Promise { + return await TAURI_INVOKE("apps_list_installed_apps"); +}, +async appsUninstallApp(appId: string) : Promise { + return await TAURI_INVOKE("apps_uninstall_app", { appId }); +}, +async appsCheckAppUpdate(appId: string) : Promise { + return await TAURI_INVOKE("apps_check_app_update", { appId }); +}, +async appsApplyAppUpdate(appId: string) : Promise { + return await TAURI_INVOKE("apps_apply_app_update", { appId }); +}, +async appsClearRuntimeBrowsingData(appId: string) : Promise { + return await TAURI_INVOKE("apps_clear_runtime_browsing_data", { appId }); +}, +async appsStartSystemApp(args: StartSystemAppArgs) : Promise { + return await TAURI_INVOKE("apps_start_system_app", { args }); +}, +async appsStartUserApp(args: CreateInstalledRuntimeArgs) : Promise { + return await TAURI_INVOKE("apps_start_user_app", { args }); +}, +async appsListRuntimes() : Promise { + return await TAURI_INVOKE("apps_list_runtimes"); +}, +async appsFocusTaskbarRuntime(params: RuntimeTargetParams) : Promise { + return await TAURI_INVOKE("apps_focus_taskbar_runtime", { params }); +}, +async appsClearActiveTaskbarRuntime(params: WindowTargetParams) : Promise { + return await TAURI_INVOKE("apps_clear_active_taskbar_runtime", { params }); +}, +async appsKillTaskbarRuntime(params: RuntimeTargetParams) : Promise { + return await TAURI_INVOKE("apps_kill_taskbar_runtime", { params }); +}, +async appsDevReloadRuntime(params: RuntimeTargetParams) : Promise { + return await TAURI_INVOKE("apps_dev_reload_runtime", { params }); +}, +async appsGetAutoUpdateEnabled() : Promise { + return await TAURI_INVOKE("apps_get_auto_update_enabled"); +}, +async appsSetAutoUpdateEnabled(enabled: boolean) : Promise { + return await TAURI_INVOKE("apps_set_auto_update_enabled", { enabled }); } } @@ -411,6 +480,9 @@ export type AddPeer = { ip: string } export type AddressKind = "own" | "burn" | "launcher" | "offer" | "external" | "unknown" export type Amount = string | number +export type AppLaunchGateResult = { allowed: boolean; kind: string; capability: SandboxCapability | null; message: string | null } +export type AppModalPresentation = { visibleOverAppIds: string[]; visibleOverLaunchpad: boolean; priority: number } +export type AppPresentation = { kind: "Taskbar" } | ({ kind: "Modal" } & AppModalPresentation) export type Asset = { asset_id: string | null; name: string | null; ticker: string | null; precision: number; icon_url: string | null; description: string | null; is_sensitive_content: boolean; is_visible: boolean; revocation_address: string | null; kind: AssetKind } /** * Type of asset coin @@ -722,6 +794,7 @@ export type CombineOffersResponse = { * Combined offer string */ offer: string } +export type CorruptedInstalledSageApp = { id: string; icon?: SageAppIconView | null; appDir: string; error: string; manifestHeader?: SageAppManifestHeaderV0 | null; source?: UserSageAppSource | null } /** * Create a new DID */ @@ -738,6 +811,7 @@ fee: Amount; * Whether to automatically submit the transaction */ auto_submit?: boolean } +export type CreateInstalledRuntimeArgs = { appId: string; focus?: boolean | null } export type CreateTransaction = { /** * Pre-selected coins to use in the transaction prior to coin selection @@ -803,6 +877,7 @@ export type DeleteUserThemeResponse = Record export type DerivationRecord = { index: number; public_key: string; address: string } export type DidRecord = { launcher_id: string; name: string | null; visible: boolean; coin_id: string; address: string; amount: Amount; recovery_hash: string | null; created_height: number | null } export type EmptyResponse = Record +export type EnvironmentThemeView = { name: string; displayName: string; mostLike?: string | null; inherits?: string | null; cssVars: Partial<{ [key in string]: string }> } export type Error = { kind: ErrorKind; reason: string } export type ErrorKind = "wallet" | "api" | "not_found" | "unauthorized" | "internal" | "database_migration" | "nfc" /** @@ -1600,6 +1675,8 @@ export type GetVersionResponse = { * Semantic version string */ version: string } +export type GetXchUsdPrice = Record +export type GetXchUsdPriceResponse = { usd: number } export type Id = /** * The XCH asset @@ -1752,6 +1829,7 @@ innerPuzzleHash: string | null; * Amount */ amount: Amount | null } +export type ListedSageAppView = ({ kind: "user" } & UserSageAppView) | ({ kind: "system" } & SystemSageAppView) | ({ kind: "corrupted" } & CorruptedInstalledSageApp) export type LogFile = { name: string; text: string } /** * Login to a wallet using a fingerprint @@ -2155,6 +2233,47 @@ export type ResyncCatResponse = Record * Response from resynchronizing the wallet */ export type ResyncResponse = Record +export type RuntimeTargetParams = { appId: string } +export type RustBridgeErrorPayload = { code: string; message: string } +export type RustBridgeErrorResponse = { bridgeVersion: string; id: string; ok: boolean; error: RustBridgeErrorPayload } +export type RustBridgeInvokeResult = ({ kind: "success" } & RustBridgeSuccessResponse) | ({ kind: "error" } & RustBridgeErrorResponse) | { kind: "pending" } +export type RustBridgeRequest = { bridgeVersion: string | null; id: string; method: string; paramsJson: string | null } +export type RustBridgeSuccessResponse = { bridgeVersion: string; id: string; ok: boolean; resultJson: string } +export type SageAppAuthor = { name: string; avatar: string | null } +export type SageAppCommonView = { identity: SageAppIdentityView; grantedPermissions: SageGrantedPermissionsView; walletScope: SageAppWalletScope; activeSnapshot: SageAppSnapshotView; icon: SageAppIconView | null } +export type SageAppDonation = { address: string } +export type SageAppIconView = { mime: string; bytes: number[] } +export type SageAppIdentityView = { id: string; originId: string } +export type SageAppManifestFile = { path: string; sha256: string; size: number } +export type SageAppManifestHeaderV0 = { manifestVersion?: SageAppManifestVersion; name: string; icon?: string | null; sageVersion: SageAppManifestSageVersion } +export type SageAppManifestSageVersion = { min: string; testedMax?: string | null } +export type SageAppManifestVersion = number +export type SageAppPackageManifest = { manifestVersion: SageAppManifestVersion; name: string; icon: string | null; sageVersion: SageAppManifestSageVersion; version: string; permissions: SageRequestedPermissions; files: SageAppManifestFile[]; totalBytes: number; entry: string | null; author: SageAppAuthor | null; donation: SageAppDonation | null } +export type SageAppPackageManifestPreview = { kind: "full"; manifest: SageAppPackageManifest } | { kind: "partial"; manifest_header: SageAppManifestHeaderV0; parse_error: string } +export type SageAppRuntimeMode = "Inline" | "Windowed" +export type SageAppRuntimeRecordView = { runtimeId: string; app: SageAppView; hostWindowLabel: string; webviewLabel: string; presentation: AppPresentation; mode: SageAppRuntimeMode; visibility: SageAppRuntimeVisibility; startedAt: number; lastActiveAt: number; internal: boolean } +export type SageAppRuntimeVisibility = "Visible" | "Hidden" +export type SageAppSnapshotView = { manifest: SageAppPackageManifest } +export type SageAppUrl = string +export type SageAppUrlPreview = { appUrl: SageAppUrl; manifestHash: string; manifest: SageAppPackageManifestPreview; icon: SageAppIconView | null } +export type SageAppView = ({ kind: "system" } & SystemSageAppView) | ({ kind: "user" } & UserSageAppView) +export type SageAppWalletScope = { kind: "allWallets" } | { kind: "selectedWallets"; fingerprints: number[] } +export type SageAppsError = { kind: ErrorKind; reason: string } +export type SageGrantedNetworkPermissionsView = { whitelist: SageNetworkWhitelistEntryView[]; whitelistByNetwork?: Partial<{ [key in string]: SageNetworkWhitelistEntryView[] }> } +export type SageGrantedPermissionsView = { capabilities: UserBridgeCapability[]; network: SageGrantedNetworkPermissionsView } +export type SageGrantedSystemPermissionsView = { capabilities: SystemBridgeCapability[] } +export type SageNetworkWhitelistEntry = { scheme: string; host: string } +export type SageNetworkWhitelistEntryView = { scheme: string; host: string } +export type SageRequestedCapabilities = { required: UserBridgeCapability[]; optional: UserBridgeCapability[] } +export type SageRequestedNetworkPermissions = { whitelist: SageRequestedNetworkWhitelist; whitelistByNetwork: Partial<{ [key in string]: SageRequestedNetworkWhitelist }> } +export type SageRequestedNetworkWhitelist = { required: SageNetworkWhitelistEntry[]; optional: SageNetworkWhitelistEntry[] } +export type SageRequestedPermissions = { network: SageRequestedNetworkPermissions; capabilities: SageRequestedCapabilities } +export type SandboxCapability = "storage_isolation_from_sage" | "storage_persistence_normal" | "storage_non_persistence_incognito" | "network_allowlist_enforced" +export type SandboxCapabilityResult = { status: SandboxCapabilityStatus; checkedAt: number | null; details: string | null } +export type SandboxCapabilityStatus = "pending" | "running" | "passed" | "failed" +export type SandboxRunState = { runId: string; state: SandboxState } +export type SandboxState = { overallCriticalStatus: SandboxCapabilityStatus; storageIsolationFromSage: SandboxCapabilityResult; storagePersistenceNormal: SandboxCapabilityResult; storageNonPersistenceIncognito: SandboxCapabilityResult; networkAllowlistEnforced: SandboxCapabilityResult; startedAt: number | null; finishedAt: number | null } +export type SandboxStateView = { baseline: SandboxState; currentRun: SandboxRunState | null; effective: SandboxState } /** * Save a theme NFT to the wallet */ @@ -2483,6 +2602,12 @@ fee: Amount; * Whether to automatically submit the transaction */ auto_submit?: boolean } +export type StartAppInstallArgs = { source: StartAppInstallSource } +export type StartAppInstallSource = { kind: "selectSource" } | { kind: "url"; app_url: string } +export type StartAppUpdateArgs = { mode: StartAppUpdateMode; appId: string } +export type StartAppUpdateMode = "reviewUpdate" | "reviewPermissions" +export type StartDonationArgs = { appId: string } +export type StartSystemAppArgs = ({ kind: "appInstall" } & StartAppInstallArgs) | ({ kind: "appUpdate" } & StartAppUpdateArgs) | ({ kind: "donation" } & StartDonationArgs) | { kind: "sandboxTests" } /** * Submit a transaction to the network */ @@ -2496,6 +2621,9 @@ spend_bundle: SpendBundleJson } */ export type SubmitTransactionResponse = Record export type SyncEvent = { type: "start"; ip: string } | { type: "stop" } | { type: "subscribed" } | { type: "derivation" } | { type: "coin_state" } | { type: "transaction_failed"; transaction_id: string; error: string | null } | { type: "puzzle_batch_synced" } | { type: "cat_info" } | { type: "did_info" } | { type: "nft_data" } +export type SystemBridgeCapability = "runtime_manager.list_runtimes" | "runtime_manager.focus_taskbar_runtime" | "runtime_manager.hide_runtime" | "runtime_manager.kill_runtime" | "runtime_manager.get_active_taskbar_runtime" | "runtime_manager.listen_runtimes_changed" | "runtime_manager.listen_active_runtime_changed" | "runtime_manager.hide_self" | "runtime_manager.close_self" | "capability_definitions.read" | "app_permissions.read" | "app_permissions.apply" | "app_install.preview" | "app_install.apply" | "app_update.read" | "app_update.apply" | "app_registry.listen_listed_apps_changed" | "file_system.select_file" | "bridge_approval.list" | "bridge_approval.resolve" | "bridge_approval.listen_changed" | "donation.get_details" | "sandbox.get_state" | "sandbox.rerun_tests" | "sandbox.listen_state_changed" | "wallet.list_wallets" +export type SystemKillRuntimeResult = { ok: boolean; appId: string } +export type SystemSageAppView = { common: SageAppCommonView; systemGrantedPermissions: SageGrantedSystemPermissionsView } /** * Accept an offer */ @@ -2712,6 +2840,12 @@ visible: boolean } * Response after updating an option */ export type UpdateOptionResponse = Record +export type UserBridgeCapability = "bridge.send" | "app.get_info" | "app.lifecycle.ready_to_stop" | "app.lifecycle.set_before_stop_listener" | "app.get_capabilities" | "app.request_capability_grant" | "app.request_network_whitelist_grant" | "wallet.get_key" | "wallet.get_secret_key" | "wallet.send_xch" | "wallet.send_xch_auto_submit" | "wallet.get_sync_status" | "wallet.get_version" | "wallet.get_xch_usd_price" | "wallet.check_address" | "wallet.get_derivations" | "wallet.get_spendable_coin_count" | "wallet.get_coins_by_ids" | "wallet.get_coins" | "wallet.get_pending_transactions" | "wallet.get_transaction" | "wallet.get_transactions" | "environment.theme.get_current" | "environment.theme.css_vars" | "environment.theme.listen_changed" | "environment.get_network" | "storage.persistent_webview" +export type UserSageAppPendingUpdateDecisionReviewView = { requiredUserGrantableCapabilities: UserBridgeCapability[]; requiredNetworkWhitelist: SageNetworkWhitelistEntry[]; requiredNetworkWhitelistByNetwork: Partial<{ [key in string]: SageNetworkWhitelistEntry[] }> } +export type UserSageAppPendingUpdateDecisionView = { kind: "apply" } | ({ kind: "review" } & UserSageAppPendingUpdateDecisionReviewView) +export type UserSageAppPendingUpdateView = { appUrl: SageAppUrl; manifestHash: string; manifest: SageAppPackageManifest; decision: UserSageAppPendingUpdateDecisionView } +export type UserSageAppSource = { kind: "zip" } | { kind: "url"; app_url: SageAppUrl } +export type UserSageAppView = { common: SageAppCommonView; source: UserSageAppSource; pendingUpdate?: UserSageAppPendingUpdateView | null } /** * View coin spends without signing */ @@ -2750,6 +2884,7 @@ offer: OfferSummary; status: OfferRecordStatus } export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null } export type WalletDefaults = { delta_sync: boolean } +export type WindowTargetParams = { windowLabel: string } /** tauri-specta globals **/ diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4c668301f..32cf0f5df 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,18 +1,11 @@ -import { useInsets } from '@/contexts/SafeAreaContext'; -import { useWallet } from '@/contexts/WalletContext'; -import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { platform } from '@tauri-apps/plugin-os'; import { AnimatePresence, motion } from 'framer-motion'; -import { ChevronLeft, Menu } from 'lucide-react'; +import { ChevronLeft } from 'lucide-react'; import { PropsWithChildren, ReactNode } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useTheme } from 'theme-o-rama'; -import { BottomNav, TopNav } from './Nav'; import { Button } from './ui/button'; -import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; -import { TooltipProvider } from './ui/tooltip'; -import { WalletSwitcher } from './WalletSwitcher'; +import { MobileNavSheet } from '@/components/MobileNavSheet.tsx'; const headerPaginationVariants = { enter: { opacity: 1, x: 0 }, @@ -32,15 +25,10 @@ export default function Header( ) { const navigate = useNavigate(); const location = useLocation(); - const insets = useInsets(); - - const { wallet } = useWallet(); const hasBackButton = props.back || location.pathname.split('/').length > 2; const isMobile = platform() === 'ios' || platform() === 'android'; - const { currentTheme } = useTheme(); - return (
- - {hasBackButton ? ( - - ) : ( - - - - )} - -
-
- - - -
- -
- -
-
-
-
+
+ + + +
+
+ + + +
+ + + +
+ +
+
+
+ + ); +} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 03d9e8a39..2ac0e496b 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -11,6 +11,7 @@ import { platform } from '@tauri-apps/plugin-os'; import { ArrowDownUp, ArrowLeftRight, + Blocks, BookUser, Cog, FilePenLine, @@ -34,6 +35,7 @@ export function TopNav({ isCollapsed }: NavProps) { const className = isCollapsed ? 'h-5 w-5' : 'h-4 w-4'; const isIos = platform() === 'ios'; + const isMobile = platform() === 'android' || isIos; return ( ); } diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx index fab185512..9e26ccb24 100644 --- a/src/components/NavLink.tsx +++ b/src/components/NavLink.tsx @@ -14,6 +14,18 @@ interface NavLinkProps extends PropsWithChildren { ariaCurrent?: 'page' | 'step' | 'location' | 'date' | 'time' | true | false; } +function isActiveRoute(pathname: string, url: string): boolean { + if (pathname === url) { + return true; + } + + if (url === '/') { + return false; + } + + return pathname.startsWith(url); +} + export function NavLink({ url, children, @@ -24,9 +36,7 @@ export function NavLink({ }: NavLinkProps) { const location = useLocation(); const isActive = - typeof url === 'string' && - (location.pathname === url || - (url !== '/' && location.pathname.startsWith(url))); + typeof url === 'string' && isActiveRoute(location.pathname, url); const baseClassName = `flex items-center gap-3 transition-all ${ isCollapsed ? 'justify-center p-2 rounded-full' : 'px-2 rounded-lg py-1.5' diff --git a/src/components/RustThemeSync.tsx b/src/components/RustThemeSync.tsx new file mode 100644 index 000000000..0dd676e83 --- /dev/null +++ b/src/components/RustThemeSync.tsx @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useTheme } from 'theme-o-rama'; +import { publishEnvironmentThemeToRust } from '@/lib/apps/environmentTheme'; + +export function RustThemeSync() { + const { currentTheme } = useTheme(); + + useEffect(() => { + if (!currentTheme) { + return; + } + + const frame = requestAnimationFrame(() => { + void publishEnvironmentThemeToRust(currentTheme).catch((err) => { + console.error('Failed to publish current theme to Rust:', err); + }); + }); + + return () => { + cancelAnimationFrame(frame); + }; + }, [currentTheme]); + + return null; +} diff --git a/src/components/apps/AppHost.tsx b/src/components/apps/AppHost.tsx new file mode 100644 index 000000000..1d5c199f0 --- /dev/null +++ b/src/components/apps/AppHost.tsx @@ -0,0 +1,46 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useApps } from '@/contexts/AppsContext'; +import { useRuntimeWebviewBounds } from '@/hooks/useRuntimeWebviewBounds'; + +export function AppHost() { + const containerRef = useRef(null); + + const { activeTaskbarRuntime, getTaskbarRuntime } = useApps(); + + const appId = activeTaskbarRuntime?.appId ?? null; + + const runtime = useMemo(() => { + return appId ? getTaskbarRuntime(appId) : null; + }, [getTaskbarRuntime, appId]); + + const webviewLabel = runtime?.webviewLabel ?? null; + + const { scheduleSyncBounds } = useRuntimeWebviewBounds({ + webviewLabel, + containerRef, + enabled: !!webviewLabel, + }); + + useEffect(() => { + if (!webviewLabel) { + return; + } + + scheduleSyncBounds(); + }, [webviewLabel, scheduleSyncBounds]); + + if (!runtime) { + return null; + } + + return ( +
+
+
+
+
+ ); +} diff --git a/src/components/apps/AppIcon.tsx b/src/components/apps/AppIcon.tsx new file mode 100644 index 000000000..f5867591d --- /dev/null +++ b/src/components/apps/AppIcon.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import { ListedSageAppView } from '@/bindings.ts'; + +export function AppIconContent({ + name, + iconUrl, +}: { + name: string; + iconUrl: string | null; +}) { + if (iconUrl) { + return ( + + ); + } + + return <>{name.trim().charAt(0).toUpperCase() || 'A'}; +} + +export function AppIcon({ app }: { app: ListedSageAppView }) { + const name = + app.kind === 'corrupted' + ? (app.manifestHeader?.name ?? app.id) + : app.common.activeSnapshot.manifest.name; + + const iconUrl = useMemo(() => { + return iconUrlFromApp(app); + }, [app]); + + return ; +} + +function iconUrlFromApp(app: ListedSageAppView): string | null { + const icon = app.kind === 'corrupted' ? app.icon : app.common.icon; + + if (!icon) return null; + + return URL.createObjectURL( + new Blob([new Uint8Array(icon.bytes)], { type: icon.mime }), + ); +} diff --git a/src/components/apps/AppTaskBar.tsx b/src/components/apps/AppTaskBar.tsx new file mode 100644 index 000000000..adba5754c --- /dev/null +++ b/src/components/apps/AppTaskBar.tsx @@ -0,0 +1,419 @@ +import { Button } from '@/components/ui/button.tsx'; +import { Blocks, X } from 'lucide-react'; +import clsx from 'clsx'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { AppIcon } from '@/components/apps/AppIcon.tsx'; +import { ListedSageAppView } from '@/bindings.ts'; +import { MobileNavSheet } from '@/components/MobileNavSheet.tsx'; + +type InstalledAppView = Exclude; + +export interface AppTaskBarTab { + app: InstalledAppView; + isActive: boolean; +} + +interface Props { + tabs: AppTaskBarTab[]; + activeAppId: string | null; + activeAppHasDonation: boolean; + onOpenApps: () => void; + onSelectApp: (tab: AppTaskBarTab) => void; + onCloseApp: (tab: AppTaskBarTab) => void; + onReorderTabs: (nextAppIds: string[]) => void; + onOpenDonation: () => void; +} + +interface DragState { + draggedAppId: string; + pointerOffsetWithinTab: number; + currentPointerX: number; + overlayLeftPx: number; +} + +const MAX_TAB_WIDTH_PX = 200; +const MIN_TAB_WIDTH_PX = 80; +const TAB_GAP_PX = 4; + +function reorderIds( + ids: string[], + fromIndex: number, + toIndex: number, +): string[] { + if (fromIndex === toIndex) { + return ids; + } + + const next = [...ids]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function AppTaskBar({ + tabs, + activeAppHasDonation, + onOpenApps, + onSelectApp, + onCloseApp, + onReorderTabs, + onOpenDonation, +}: Props) { + const tabsViewportRef = useRef(null); + const tabsStripRef = useRef(null); + + const [dragState, setDragState] = useState(null); + const [previewOrder, setPreviewOrder] = useState(null); + const [tabsViewportWidthPx, setTabsViewportWidthPx] = useState(0); + + const baseOrder = useMemo( + () => tabs.map((tab) => tab.app.common.identity.id), + [tabs], + ); + const activeOrder = previewOrder ?? baseOrder; + + const tabsById = useMemo(() => { + return new Map( + tabs.map((tab) => [tab.app.common.identity.id, tab] as const), + ); + }, [tabs]); + + const orderedTabs = useMemo(() => { + return activeOrder + .map((appId) => tabsById.get(appId)) + .filter((tab): tab is AppTaskBarTab => tab != null); + }, [activeOrder, tabsById]); + + const tabWidthPx = useMemo(() => { + const count = Math.max(1, tabs.length); + + if (tabsViewportWidthPx <= 0) { + return MAX_TAB_WIDTH_PX; + } + + const totalGapPx = Math.max(0, count - 1) * TAB_GAP_PX; + const availableForTabsPx = Math.max(0, tabsViewportWidthPx - totalGapPx); + const fittedWidthPx = Math.floor(availableForTabsPx / count); + + return Math.max( + MIN_TAB_WIDTH_PX, + Math.min(MAX_TAB_WIDTH_PX, fittedWidthPx), + ); + }, [tabs.length, tabsViewportWidthPx]); + + const totalStripWidthPx = useMemo(() => { + if (tabs.length === 0) { + return 0; + } + + return tabs.length * tabWidthPx + (tabs.length - 1) * TAB_GAP_PX; + }, [tabs.length, tabWidthPx]); + + const slotSpanPx = tabWidthPx + TAB_GAP_PX; + + useEffect(() => { + const viewport = tabsViewportRef.current; + if (!viewport) { + return; + } + + const updateWidth = () => { + setTabsViewportWidthPx(viewport.getBoundingClientRect().width); + }; + + updateWidth(); + + const resizeObserver = new ResizeObserver(() => { + updateWidth(); + }); + + resizeObserver.observe(viewport); + window.addEventListener('resize', updateWidth); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateWidth); + }; + }, []); + + useEffect(() => { + if (!dragState) { + setPreviewOrder(null); + } + }, [dragState]); + + useEffect(() => { + if (!dragState) { + return; + } + + const handlePointerMove = (event: PointerEvent) => { + setDragState((prev) => + prev + ? { + ...prev, + currentPointerX: event.clientX, + } + : null, + ); + }; + + const handlePointerUp = () => { + if (previewOrder) { + onReorderTabs(previewOrder); + } + + setDragState(null); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }; + }, [dragState, previewOrder, onReorderTabs]); + + useEffect(() => { + if (!dragState) { + return; + } + + const viewportEl = tabsViewportRef.current; + if (!viewportEl) { + return; + } + + const tabCount = activeOrder.length; + if (tabCount === 0) { + return; + } + + const viewportRect = viewportEl.getBoundingClientRect(); + + const minOverlayLeftPx = 0; + const maxOverlayLeftPx = Math.max(0, totalStripWidthPx - tabWidthPx); + + const rawOverlayLeftPx = + dragState.currentPointerX - + viewportRect.left + + viewportEl.scrollLeft - + dragState.pointerOffsetWithinTab; + + const clampedOverlayLeftPx = clamp( + rawOverlayLeftPx, + minOverlayLeftPx, + maxOverlayLeftPx, + ); + + const draggedCenterX = clampedOverlayLeftPx + tabWidthPx / 2; + const nextIndex = clamp( + Math.floor((draggedCenterX + TAB_GAP_PX / 2) / slotSpanPx), + 0, + tabCount - 1, + ); + + setDragState((prev) => { + if (!prev || prev.overlayLeftPx === clampedOverlayLeftPx) { + return prev; + } + + return { + ...prev, + overlayLeftPx: clampedOverlayLeftPx, + }; + }); + + const currentIndex = activeOrder.indexOf(dragState.draggedAppId); + if (currentIndex === -1 || nextIndex === currentIndex) { + return; + } + + setPreviewOrder(reorderIds(activeOrder, currentIndex, nextIndex)); + }, [dragState, activeOrder, tabWidthPx, slotSpanPx, totalStripWidthPx]); + + return ( +
+ + + +
+
+ {orderedTabs.map((tab) => { + const isDragged = + dragState?.draggedAppId === tab.app.common.identity.id; + + return ( +
+
{ + if (!dragState && !tab.isActive) { + onSelectApp(tab); + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (!dragState && !tab.isActive) { + onSelectApp(tab); + } + } + }} + onPointerDown={(event) => { + if (event.button !== 0) { + return; + } + + onSelectApp(tab); + + const viewportEl = tabsViewportRef.current; + if (!viewportEl) { + return; + } + + const viewportRect = viewportEl.getBoundingClientRect(); + const currentIndex = activeOrder.indexOf( + tab.app.common.identity.id, + ); + const slotLeftPx = currentIndex * slotSpanPx; + const pointerXWithinStripPx = + event.clientX - viewportRect.left + viewportEl.scrollLeft; + const pointerOffsetWithinTabPx = clamp( + pointerXWithinStripPx - slotLeftPx, + 0, + tabWidthPx, + ); + + setPreviewOrder((prev) => prev ?? baseOrder); + setDragState({ + draggedAppId: tab.app.common.identity.id, + pointerOffsetWithinTab: pointerOffsetWithinTabPx, + currentPointerX: event.clientX, + overlayLeftPx: slotLeftPx, + }); + }} + className={clsx( + 'group flex h-9 w-full items-center gap-2 rounded-t-md border border-b-0 px-3 text-left transition-[background-color,color] duration-150 select-none', + tab.isActive + ? 'bg-background' + : 'bg-muted text-muted-foreground hover:bg-muted/80', + )} + > +
+ +
+ + + {tab.app.common.activeSnapshot.manifest.name} + + + + + +
+
+ ); + })} + + {dragState + ? (() => { + const draggedTab = tabsById.get(dragState.draggedAppId); + if (!draggedTab) { + return null; + } + + return ( +
+
+
+ +
+ + + {draggedTab.app.common.activeSnapshot.manifest.name} + + + +
+ +
+
+ ); + })() + : null} +
+
+ + {activeAppHasDonation && ( + + )} +
+ ); +} diff --git a/src/components/apps/AppTile.tsx b/src/components/apps/AppTile.tsx new file mode 100644 index 000000000..12160579a --- /dev/null +++ b/src/components/apps/AppTile.tsx @@ -0,0 +1,72 @@ +import type { SageAppView } from '@/bindings'; +import type { SandboxLaunchDecision } from '@/lib/apps/sandboxPolicy'; +import { AppIcon } from '@/components/apps/AppIcon.tsx'; +import React from 'react'; + +interface Props { + app: SageAppView; + launchDecision: SandboxLaunchDecision; + onOpen: () => void; + onContextMenu: (event: React.MouseEvent) => void; +} + +export function AppTile({ app, launchDecision, onOpen, onContextMenu }: Props) { + const isChecking = + !launchDecision.allowed && + launchDecision.title === 'Sandbox tests are still running'; + + const isBlocked = !launchDecision.allowed && !isChecking; + + function handleOpen() { + if (!launchDecision.allowed) return; + onOpen(); + } + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleOpen(); + } + }} + onContextMenu={onContextMenu} + aria-disabled={!launchDecision.allowed} + className='relative group flex cursor-pointer flex-col items-center gap-3 rounded-2xl p-4 text-center transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:cursor-default' + > + {isChecking || isBlocked ? ( +
+ {isChecking ? ( +
+
+
Checking…
+
+ ) : ( +
+ Blocked +
+ )} +
+ ) : null} + +
+ +
+ +
+
+ {app.common.activeSnapshot.manifest.name} +
+ + {isBlocked ? ( +
+ {launchDecision.title} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/apps/AppsLaunchpad.tsx b/src/components/apps/AppsLaunchpad.tsx new file mode 100644 index 000000000..3054658c7 --- /dev/null +++ b/src/components/apps/AppsLaunchpad.tsx @@ -0,0 +1,548 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { CorruptedAppCard } from '@/components/apps/CorruptedAppCard'; +import { AppsLaunchpadContextMenu } from '@/components/apps/AppsLaunchpadContextMenu'; +import { Button } from '@/components/ui/button'; +import { formatSandboxLaunchDecision } from '@/lib/apps/sandboxPolicy'; +import { + commands, + ListedSageAppView, + SystemSageAppView, + UserSageAppView, +} from '@/bindings.ts'; +import { useApps } from '@/contexts/AppsContext.tsx'; +import { Plus } from 'lucide-react'; +import { AppsPageActionsMenu } from '@/components/apps/AppsPageActionsMenu'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AppTile } from '@/components/apps/AppTile'; +import { formatAppError } from '@/lib/apps/formatAppError.ts'; +import { openAppPermissionsReview } from '@/lib/apps/openAppUpdate.ts'; + +type UserInstalledEntry = { kind: 'user' } & UserSageAppView; +type SystemInstalledEntry = { kind: 'system' } & SystemSageAppView; +type InstalledEntry = UserInstalledEntry | SystemInstalledEntry; +type CorruptedEntry = Extract; + +type AppContextMenuState = { + app: InstalledEntry; + x: number; + y: number; +} | null; + +function isInstalledEntry(entry: ListedSageAppView): entry is InstalledEntry { + return entry.kind === 'user' || entry.kind === 'system'; +} + +function isUserInstalledEntry( + entry: InstalledEntry, +): entry is UserInstalledEntry { + return entry.kind === 'user'; +} + +function isCorruptedEntry(entry: ListedSageAppView): entry is CorruptedEntry { + return entry.kind === 'corrupted'; +} + +function clampContextMenuPosition(args: { + x: number; + y: number; + containerWidth: number; + containerHeight: number; +}) { + const menuWidth = 260; + const menuHeight = 260; + const padding = 8; + + return { + x: Math.max( + padding, + Math.min(args.x, args.containerWidth - menuWidth - padding), + ), + y: Math.max( + padding, + Math.min(args.y, args.containerHeight - menuHeight - padding), + ), + }; +} + +function formatErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + + if (typeof err === 'string') { + return err; + } + + try { + return JSON.stringify(err, null, 2); + } catch { + return String(err); + } +} + +async function openApp(appId: string) { + return await commands.appsStartUserApp({ + appId, + }); +} + +export function AppsLaunchpad() { + const [contextMenu, setContextMenu] = useState(null); + const pageRef = useRef(null); + + const [updateCheckStateByAppId, setUpdateCheckStateByAppId] = useState< + Record + >({}); + + const [clearingDataByAppId, setClearingDataByAppId] = useState< + Record + >({}); + + const [clearDataErrorByAppId, setClearDataErrorByAppId] = useState< + Record + >({}); + + const { + apps, + runtimes, + loading, + error, + refresh, + uninstallApp, + clearAppStorage, + pendingUpdates, + busyAppIds, + getLaunchGate, + } = useApps(); + + const runningAppIds = useMemo(() => { + return new Set(runtimes.map((runtime) => runtime.app.common.identity.id)); + }, [runtimes]); + + const installedApps = useMemo( + () => + apps.filter((entry): entry is InstalledEntry => isInstalledEntry(entry)), + [apps], + ); + + const corruptedApps = useMemo(() => apps.filter(isCorruptedEntry), [apps]); + + const contextMenuAppId = contextMenu?.app.common.identity.id ?? null; + + const contextMenuPendingUpdate = contextMenuAppId + ? (pendingUpdates[contextMenuAppId] ?? { kind: 'none' as const }) + : { kind: 'none' as const }; + + const contextMenuBusy = contextMenuAppId + ? (busyAppIds[contextMenuAppId] ?? false) + : false; + + const contextMenuCheckState = contextMenuAppId + ? (updateCheckStateByAppId[contextMenuAppId] ?? 'idle') + : 'idle'; + + const contextMenuAppIsRunning = contextMenuAppId + ? runningAppIds.has(contextMenuAppId) + : false; + + const contextMenuClearDataBusy = contextMenuAppId + ? (clearingDataByAppId[contextMenuAppId] ?? false) + : false; + + const contextMenuClearDataError = contextMenuAppId + ? (clearDataErrorByAppId[contextMenuAppId] ?? null) + : null; + + const contextMenuHasUpdate = contextMenuPendingUpdate.kind !== 'none'; + const contextMenuUpdateIsInstallable = + contextMenuPendingUpdate.kind === 'readyToApply'; + + const closeContextMenu = useCallback(() => { + setContextMenu((prevContextMenu) => { + if (prevContextMenu) { + setUpdateCheckStateByAppId((prev) => { + if (prev[prevContextMenu.app.common.identity.id] !== 'up_to_date') { + return prev; + } + + return { + ...prev, + [prevContextMenu.app.common.identity.id]: 'idle', + }; + }); + } + + return null; + }); + }, []); + + async function handleCheckForUpdate(appId: string) { + setUpdateCheckStateByAppId((prev) => ({ + ...prev, + [appId]: 'idle', + })); + + setClearDataErrorByAppId((prev) => ({ + ...prev, + [appId]: null, + })); + + try { + const preview = await commands.appsCheckAppUpdate(appId); + + if (!preview) { + setUpdateCheckStateByAppId((prev) => ({ + ...prev, + [appId]: 'up_to_date', + })); + } + } catch (err) { + const message = formatAppError(err); + + console.error('checkAppUpdate failed:', err); + + setClearDataErrorByAppId((prev) => ({ + ...prev, + [appId]: `Update check failed: ${message}`, + })); + } + } + + async function handleApplyUpdate(appId: string) { + setClearDataErrorByAppId((prev) => ({ + ...prev, + [appId]: null, + })); + + try { + await commands.appsApplyAppUpdate(appId); + } catch (err) { + const message = formatAppError(err); + + console.error('applyAppUpdate failed:', err); + + setClearDataErrorByAppId((prev) => ({ + ...prev, + [appId]: `Update failed: ${message}`, + })); + } + } + + const handleClearData = useCallback( + async (app: InstalledEntry) => { + const appId = app.common.identity.id; + + setClearingDataByAppId((prev) => ({ + ...prev, + [appId]: true, + })); + + setClearDataErrorByAppId((prev) => ({ + ...prev, + [appId]: null, + })); + + try { + await clearAppStorage(appId); + await refresh(); + } catch (err) { + setClearDataErrorByAppId((prev) => ({ + ...prev, + [appId]: formatErrorMessage(err), + })); + } finally { + setClearingDataByAppId((prev) => + Object.fromEntries( + Object.entries(prev).filter(([key]) => key !== appId), + ), + ); + } + }, + [clearAppStorage, refresh], + ); + + useEffect(() => { + if (!contextMenu || contextMenuCheckState !== 'up_to_date') { + return; + } + + const timeoutId = window.setTimeout(() => { + setUpdateCheckStateByAppId((prev) => { + if (prev[contextMenu.app.common.identity.id] !== 'up_to_date') { + return prev; + } + + return { + ...prev, + [contextMenu.app.common.identity.id]: 'idle', + }; + }); + }, 3000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [contextMenu, contextMenuCheckState]); + + useEffect(() => { + if (!contextMenu) { + return; + } + + const handleClose = () => { + if (clearingDataByAppId[contextMenu.app.common.identity.id]) { + return; + } + + closeContextMenu(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (clearingDataByAppId[contextMenu.app.common.identity.id]) { + return; + } + + closeContextMenu(); + } + }; + + window.addEventListener('click', handleClose); + window.addEventListener('resize', handleClose); + window.addEventListener('scroll', handleClose, true); + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('click', handleClose); + window.removeEventListener('resize', handleClose); + window.removeEventListener('scroll', handleClose, true); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [contextMenu, clearingDataByAppId, closeContextMenu]); + + if (loading) { + return ( +
+ + Loading apps... + Please wait. + +
+ ); + } + + return ( +
+
+
+

Apps

+

+ Launch and manage installed Sage apps. +

+
+ +
+ + + { + void commands.appsStartSystemApp({ + kind: 'sandboxTests', + }); + }} + onClose={() => { + // + }} + /> +
+
+ +
+ {error ? ( + + Apps error + {error} + + ) : null} + + {installedApps.length === 0 ? ( + + No apps installed + + Install a Sage app package to get started. + + + ) : null} + + {installedApps.length > 0 ? ( +
+ {installedApps.map((app) => ( + { + void openApp(app.common.identity.id); + }} + onContextMenu={(event) => { + event.preventDefault(); + + const pageEl = pageRef.current; + if (!pageEl) { + return; + } + + const pageRect = pageEl.getBoundingClientRect(); + + const localX = event.clientX - pageRect.left; + const localY = event.clientY - pageRect.top; + + const position = clampContextMenuPosition({ + x: localX, + y: localY, + containerWidth: pageRect.width, + containerHeight: pageRect.height, + }); + + setClearDataErrorByAppId((prev) => ({ + ...prev, + [app.common.identity.id]: null, + })); + + setContextMenu({ + app, + x: position.x, + y: position.y, + }); + }} + /> + ))} +
+ ) : null} + + {corruptedApps.length > 0 ? ( +
+
+

+ Corrupted apps +

+

+ These app installations could not be loaded correctly. +

+
+ +
+ {corruptedApps.map((entry) => ( + uninstallApp(entry.id)} + /> + ))} +
+
+ ) : null} +
+ + { + if (!contextMenu) { + return; + } + + setUpdateCheckStateByAppId((prev) => ({ + ...prev, + [contextMenu.app.common.identity.id]: 'idle', + })); + + void openApp(contextMenu.app.common.identity.id); + closeContextMenu(); + }} + onCheckForUpdate={() => { + if (!contextMenu || !isUserInstalledEntry(contextMenu.app)) { + return; + } + + void handleCheckForUpdate(contextMenu.app.common.identity.id); + }} + onUpdate={() => { + if (!contextMenu || !isUserInstalledEntry(contextMenu.app)) { + return; + } + + const appId = contextMenu.app.common.identity.id; + + closeContextMenu(); + + void handleApplyUpdate(appId); + }} + onChangePermissions={() => { + if (!contextMenu || !isUserInstalledEntry(contextMenu.app)) { + return; + } + + const appId = contextMenu.app.common.identity.id; + + void openAppPermissionsReview(appId); + closeContextMenu(); + }} + onClearData={() => { + if (!contextMenu) { + return; + } + + void handleClearData(contextMenu.app); + }} + onUninstall={() => { + if (!contextMenu || !isUserInstalledEntry(contextMenu.app)) { + return; + } + + setUpdateCheckStateByAppId((prev) => ({ + ...prev, + [contextMenu.app.common.identity.id]: 'idle', + })); + + void uninstallApp(contextMenu.app.common.identity.id).finally(() => { + closeContextMenu(); + }); + }} + /> +
+ ); +} diff --git a/src/components/apps/AppsLaunchpadContextMenu.tsx b/src/components/apps/AppsLaunchpadContextMenu.tsx new file mode 100644 index 000000000..ba6a14cb6 --- /dev/null +++ b/src/components/apps/AppsLaunchpadContextMenu.tsx @@ -0,0 +1,165 @@ +import { Button } from '@/components/ui/button'; + +export type AppsLaunchpadContextMenuUpdateState = + | 'idle' + | 'checking' + | 'up_to_date'; + +interface Props { + open: boolean; + x: number; + y: number; + busy: boolean; + hasUpdate: boolean; + updateIsInstallable: boolean; + isRunning: boolean; + updateCheckState: AppsLaunchpadContextMenuUpdateState; + clearDataBusy?: boolean; + clearDataError?: string | null; + clearDataEnabled?: boolean; + clearDataDisabledReason?: string | null; + onClose: () => void; + onOpen: () => void; + onCheckForUpdate: () => void; + onUpdate: () => void; + onChangePermissions: () => void; + onClearData: () => void; + onUninstall: () => void; +} + +export function AppsLaunchpadContextMenu({ + open, + x, + y, + busy, + hasUpdate, + updateIsInstallable, + isRunning, + updateCheckState, + clearDataBusy = false, + clearDataError = null, + clearDataEnabled = true, + clearDataDisabledReason = null, + onClose, + onOpen, + onCheckForUpdate, + onUpdate, + onChangePermissions, + onClearData, + onUninstall, +}: Props) { + if (!open) { + return null; + } + + const clearDataDisabled = busy || clearDataBusy || !clearDataEnabled; + const updateDisabled = busy || clearDataBusy; + + const updateLabel = updateIsInstallable + ? isRunning + ? 'Update and reopen' + : 'Update' + : 'Review update'; + + return ( + <> +
+ +
{ + event.stopPropagation(); + }} + > + + + {!hasUpdate ? ( + + ) : ( + + )} + +
+ + + + + + {clearDataError ? ( +
+ {clearDataError} +
+ ) : !clearDataEnabled && clearDataDisabledReason ? ( +
+ {clearDataDisabledReason} +
+ ) : null} + + +
+ + ); +} diff --git a/src/components/apps/AppsPageActionsMenu.tsx b/src/components/apps/AppsPageActionsMenu.tsx new file mode 100644 index 000000000..60ab7cf99 --- /dev/null +++ b/src/components/apps/AppsPageActionsMenu.tsx @@ -0,0 +1,96 @@ +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { commands } from '@/bindings'; +import { Menu } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +interface Props { + onOpenSandboxTests: () => void; + onClose?: () => void; +} + +export function AppsPageActionsMenu({ onOpenSandboxTests, onClose }: Props) { + const [open, setOpen] = useState(false); + + const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(false); + const [loadingAutoUpdate, setLoadingAutoUpdate] = useState(true); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const enabled = await commands.appsGetAutoUpdateEnabled(); + + if (!cancelled) { + setAutoUpdateEnabled(enabled); + } + } catch (err) { + console.error('Failed to load apps auto update setting', err); + } finally { + if (!cancelled) { + setLoadingAutoUpdate(false); + } + } + } + + load(); + + return () => { + cancelled = true; + }; + }, []); + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + + if (!nextOpen) { + onClose?.(); + } + } + + async function handleToggleAutoUpdate() { + try { + const enabled = + await commands.appsSetAutoUpdateEnabled(!autoUpdateEnabled); + + setAutoUpdateEnabled(enabled); + } catch (err) { + console.error('Failed to update apps auto update setting', err); + } + } + + return ( + + + + + + + { + event.preventDefault(); + }} + onClick={handleToggleAutoUpdate} + > + {autoUpdateEnabled ? 'Disable auto-update' : 'Enable auto-update'} + + + + + + Sandbox tests + + + + ); +} diff --git a/src/components/apps/CorruptedAppCard.tsx b/src/components/apps/CorruptedAppCard.tsx new file mode 100644 index 000000000..b9fe4dcac --- /dev/null +++ b/src/components/apps/CorruptedAppCard.tsx @@ -0,0 +1,81 @@ +import { AppIconContent } from '@/components/apps/AppIcon'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { CorruptedInstalledSageApp } from '@/bindings'; +import { Trash2, TriangleAlert } from 'lucide-react'; + +interface Props { + app: CorruptedInstalledSageApp; + onRemove: () => Promise; +} + +function appDisplayName(app: CorruptedInstalledSageApp): string { + return app.manifestHeader?.name ?? app.id; +} + +function appIconUrl(app: CorruptedInstalledSageApp): string | null { + const icon = app.manifestHeader?.icon; + if (!icon) return null; + + return `sage-app://${app.id}/${icon}`; +} + +export function CorruptedAppCard({ app, onRemove }: Props) { + const name = appDisplayName(app); + const iconUrl = appIconUrl(app); + const sageVersion = app.manifestHeader?.sageVersion; + + return ( + + +
+
+ +
+ +
+ + + {name} + corrupted + {app.manifestHeader ? ( + + manifest v{app.manifestHeader.manifestVersion} + + ) : null} + + +
+ ID: {app.id} +
+ +
+ App dir: {app.appDir} +
+ + {sageVersion ? ( +
+ Requires Sage {sageVersion.min} + {sageVersion.testedMax + ? ` · tested up to ${sageVersion.testedMax}` + : null} +
+ ) : null} +
+
+ +
+ +
+
+ + +
{app.error}
+
+
+ ); +} diff --git a/src/components/apps/SystemAppModalLayer.tsx b/src/components/apps/SystemAppModalLayer.tsx new file mode 100644 index 000000000..e4b13e90e --- /dev/null +++ b/src/components/apps/SystemAppModalLayer.tsx @@ -0,0 +1,35 @@ +import { useMemo, useRef } from 'react'; +import { useRuntimeWebviewBounds } from '@/hooks/useRuntimeWebviewBounds'; +import { useApps } from '@/contexts/AppsContext.tsx'; + +export function SystemAppModalLayer() { + const containerRef = useRef(null); + + const { runtimes } = useApps(); + + const modalRuntime = useMemo(() => { + return runtimes.find( + (runtime) => + runtime.app.kind === 'system' && + runtime.presentation.kind === 'Modal' && + runtime.visibility === 'Visible', + ); + }, [runtimes]); + + useRuntimeWebviewBounds({ + webviewLabel: modalRuntime?.webviewLabel ?? null, + containerRef, + enabled: !!modalRuntime, + }); + + if (!modalRuntime) { + return null; + } + + return ( +
+ ); +} diff --git a/src/contexts/AppsContext.tsx b/src/contexts/AppsContext.tsx new file mode 100644 index 000000000..7d260b171 --- /dev/null +++ b/src/contexts/AppsContext.tsx @@ -0,0 +1,489 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { + type AppLaunchGateResult, + commands, + type ListedSageAppView, + type SageAppRuntimeRecordView, + type SandboxStateView, + type SystemSageAppView, + type UserSageAppView, +} from '@/bindings'; + +type UserInstalledEntry = { kind: 'user' } & UserSageAppView; +type SystemInstalledEntry = { kind: 'system' } & SystemSageAppView; +type InstalledEntry = UserInstalledEntry | SystemInstalledEntry; + +const SAGE_RUNTIME_EVENT_NAME = 'apps:runtime-event'; + +export type PendingUpdateStatusView = + | { kind: 'none' } + | { kind: 'readyToApply' } + | { kind: 'requiresReview' }; + +interface RuntimeManagerRuntimesChangedEvent { + type: 'runtimeManager.runtimesChanged'; + payload: { + runtimes: SageAppRuntimeRecordView[]; + }; +} + +interface ActiveTaskbarRuntimeChangedEvent { + type: 'runtimeManager.activeTaskbarRuntimeChanged'; + payload: { + hostWindowLabel: string; + appId: string | null; + runtimeId: string | null; + }; +} + +interface ListedAppsChangedEvent { + type: 'appRegistry.listedAppsChanged'; + payload: { + apps: ListedSageAppView[]; + }; +} + +interface SandboxStateChangedEvent { + type: 'sandbox.stateChanged'; + payload: { + state: SandboxStateView; + }; +} + +interface PendingUpdateChangedEvent { + type: 'appUpdate.pendingUpdateChanged'; + payload: { + appId: string; + status: PendingUpdateStatusView; + }; +} + +type SageRuntimeEvent = + | RuntimeManagerRuntimesChangedEvent + | ActiveTaskbarRuntimeChangedEvent + | ListedAppsChangedEvent + | SandboxStateChangedEvent + | PendingUpdateChangedEvent; + +type ActiveTaskbarRuntime = { + appId: string | null; + runtimeId: string | null; +} | null; + +interface AppsContextValue { + apps: ListedSageAppView[]; + runtimes: SageAppRuntimeRecordView[]; + taskbarRuntimes: SageAppRuntimeRecordView[]; + loading: boolean; + error: string | null; + busyAppIds: Record; + pendingUpdates: Record; + sandboxState: SandboxStateView | null; + launchGatesByAppId: Record; + + getApp: (appId: string) => UserSageAppView | undefined; + getListedApp: (appId: string) => InstalledEntry | undefined; + getLaunchGate: (appId: string) => AppLaunchGateResult | null; + getTaskbarRuntime: (appId: string) => SageAppRuntimeRecordView | null; + + refresh: () => Promise; + refreshInstalledApps: () => Promise; + refreshRuntimes: () => Promise; + refreshLaunchGates: (listed: ListedSageAppView[]) => Promise; + setBusy: (appId: string, busy: boolean) => void; + + uninstallApp: (appId: string) => Promise; + clearAppStorage: (appId: string) => Promise; + activeTaskbarRuntime: ActiveTaskbarRuntime; +} + +const AppsContext = createContext(null); + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + + try { + return JSON.stringify(err, null, 2); + } catch { + return String(err); + } +} + +function isInstalledEntry(entry: ListedSageAppView): entry is InstalledEntry { + return entry.kind === 'user' || entry.kind === 'system'; +} + +function installedAppId(app: InstalledEntry): string { + return app.common.identity.id; +} + +function runtimeAppId(runtime: SageAppRuntimeRecordView): string { + return runtime.app.common.identity.id; +} + +function isUserListedApp( + entry: ListedSageAppView, +): entry is { kind: 'user' } & UserSageAppView { + return entry.kind === 'user'; +} + +function isTaskbarRuntime(runtime: SageAppRuntimeRecordView): boolean { + return runtime.presentation.kind === 'Taskbar'; +} + +export function AppsProvider({ children }: { children: ReactNode }) { + const [apps, setApps] = useState([]); + const appsRef = useRef([]); + + const [runtimes, setRuntimes] = useState([]); + const [taskbarRuntimes, setTaskbarRuntimes] = useState< + SageAppRuntimeRecordView[] + >([]); + const [activeTaskbarRuntime, setActiveTaskbarRuntime] = + useState(null); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busyAppIds, setBusyAppIds] = useState>({}); + + const [pendingUpdates, setPendingUpdates] = useState< + Record + >({}); + + const [sandboxState, setSandboxState] = useState( + null, + ); + + const [launchGatesByAppId, setLaunchGatesByAppId] = useState< + Record + >({}); + + useEffect(() => { + appsRef.current = apps; + }, [apps]); + + const refreshRuntimes = useCallback(async () => { + try { + const next = await commands.appsListRuntimes(); + setRuntimes(next); + setTaskbarRuntimes(next.filter(isTaskbarRuntime)); + } catch (err) { + console.error('Failed to refresh runtimes:', err); + } + }, []); + + const refreshLaunchGates = useCallback( + async (listed: ListedSageAppView[]) => { + const installed = listed.filter(isInstalledEntry); + + const results = await Promise.allSettled( + installed.map(async (app) => { + const appId = installedAppId(app); + const gate = await commands.appsGetAppLaunchGate(appId); + return [appId, gate] as const; + }), + ); + + const next: Record = {}; + + for (const result of results) { + if (result.status === 'fulfilled') { + const [appId, gate] = result.value; + next[appId] = gate; + } else { + console.error('Failed to refresh launch gate:', result.reason); + } + } + + setLaunchGatesByAppId(next); + }, + [], + ); + + const refreshInstalledApps = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const listed = await commands.appsListInstalledApps(); + + setApps(listed); + setLoading(false); + + void (async () => { + try { + const sandbox = await commands.appsGetSandboxState(); + setSandboxState(sandbox); + } catch (err) { + console.error('Failed to refresh sandbox state:', err); + } + })(); + + void refreshLaunchGates(listed); + } catch (err) { + setError(formatError(err)); + setLoading(false); + } + }, [refreshLaunchGates]); + + useEffect(() => { + void refreshInstalledApps(); + void refreshRuntimes(); + }, [refreshInstalledApps, refreshRuntimes]); + + useEffect(() => { + let isCancelled = false; + let unsubscribe: (() => void) | undefined; + + const setup = async () => { + try { + unsubscribe = await listen( + SAGE_RUNTIME_EVENT_NAME, + (event) => { + if (isCancelled) return; + + const runtimeEvent = event.payload; + + switch (runtimeEvent.type) { + case 'runtimeManager.runtimesChanged': + setRuntimes(runtimeEvent.payload.runtimes); + setTaskbarRuntimes( + runtimeEvent.payload.runtimes.filter(isTaskbarRuntime), + ); + break; + + case 'runtimeManager.activeTaskbarRuntimeChanged': + if ( + runtimeEvent.payload.hostWindowLabel !== + getCurrentWindow().label + ) { + break; + } + + setActiveTaskbarRuntime({ + appId: runtimeEvent.payload.appId, + runtimeId: runtimeEvent.payload.runtimeId, + }); + break; + + case 'appRegistry.listedAppsChanged': + setApps(runtimeEvent.payload.apps); + setLoading(false); + setError(null); + void refreshLaunchGates(runtimeEvent.payload.apps); + break; + + case 'sandbox.stateChanged': + setSandboxState(runtimeEvent.payload.state); + void refreshLaunchGates(appsRef.current); + break; + + case 'appUpdate.pendingUpdateChanged': + setPendingUpdates((prev) => ({ + ...prev, + [runtimeEvent.payload.appId]: runtimeEvent.payload.status, + })); + break; + } + }, + ); + } catch (err) { + if (!isCancelled) { + console.error('Failed to subscribe to runtime events:', err); + } + } + }; + + void setup(); + + return () => { + isCancelled = true; + unsubscribe?.(); + }; + }, [refreshLaunchGates]); + + const currentSandboxRunId = sandboxState?.currentRun?.runId ?? null; + + useEffect(() => { + if (!currentSandboxRunId) return; + + let isCancelled = false; + + const refreshSandboxState = async () => { + try { + const next = await commands.appsGetSandboxState(); + if (!isCancelled) { + setSandboxState(next); + void refreshLaunchGates(appsRef.current); + } + } catch (err) { + if (!isCancelled) { + console.error('Failed to refresh sandbox state:', err); + } + } + }; + + void refreshSandboxState(); + + const intervalId = window.setInterval(() => { + void refreshSandboxState(); + }, 1000); + + return () => { + isCancelled = true; + window.clearInterval(intervalId); + }; + }, [currentSandboxRunId, refreshLaunchGates]); + + const refresh = refreshInstalledApps; + + const getListedApp = useCallback( + (appId: string): InstalledEntry | undefined => { + return apps.find( + (item): item is InstalledEntry => + isInstalledEntry(item) && item.common.identity.id === appId, + ); + }, + [apps], + ); + + const getLaunchGate = useCallback( + (appId: string): AppLaunchGateResult | null => + launchGatesByAppId[appId] ?? null, + [launchGatesByAppId], + ); + + const getTaskbarRuntime = useCallback( + (appId: string) => { + return ( + taskbarRuntimes.find((runtime) => runtimeAppId(runtime) === appId) ?? + null + ); + }, + [taskbarRuntimes], + ); + + const getApp = useCallback( + (appId: string): UserSageAppView | undefined => { + return apps.find( + (item): item is { kind: 'user' } & UserSageAppView => + isUserListedApp(item) && item.common.identity.id === appId, + ); + }, + [apps], + ); + + const setBusy = useCallback((appId: string, busy: boolean) => { + setBusyAppIds((prev) => ({ ...prev, [appId]: busy })); + }, []); + + const uninstallApp = useCallback( + async (appId: string) => { + setBusy(appId, true); + + try { + await commands.appsUninstallApp(appId); + + setPendingUpdates((prev) => + Object.fromEntries( + Object.entries(prev).filter(([key]) => key !== appId), + ), + ); + + setLaunchGatesByAppId((prev) => + Object.fromEntries( + Object.entries(prev).filter(([key]) => key !== appId), + ), + ); + } finally { + setBusy(appId, false); + } + }, + [setBusy], + ); + + const clearAppStorage = useCallback( + async (appId: string) => { + await commands.appsClearRuntimeBrowsingData(appId); + await refreshInstalledApps(); + await refreshRuntimes(); + }, + [refreshInstalledApps, refreshRuntimes], + ); + + const value = useMemo( + () => ({ + apps, + runtimes, + taskbarRuntimes, + loading, + error, + busyAppIds, + pendingUpdates, + sandboxState, + launchGatesByAppId, + + getApp, + getListedApp, + getLaunchGate, + getTaskbarRuntime, + + refresh, + refreshInstalledApps, + refreshRuntimes, + refreshLaunchGates, + setBusy, + + uninstallApp, + clearAppStorage, + activeTaskbarRuntime, + }), + [ + apps, + runtimes, + taskbarRuntimes, + loading, + error, + busyAppIds, + pendingUpdates, + sandboxState, + launchGatesByAppId, + getApp, + getListedApp, + getLaunchGate, + getTaskbarRuntime, + refresh, + refreshInstalledApps, + refreshRuntimes, + refreshLaunchGates, + setBusy, + uninstallApp, + clearAppStorage, + activeTaskbarRuntime, + ], + ); + + return {children}; +} + +export function useApps() { + const value = useContext(AppsContext); + + if (!value) { + throw new Error('useApps must be used within AppsProvider'); + } + + return value; +} diff --git a/src/dev/system-apps/setupDevSystemAppsReload.ts b/src/dev/system-apps/setupDevSystemAppsReload.ts new file mode 100644 index 000000000..5035d8397 --- /dev/null +++ b/src/dev/system-apps/setupDevSystemAppsReload.ts @@ -0,0 +1,71 @@ +import { commands, type SageAppRuntimeRecordView } from '@/bindings'; + +type GetRuntimes = () => SageAppRuntimeRecordView[]; + +interface DevMessage { + type?: string; + ok?: boolean; +} + +export function setupDevSystemAppsReload(getRuntimes: GetRuntimes): () => void { + let disposed = false; + let ws: WebSocket | null = null; + let reconnectTimer: number | null = null; + + const connect = () => { + if (disposed) return; + + ws = new WebSocket('ws://127.0.0.1:1421'); + + ws.onmessage = (event) => { + let payload: DevMessage; + + try { + payload = JSON.parse(event.data) as DevMessage; + } catch { + return; + } + + if (payload.type !== 'system-apps-built' || payload.ok !== true) { + return; + } + + for (const runtime of getRuntimes()) { + if (runtime.app.kind !== 'system') { + continue; + } + + void commands + .appsDevReloadRuntime({ + appId: runtime.app.common.identity.id, + }) + .catch(() => { + // + }); + } + }; + + ws.onclose = () => { + if (disposed) return; + + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connect(); + }, 1000); + }; + }; + + connect(); + + return () => { + disposed = true; + + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + ws?.close(); + ws = null; + }; +} diff --git a/src/hooks/useRuntimeWebviewBounds.ts b/src/hooks/useRuntimeWebviewBounds.ts new file mode 100644 index 000000000..92a8c1fab --- /dev/null +++ b/src/hooks/useRuntimeWebviewBounds.ts @@ -0,0 +1,108 @@ +import React, { useCallback, useEffect } from 'react'; +import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'; +import { Webview } from '@tauri-apps/api/webview'; + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + + try { + return JSON.stringify(err, null, 2); + } catch { + return String(err); + } +} + +interface Args { + webviewLabel: string | null | undefined; + containerRef: React.RefObject; + enabled?: boolean; +} + +export function useRuntimeWebviewBounds({ + webviewLabel, + containerRef, + enabled = true, +}: Args) { + const syncBounds = useCallback(async () => { + if (!enabled || !webviewLabel) { + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + + const webview = await Webview.getByLabel(webviewLabel).catch(() => null); + if (!webview) { + return; + } + + const rect = container.getBoundingClientRect(); + + await webview.setPosition( + new LogicalPosition(Math.round(rect.left), Math.round(rect.top)), + ); + + await webview.setSize( + new LogicalSize( + Math.max(1, Math.round(rect.width)), + Math.max(1, Math.round(rect.height)), + ), + ); + }, [containerRef, enabled, webviewLabel]); + + const scheduleSyncBounds = useCallback(() => { + requestAnimationFrame(() => { + void syncBounds().catch((err) => { + const message = formatError(err); + + if (message.includes('webview not found')) { + return; + } + + console.error('Failed to sync runtime webview bounds:', err); + }); + }); + }, [syncBounds]); + + useEffect(() => { + if (!enabled || !webviewLabel || !containerRef.current) { + return; + } + + let disposed = false; + let delayedSyncTimers: number[] = []; + let resizeObserver: ResizeObserver | null = null; + + const run = () => { + if (!disposed) { + scheduleSyncBounds(); + } + }; + + run(); + + delayedSyncTimers = [0, 50, 150, 300].map((delay) => + window.setTimeout(run, delay), + ); + + resizeObserver = new ResizeObserver(run); + resizeObserver.observe(containerRef.current); + + window.addEventListener('resize', run); + + return () => { + disposed = true; + delayedSyncTimers.forEach((id) => window.clearTimeout(id)); + resizeObserver?.disconnect(); + window.removeEventListener('resize', run); + }; + }, [containerRef, enabled, scheduleSyncBounds, webviewLabel]); + + return { + syncBounds, + scheduleSyncBounds, + }; +} diff --git a/src/lib/apps/environmentTheme.ts b/src/lib/apps/environmentTheme.ts new file mode 100644 index 000000000..7ec5e3bc2 --- /dev/null +++ b/src/lib/apps/environmentTheme.ts @@ -0,0 +1,60 @@ +import { commands } from '@/bindings'; +import type { Theme } from 'theme-o-rama'; + +const SAGE_THEME_VAR_NAMES = [ + '--background', + '--foreground', + '--card', + '--card-foreground', + '--popover', + '--popover-foreground', + '--primary', + '--primary-foreground', + '--secondary', + '--secondary-foreground', + '--muted', + '--muted-foreground', + '--accent', + '--accent-foreground', + '--destructive', + '--destructive-foreground', + '--border', + '--input', + '--ring', + '--radius', +]; + +function normalizeCssVarValue(value: string): string { + const trimmed = value.trim(); + + // unwrap hsl(...) → inner value (for Tailwind hsl(var(--...))) + const match = trimmed.match(/^hsl\((.*)\)$/i); + if (match) { + return match[1].trim(); + } + + return trimmed; +} + +function collectResolvedThemeCssVars(): Record { + const style = getComputedStyle(document.documentElement); + + return Object.fromEntries( + SAGE_THEME_VAR_NAMES.map((name) => { + const raw = style.getPropertyValue(name); + return [name, normalizeCssVarValue(raw)]; + }), + ); +} + +export async function publishEnvironmentThemeToRust( + currentTheme: Theme, +): Promise { + await commands.appsSetEnvironmentTheme({ + name: currentTheme.name, + displayName: currentTheme.displayName, + mostLike: currentTheme.mostLike ?? null, + inherits: currentTheme.inherits ?? null, + cssVars: collectResolvedThemeCssVars(), + }); +} diff --git a/src/lib/apps/formatAppError.ts b/src/lib/apps/formatAppError.ts new file mode 100644 index 000000000..b7aa75abc --- /dev/null +++ b/src/lib/apps/formatAppError.ts @@ -0,0 +1,36 @@ +export function formatAppError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + + if (typeof err === 'string') { + return err; + } + + if (err && typeof err === 'object') { + const maybe = err as Record; + + if (typeof maybe.message === 'string') { + return maybe.message; + } + + if (typeof maybe.error === 'string') { + return maybe.error; + } + + if (maybe.error && typeof maybe.error === 'object') { + const nested = maybe.error as Record; + if (typeof nested.message === 'string') { + return nested.message; + } + } + + try { + return JSON.stringify(err, null, 2); + } catch { + return 'Unknown error'; + } + } + + return String(err); +} diff --git a/src/lib/apps/openAppUpdate.ts b/src/lib/apps/openAppUpdate.ts new file mode 100644 index 000000000..f481f183c --- /dev/null +++ b/src/lib/apps/openAppUpdate.ts @@ -0,0 +1,9 @@ +import { commands } from '@/bindings'; + +export async function openAppPermissionsReview(appId: string) { + return await commands.appsStartSystemApp({ + kind: 'appUpdate', + mode: 'reviewPermissions', + appId, + }); +} diff --git a/src/lib/apps/sandbox.ts b/src/lib/apps/sandbox.ts new file mode 100644 index 000000000..dde86cc54 --- /dev/null +++ b/src/lib/apps/sandbox.ts @@ -0,0 +1,56 @@ +import type { + SandboxCapabilityResult, + SandboxState, + SandboxStateView, +} from '@/bindings'; + +export type SandboxCapability = + | 'storage_isolation_from_sage' + | 'storage_persistence_normal' + | 'storage_non_persistence_incognito' + | 'network_allowlist_enforced'; + +export function formatCapabilityLabel(capability: SandboxCapability): string { + switch (capability) { + case 'storage_isolation_from_sage': + return 'storage isolation from Sage'; + case 'storage_persistence_normal': + return 'persistent storage behavior'; + case 'storage_non_persistence_incognito': + return 'incognito storage behavior'; + case 'network_allowlist_enforced': + return 'network allowlist enforcement'; + } +} + +export function listSandboxCapabilities( + sandbox: SandboxState, +): [SandboxCapability, SandboxCapabilityResult][] { + return [ + ['storage_isolation_from_sage', sandbox.storageIsolationFromSage], + ['storage_persistence_normal', sandbox.storagePersistenceNormal], + [ + 'storage_non_persistence_incognito', + sandbox.storageNonPersistenceIncognito, + ], + ['network_allowlist_enforced', sandbox.networkAllowlistEnforced], + ]; +} + +export function getLiveSandboxState( + sandboxView: SandboxStateView | null | undefined, +): SandboxState | null { + return sandboxView?.currentRun?.state ?? null; +} + +export function getEffectiveSandboxState( + sandboxView: SandboxStateView | null | undefined, +): SandboxState | null { + return sandboxView?.effective ?? null; +} + +export function getBaselineSandboxState( + sandboxView: SandboxStateView | null | undefined, +): SandboxState | null { + return sandboxView?.baseline ?? null; +} diff --git a/src/lib/apps/sandboxPolicy.ts b/src/lib/apps/sandboxPolicy.ts new file mode 100644 index 000000000..d18026c81 --- /dev/null +++ b/src/lib/apps/sandboxPolicy.ts @@ -0,0 +1,57 @@ +import type { AppLaunchGateResult } from '@/bindings'; +import { formatCapabilityLabel } from '@/lib/apps/sandbox'; + +export interface SandboxLaunchDecision { + allowed: boolean; + title: string; + description: string; +} + +export function formatSandboxLaunchDecision( + gate: AppLaunchGateResult | null | undefined, +): SandboxLaunchDecision { + if (!gate) { + return { + allowed: false, + title: 'Sandbox tests are still running', + description: + 'Apps are allowed to launch only when all required sandbox capabilities have passed.', + }; + } + + if (gate.allowed) { + return { + allowed: true, + title: 'Sandbox checks passed', + description: 'This app is allowed to launch.', + }; + } + + if (gate.kind === 'sandboxPending') { + return { + allowed: false, + title: 'Sandbox tests are still running', + description: + gate.message ?? + (gate.capability + ? `Sandbox tests are still running for ${formatCapabilityLabel(gate.capability)}.` + : 'Apps are allowed to launch only when all required sandbox capabilities have passed.'), + }; + } + + const capabilityLabel = gate.capability + ? formatCapabilityLabel(gate.capability) + : null; + + return { + allowed: false, + title: capabilityLabel + ? `${capabilityLabel} failed` + : 'Sandbox test failed', + description: + gate.message ?? + (capabilityLabel + ? `This app cannot be launched because ${capabilityLabel} failed.` + : 'This app cannot be launched because a required sandbox capability failed.'), + }; +} diff --git a/src/locales/de-DE/messages.po b/src/locales/de-DE/messages.po index 7a5786268..af69fe189 100644 --- a/src/locales/de-DE/messages.po +++ b/src/locales/de-DE/messages.po @@ -118,7 +118,7 @@ msgstr "" #~ msgid "{peerCount, plural, one {# peer} other {# peers}}" #~ msgstr "" -#: src/components/Nav.tsx:162 +#: src/components/Nav.tsx:172 msgid "{peerCount} peers synced" msgstr "" @@ -331,7 +331,7 @@ msgstr "" msgid "Address to check" msgstr "" -#: src/components/Nav.tsx:102 +#: src/components/Nav.tsx:104 msgid "Addresses" msgstr "Adressen" @@ -388,6 +388,10 @@ msgstr "" msgid "Approving" msgstr "" +#: src/components/Nav.tsx:119 +msgid "Apps" +msgstr "" + #: src/components/ThemeCard.tsx:204 msgid "Are you sure you want to delete the theme \"{0}\"?" msgstr "" @@ -500,8 +504,7 @@ msgstr "" msgid "Automatically start the RPC server when the app launches" msgstr "" -#: src/components/Header.tsx:59 -#: src/components/Header.tsx:141 +#: src/components/Header.tsx:50 msgid "Back" msgstr "Zurück" @@ -1006,11 +1009,11 @@ msgstr "" msgid "Connected to {totalPeersCount} peers" msgstr "Verbunden mit {totalPeersCount} Peers" -#: src/components/Nav.tsx:184 +#: src/components/Nav.tsx:194 msgid "connecting..." msgstr "verbinden..." -#: src/components/Nav.tsx:164 +#: src/components/Nav.tsx:174 #: src/pages/Settings.tsx:530 msgid "Connecting..." msgstr "" @@ -1444,7 +1447,7 @@ msgstr "Display Name" msgid "Done" msgstr "Fertig" -#: src/components/Nav.tsx:168 +#: src/components/Nav.tsx:178 msgid "Downloading {checkedFiles} / {totalFiles}" msgstr "" @@ -2282,13 +2285,13 @@ msgstr "" msgid "Login" msgstr "Anmelden" -#: src/components/Nav.tsx:222 -#: src/components/Nav.tsx:228 +#: src/components/Nav.tsx:232 #: src/components/Nav.tsx:238 +#: src/components/Nav.tsx:248 msgid "Logout" msgstr "Abmelden" -#: src/components/Nav.tsx:42 +#: src/components/Nav.tsx:44 msgid "Main navigation" msgstr "" @@ -2459,7 +2462,7 @@ msgstr "" #~ msgid "Navigate to: {content}" #~ msgstr "" -#: src/components/Header.tsx:82 +#: src/components/MobileNavSheet.tsx:42 msgid "Navigation menu" msgstr "" @@ -2580,7 +2583,7 @@ msgid "NFT view options" msgstr "" #: src/components/MultiSelectActions.tsx:271 -#: src/components/Nav.tsx:58 +#: src/components/Nav.tsx:60 #: src/components/NftGroupCard.tsx:466 #: src/components/NftPageTitle.tsx:44 msgid "NFTs" @@ -2803,7 +2806,7 @@ msgstr "" msgid "Offering" msgstr "" -#: src/components/Nav.tsx:84 +#: src/components/Nav.tsx:86 #: src/pages/Offers.tsx:214 msgid "Offers" msgstr "Offers" @@ -2925,7 +2928,7 @@ msgstr "" msgid "options" msgstr "" -#: src/components/Nav.tsx:75 +#: src/components/Nav.tsx:77 msgid "Options" msgstr "" @@ -3043,7 +3046,7 @@ msgstr "" #~ msgid "Peak Height" #~ msgstr "Höchststand" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peer" msgstr "" @@ -3051,7 +3054,7 @@ msgstr "" msgid "Peer List" msgstr "Peer Liste" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peers" msgstr "" @@ -3198,7 +3201,7 @@ msgstr "Profilname" #~ msgid "Profile NFTs" #~ msgstr "" -#: src/components/Nav.tsx:66 +#: src/components/Nav.tsx:68 #: src/pages/DidList.tsx:66 msgid "Profiles" msgstr "Profile" @@ -3513,7 +3516,7 @@ msgstr "" msgid "Secondary" msgstr "" -#: src/components/Nav.tsx:153 +#: src/components/Nav.tsx:163 msgid "Secondary navigation" msgstr "" @@ -3679,7 +3682,7 @@ msgstr "" msgid "Set a specific address to send change to" msgstr "" -#: src/components/Nav.tsx:210 +#: src/components/Nav.tsx:220 #: src/pages/Settings.tsx:108 msgid "Settings" msgstr "Einstellungen" @@ -3957,7 +3960,7 @@ msgstr "Zusammenfassung" msgid "Support for Clawback v2 is limited at this time. Please make sure the recipient supports it before submitting this transaction." msgstr "" -#: src/components/Nav.tsx:93 +#: src/components/Nav.tsx:95 #: src/pages/Swap.tsx:335 msgid "Swap" msgstr "" @@ -4011,7 +4014,7 @@ msgstr "" msgid "Sync Status" msgstr "" -#: src/components/Nav.tsx:182 +#: src/components/Nav.tsx:192 msgid "synced to peak {peerMaxHeight}" msgstr "" @@ -4019,7 +4022,7 @@ msgstr "" msgid "Syncing" msgstr "" -#: src/components/Nav.tsx:172 +#: src/components/Nav.tsx:182 msgid "Syncing {syncedCoins} / {totalCoins}" msgstr "{syncedCoins} / {totalCoins} synchronisieren" @@ -4440,7 +4443,7 @@ msgstr "" msgid "Toggle multi-select" msgstr "Mehrfachauswahl umschalten" -#: src/components/Header.tsx:69 +#: src/components/MobileNavSheet.tsx:27 msgid "Toggle navigation menu" msgstr "Navigationsmenü umschalten" @@ -4591,7 +4594,7 @@ msgstr "" #~ msgid "Transaction Time" #~ msgstr "" -#: src/components/Nav.tsx:110 +#: src/components/Nav.tsx:111 #: src/pages/Transactions.tsx:149 msgid "Transactions" msgstr "Transaktionen" @@ -4995,7 +4998,7 @@ msgstr "" msgid "WAL Pages Checkpointed" msgstr "" -#: src/components/Nav.tsx:49 +#: src/components/Nav.tsx:51 #: src/components/WalletSwitcher.tsx:217 #: src/components/WalletSwitcher.tsx:261 #: src/components/WalletSwitcher.tsx:331 @@ -5067,7 +5070,7 @@ msgstr "" msgid "While 12 word mnemonics are sufficiently hard to crack, you can choose to use 24 instead to increase security." msgstr "Obwohl 12-Wort-Mnemonics ausreichend schwer zu knacken sind, können Sie sich entscheiden, stattdessen 24 zu verwenden, um die Sicherheit zu erhöhen." -#: src/components/Nav.tsx:187 +#: src/components/Nav.tsx:197 msgid "with {syncedCoins} / {totalCoins} coins synced" msgstr "" diff --git a/src/locales/en-US/messages.po b/src/locales/en-US/messages.po index 1cf565ea0..dfc749d75 100644 --- a/src/locales/en-US/messages.po +++ b/src/locales/en-US/messages.po @@ -118,7 +118,7 @@ msgstr "{pageSize} items per page" #~ msgid "{peerCount, plural, one {# peer} other {# peers}}" #~ msgstr "{peerCount, plural, one {# peer} other {# peers}}" -#: src/components/Nav.tsx:162 +#: src/components/Nav.tsx:172 msgid "{peerCount} peers synced" msgstr "{peerCount} peers synced" @@ -331,7 +331,7 @@ msgstr "Address not found in your wallet" msgid "Address to check" msgstr "Address to check" -#: src/components/Nav.tsx:102 +#: src/components/Nav.tsx:104 msgid "Addresses" msgstr "Addresses" @@ -388,6 +388,10 @@ msgstr "Approve" msgid "Approving" msgstr "Approving" +#: src/components/Nav.tsx:119 +msgid "Apps" +msgstr "Apps" + #: src/components/ThemeCard.tsx:204 msgid "Are you sure you want to delete the theme \"{0}\"?" msgstr "Are you sure you want to delete the theme \"{0}\"?" @@ -500,8 +504,7 @@ msgstr "Automatically discover and connect to peers" msgid "Automatically start the RPC server when the app launches" msgstr "Automatically start the RPC server when the app launches" -#: src/components/Header.tsx:59 -#: src/components/Header.tsx:141 +#: src/components/Header.tsx:50 msgid "Back" msgstr "Back" @@ -1006,11 +1009,11 @@ msgstr "Connect Wallet" msgid "Connected to {totalPeersCount} peers" msgstr "Connected to {totalPeersCount} peers" -#: src/components/Nav.tsx:184 +#: src/components/Nav.tsx:194 msgid "connecting..." msgstr "connecting..." -#: src/components/Nav.tsx:164 +#: src/components/Nav.tsx:174 #: src/pages/Settings.tsx:530 msgid "Connecting..." msgstr "Connecting..." @@ -1444,7 +1447,7 @@ msgstr "Display name" msgid "Done" msgstr "Done" -#: src/components/Nav.tsx:168 +#: src/components/Nav.tsx:178 msgid "Downloading {checkedFiles} / {totalFiles}" msgstr "Downloading {checkedFiles} / {totalFiles}" @@ -2282,13 +2285,13 @@ msgstr "Log Viewer" msgid "Login" msgstr "Login" -#: src/components/Nav.tsx:222 -#: src/components/Nav.tsx:228 +#: src/components/Nav.tsx:232 #: src/components/Nav.tsx:238 +#: src/components/Nav.tsx:248 msgid "Logout" msgstr "Logout" -#: src/components/Nav.tsx:42 +#: src/components/Nav.tsx:44 msgid "Main navigation" msgstr "Main navigation" @@ -2459,7 +2462,7 @@ msgstr "Navigate to: {0}" #~ msgid "Navigate to: {content}" #~ msgstr "Navigate to: {content}" -#: src/components/Header.tsx:82 +#: src/components/MobileNavSheet.tsx:42 msgid "Navigation menu" msgstr "Navigation menu" @@ -2580,7 +2583,7 @@ msgid "NFT view options" msgstr "NFT view options" #: src/components/MultiSelectActions.tsx:271 -#: src/components/Nav.tsx:58 +#: src/components/Nav.tsx:60 #: src/components/NftGroupCard.tsx:466 #: src/components/NftPageTitle.tsx:44 msgid "NFTs" @@ -2803,7 +2806,7 @@ msgstr "Offered in exchange:" msgid "Offering" msgstr "Offering" -#: src/components/Nav.tsx:84 +#: src/components/Nav.tsx:86 #: src/pages/Offers.tsx:214 msgid "Offers" msgstr "Offers" @@ -2925,7 +2928,7 @@ msgstr "Option Minting" msgid "options" msgstr "options" -#: src/components/Nav.tsx:75 +#: src/components/Nav.tsx:77 msgid "Options" msgstr "Options" @@ -3043,7 +3046,7 @@ msgstr "Paying" #~ msgid "Peak Height" #~ msgstr "Peak Height" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peer" msgstr "peer" @@ -3051,7 +3054,7 @@ msgstr "peer" msgid "Peer List" msgstr "Peer List" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peers" msgstr "peers" @@ -3198,7 +3201,7 @@ msgstr "Profile name" #~ msgid "Profile NFTs" #~ msgstr "Profile NFTs" -#: src/components/Nav.tsx:66 +#: src/components/Nav.tsx:68 #: src/pages/DidList.tsx:66 msgid "Profiles" msgstr "Profiles" @@ -3513,7 +3516,7 @@ msgstr "Search..." msgid "Secondary" msgstr "Secondary" -#: src/components/Nav.tsx:153 +#: src/components/Nav.tsx:163 msgid "Secondary navigation" msgstr "Secondary navigation" @@ -3679,7 +3682,7 @@ msgstr "Set a default expiration time for new offers" msgid "Set a specific address to send change to" msgstr "Set a specific address to send change to" -#: src/components/Nav.tsx:210 +#: src/components/Nav.tsx:220 #: src/pages/Settings.tsx:108 msgid "Settings" msgstr "Settings" @@ -3957,7 +3960,7 @@ msgstr "Summary" msgid "Support for Clawback v2 is limited at this time. Please make sure the recipient supports it before submitting this transaction." msgstr "Support for Clawback v2 is limited at this time. Please make sure the recipient supports it before submitting this transaction." -#: src/components/Nav.tsx:93 +#: src/components/Nav.tsx:95 #: src/pages/Swap.tsx:335 msgid "Swap" msgstr "Swap" @@ -4011,7 +4014,7 @@ msgstr "Symbol" msgid "Sync Status" msgstr "Sync Status" -#: src/components/Nav.tsx:182 +#: src/components/Nav.tsx:192 msgid "synced to peak {peerMaxHeight}" msgstr "synced to peak {peerMaxHeight}" @@ -4019,7 +4022,7 @@ msgstr "synced to peak {peerMaxHeight}" msgid "Syncing" msgstr "Syncing" -#: src/components/Nav.tsx:172 +#: src/components/Nav.tsx:182 msgid "Syncing {syncedCoins} / {totalCoins}" msgstr "Syncing {syncedCoins} / {totalCoins}" @@ -4440,7 +4443,7 @@ msgstr "To:" msgid "Toggle multi-select" msgstr "Toggle multi-select" -#: src/components/Header.tsx:69 +#: src/components/MobileNavSheet.tsx:27 msgid "Toggle navigation menu" msgstr "Toggle navigation menu" @@ -4591,7 +4594,7 @@ msgstr "Transaction submitted successfully" #~ msgid "Transaction Time" #~ msgstr "Transaction Time" -#: src/components/Nav.tsx:110 +#: src/components/Nav.tsx:111 #: src/pages/Transactions.tsx:149 msgid "Transactions" msgstr "Transactions" @@ -4995,7 +4998,7 @@ msgstr "WAL Pages" msgid "WAL Pages Checkpointed" msgstr "WAL Pages Checkpointed" -#: src/components/Nav.tsx:49 +#: src/components/Nav.tsx:51 #: src/components/WalletSwitcher.tsx:217 #: src/components/WalletSwitcher.tsx:261 #: src/components/WalletSwitcher.tsx:331 @@ -5067,7 +5070,7 @@ msgstr "Warning: This is a one-sided offer. You are not requesting anything in r msgid "While 12 word mnemonics are sufficiently hard to crack, you can choose to use 24 instead to increase security." msgstr "While 12 word mnemonics are sufficiently hard to crack, you can choose to use 24 instead to increase security." -#: src/components/Nav.tsx:187 +#: src/components/Nav.tsx:197 msgid "with {syncedCoins} / {totalCoins} coins synced" msgstr "with {syncedCoins} / {totalCoins} coins synced" diff --git a/src/locales/es-MX/messages.po b/src/locales/es-MX/messages.po index 507302e46..641b8f867 100644 --- a/src/locales/es-MX/messages.po +++ b/src/locales/es-MX/messages.po @@ -118,7 +118,7 @@ msgstr "{pageSize} elementos por página" #~ msgid "{peerCount, plural, one {# peer} other {# peers}}" #~ msgstr "{peerCount, plural, one {# nodo} other {# nodos}}" -#: src/components/Nav.tsx:162 +#: src/components/Nav.tsx:172 msgid "{peerCount} peers synced" msgstr "{peerCount} nodos sincronizados" @@ -331,7 +331,7 @@ msgstr "Dirección no encontrada en tu billetera" msgid "Address to check" msgstr "Dirección para verificar" -#: src/components/Nav.tsx:102 +#: src/components/Nav.tsx:104 msgid "Addresses" msgstr "Direcciones" @@ -388,6 +388,10 @@ msgstr "" msgid "Approving" msgstr "" +#: src/components/Nav.tsx:119 +msgid "Apps" +msgstr "" + #: src/components/ThemeCard.tsx:204 msgid "Are you sure you want to delete the theme \"{0}\"?" msgstr "" @@ -500,8 +504,7 @@ msgstr "Descubrir y conectarse automáticamente a nodos" msgid "Automatically start the RPC server when the app launches" msgstr "Iniciar automáticamente el servidor RPC cuando se inicia la aplicación" -#: src/components/Header.tsx:59 -#: src/components/Header.tsx:141 +#: src/components/Header.tsx:50 msgid "Back" msgstr "Atrás" @@ -1006,11 +1009,11 @@ msgstr "Conectar billetera" msgid "Connected to {totalPeersCount} peers" msgstr "Conectado a {totalPeersCount} nodos" -#: src/components/Nav.tsx:184 +#: src/components/Nav.tsx:194 msgid "connecting..." msgstr "conectando..." -#: src/components/Nav.tsx:164 +#: src/components/Nav.tsx:174 #: src/pages/Settings.tsx:530 msgid "Connecting..." msgstr "Conectando..." @@ -1444,7 +1447,7 @@ msgstr "Nombre para mostrar" msgid "Done" msgstr "Listo" -#: src/components/Nav.tsx:168 +#: src/components/Nav.tsx:178 msgid "Downloading {checkedFiles} / {totalFiles}" msgstr "" @@ -2282,13 +2285,13 @@ msgstr "" msgid "Login" msgstr "Iniciar sesión" -#: src/components/Nav.tsx:222 -#: src/components/Nav.tsx:228 +#: src/components/Nav.tsx:232 #: src/components/Nav.tsx:238 +#: src/components/Nav.tsx:248 msgid "Logout" msgstr "Cerrar sesión" -#: src/components/Nav.tsx:42 +#: src/components/Nav.tsx:44 msgid "Main navigation" msgstr "Navegación principal" @@ -2459,7 +2462,7 @@ msgstr "" #~ msgid "Navigate to: {content}" #~ msgstr "" -#: src/components/Header.tsx:82 +#: src/components/MobileNavSheet.tsx:42 msgid "Navigation menu" msgstr "Menú de navegación" @@ -2580,7 +2583,7 @@ msgid "NFT view options" msgstr "Opciones de vista de NFT" #: src/components/MultiSelectActions.tsx:271 -#: src/components/Nav.tsx:58 +#: src/components/Nav.tsx:60 #: src/components/NftGroupCard.tsx:466 #: src/components/NftPageTitle.tsx:44 msgid "NFTs" @@ -2803,7 +2806,7 @@ msgstr "" msgid "Offering" msgstr "" -#: src/components/Nav.tsx:84 +#: src/components/Nav.tsx:86 #: src/pages/Offers.tsx:214 msgid "Offers" msgstr "Ofertas" @@ -2925,7 +2928,7 @@ msgstr "" msgid "options" msgstr "" -#: src/components/Nav.tsx:75 +#: src/components/Nav.tsx:77 msgid "Options" msgstr "Opcioens" @@ -3043,7 +3046,7 @@ msgstr "" #~ msgid "Peak Height" #~ msgstr "Altura Máxima" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peer" msgstr "nodo" @@ -3051,7 +3054,7 @@ msgstr "nodo" msgid "Peer List" msgstr "Lista de nodos" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peers" msgstr "nodos" @@ -3198,7 +3201,7 @@ msgstr "Nombre de perfil" #~ msgid "Profile NFTs" #~ msgstr "Perfil de NFTs" -#: src/components/Nav.tsx:66 +#: src/components/Nav.tsx:68 #: src/pages/DidList.tsx:66 msgid "Profiles" msgstr "Perfiles" @@ -3513,7 +3516,7 @@ msgstr "" msgid "Secondary" msgstr "" -#: src/components/Nav.tsx:153 +#: src/components/Nav.tsx:163 msgid "Secondary navigation" msgstr "Navegación secundaria" @@ -3679,7 +3682,7 @@ msgstr "Establecer un tiempo de expiración predeterminado para las nuevas ofert msgid "Set a specific address to send change to" msgstr "" -#: src/components/Nav.tsx:210 +#: src/components/Nav.tsx:220 #: src/pages/Settings.tsx:108 msgid "Settings" msgstr "Configuración" @@ -3957,7 +3960,7 @@ msgstr "Resumen" msgid "Support for Clawback v2 is limited at this time. Please make sure the recipient supports it before submitting this transaction." msgstr "" -#: src/components/Nav.tsx:93 +#: src/components/Nav.tsx:95 #: src/pages/Swap.tsx:335 msgid "Swap" msgstr "" @@ -4011,7 +4014,7 @@ msgstr "Símbolo" msgid "Sync Status" msgstr "" -#: src/components/Nav.tsx:182 +#: src/components/Nav.tsx:192 msgid "synced to peak {peerMaxHeight}" msgstr "sincronizado con el pico {peerMaxHeight}" @@ -4019,7 +4022,7 @@ msgstr "sincronizado con el pico {peerMaxHeight}" msgid "Syncing" msgstr "Sincronizando" -#: src/components/Nav.tsx:172 +#: src/components/Nav.tsx:182 msgid "Syncing {syncedCoins} / {totalCoins}" msgstr "Sincronizando {syncedCoins} / {totalCoins}" @@ -4440,7 +4443,7 @@ msgstr "Para:" msgid "Toggle multi-select" msgstr "Alternar selección múltiple" -#: src/components/Header.tsx:69 +#: src/components/MobileNavSheet.tsx:27 msgid "Toggle navigation menu" msgstr "Alternar menú de navegación" @@ -4591,7 +4594,7 @@ msgstr "Transacción enviada con éxito" #~ msgid "Transaction Time" #~ msgstr "Tiempo de transacción" -#: src/components/Nav.tsx:110 +#: src/components/Nav.tsx:111 #: src/pages/Transactions.tsx:149 msgid "Transactions" msgstr "Transacciones" @@ -4995,7 +4998,7 @@ msgstr "" msgid "WAL Pages Checkpointed" msgstr "" -#: src/components/Nav.tsx:49 +#: src/components/Nav.tsx:51 #: src/components/WalletSwitcher.tsx:217 #: src/components/WalletSwitcher.tsx:261 #: src/components/WalletSwitcher.tsx:331 @@ -5067,7 +5070,7 @@ msgstr "" msgid "While 12 word mnemonics are sufficiently hard to crack, you can choose to use 24 instead to increase security." msgstr "Si bien los mnemotécnicos de 12 palabras son lo suficientemente difíciles de crackear, puedes optar por usar 24 para aumentar la seguridad." -#: src/components/Nav.tsx:187 +#: src/components/Nav.tsx:197 msgid "with {syncedCoins} / {totalCoins} coins synced" msgstr "con {syncedCoins} / {totalCoins} monedas sincronizadas" diff --git a/src/locales/zh-CN/messages.po b/src/locales/zh-CN/messages.po index db0d63744..491f5203f 100644 --- a/src/locales/zh-CN/messages.po +++ b/src/locales/zh-CN/messages.po @@ -118,7 +118,7 @@ msgstr "" #~ msgid "{peerCount, plural, one {# peer} other {# peers}}" #~ msgstr "{peerCount, plural, one {# 节点} other {# 节点}}" -#: src/components/Nav.tsx:162 +#: src/components/Nav.tsx:172 msgid "{peerCount} peers synced" msgstr "" @@ -331,7 +331,7 @@ msgstr "" msgid "Address to check" msgstr "" -#: src/components/Nav.tsx:102 +#: src/components/Nav.tsx:104 msgid "Addresses" msgstr "地址" @@ -388,6 +388,10 @@ msgstr "" msgid "Approving" msgstr "" +#: src/components/Nav.tsx:119 +msgid "Apps" +msgstr "" + #: src/components/ThemeCard.tsx:204 msgid "Are you sure you want to delete the theme \"{0}\"?" msgstr "" @@ -500,8 +504,7 @@ msgstr "" msgid "Automatically start the RPC server when the app launches" msgstr "" -#: src/components/Header.tsx:59 -#: src/components/Header.tsx:141 +#: src/components/Header.tsx:50 msgid "Back" msgstr "返回" @@ -1006,11 +1009,11 @@ msgstr "" msgid "Connected to {totalPeersCount} peers" msgstr "已连接到 {totalPeersCount} 个节点" -#: src/components/Nav.tsx:184 +#: src/components/Nav.tsx:194 msgid "connecting..." msgstr "连接中..." -#: src/components/Nav.tsx:164 +#: src/components/Nav.tsx:174 #: src/pages/Settings.tsx:530 msgid "Connecting..." msgstr "" @@ -1444,7 +1447,7 @@ msgstr "显示名称" msgid "Done" msgstr "确定" -#: src/components/Nav.tsx:168 +#: src/components/Nav.tsx:178 msgid "Downloading {checkedFiles} / {totalFiles}" msgstr "" @@ -2282,13 +2285,13 @@ msgstr "" msgid "Login" msgstr "登录" -#: src/components/Nav.tsx:222 -#: src/components/Nav.tsx:228 +#: src/components/Nav.tsx:232 #: src/components/Nav.tsx:238 +#: src/components/Nav.tsx:248 msgid "Logout" msgstr "退出" -#: src/components/Nav.tsx:42 +#: src/components/Nav.tsx:44 msgid "Main navigation" msgstr "" @@ -2459,7 +2462,7 @@ msgstr "" #~ msgid "Navigate to: {content}" #~ msgstr "" -#: src/components/Header.tsx:82 +#: src/components/MobileNavSheet.tsx:42 msgid "Navigation menu" msgstr "" @@ -2580,7 +2583,7 @@ msgid "NFT view options" msgstr "" #: src/components/MultiSelectActions.tsx:271 -#: src/components/Nav.tsx:58 +#: src/components/Nav.tsx:60 #: src/components/NftGroupCard.tsx:466 #: src/components/NftPageTitle.tsx:44 msgid "NFTs" @@ -2803,7 +2806,7 @@ msgstr "" msgid "Offering" msgstr "" -#: src/components/Nav.tsx:84 +#: src/components/Nav.tsx:86 #: src/pages/Offers.tsx:214 msgid "Offers" msgstr "报价" @@ -2925,7 +2928,7 @@ msgstr "" msgid "options" msgstr "" -#: src/components/Nav.tsx:75 +#: src/components/Nav.tsx:77 msgid "Options" msgstr "" @@ -3043,7 +3046,7 @@ msgstr "" #~ msgid "Peak Height" #~ msgstr "最高高度" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peer" msgstr "" @@ -3051,7 +3054,7 @@ msgstr "" msgid "Peer List" msgstr "节点列表" -#: src/components/Nav.tsx:179 +#: src/components/Nav.tsx:189 msgid "peers" msgstr "" @@ -3198,7 +3201,7 @@ msgstr "个人资料名称" #~ msgid "Profile NFTs" #~ msgstr "" -#: src/components/Nav.tsx:66 +#: src/components/Nav.tsx:68 #: src/pages/DidList.tsx:66 msgid "Profiles" msgstr "个人资料" @@ -3513,7 +3516,7 @@ msgstr "" msgid "Secondary" msgstr "" -#: src/components/Nav.tsx:153 +#: src/components/Nav.tsx:163 msgid "Secondary navigation" msgstr "" @@ -3679,7 +3682,7 @@ msgstr "" msgid "Set a specific address to send change to" msgstr "" -#: src/components/Nav.tsx:210 +#: src/components/Nav.tsx:220 #: src/pages/Settings.tsx:108 msgid "Settings" msgstr "设置" @@ -3957,7 +3960,7 @@ msgstr "摘要" msgid "Support for Clawback v2 is limited at this time. Please make sure the recipient supports it before submitting this transaction." msgstr "" -#: src/components/Nav.tsx:93 +#: src/components/Nav.tsx:95 #: src/pages/Swap.tsx:335 msgid "Swap" msgstr "" @@ -4011,7 +4014,7 @@ msgstr "" msgid "Sync Status" msgstr "" -#: src/components/Nav.tsx:182 +#: src/components/Nav.tsx:192 msgid "synced to peak {peerMaxHeight}" msgstr "" @@ -4019,7 +4022,7 @@ msgstr "" msgid "Syncing" msgstr "" -#: src/components/Nav.tsx:172 +#: src/components/Nav.tsx:182 msgid "Syncing {syncedCoins} / {totalCoins}" msgstr "正在同步 {syncedCoins} / {totalCoins}" @@ -4439,7 +4442,7 @@ msgstr "" msgid "Toggle multi-select" msgstr "切换多重选择" -#: src/components/Header.tsx:69 +#: src/components/MobileNavSheet.tsx:27 msgid "Toggle navigation menu" msgstr "切换导航菜单" @@ -4590,7 +4593,7 @@ msgstr "" #~ msgid "Transaction Time" #~ msgstr "" -#: src/components/Nav.tsx:110 +#: src/components/Nav.tsx:111 #: src/pages/Transactions.tsx:149 msgid "Transactions" msgstr "交易" @@ -4993,7 +4996,7 @@ msgstr "" msgid "WAL Pages Checkpointed" msgstr "" -#: src/components/Nav.tsx:49 +#: src/components/Nav.tsx:51 #: src/components/WalletSwitcher.tsx:217 #: src/components/WalletSwitcher.tsx:261 #: src/components/WalletSwitcher.tsx:331 @@ -5065,7 +5068,7 @@ msgstr "" msgid "While 12 word mnemonics are sufficiently hard to crack, you can choose to use 24 instead to increase security." msgstr "虽然12个助记词单词已经足够安全, 仍然可以选择使用24个单词来进一步增强安全性." -#: src/components/Nav.tsx:187 +#: src/components/Nav.tsx:197 msgid "with {syncedCoins} / {totalCoins} coins synced" msgstr "" diff --git a/src/pages/Apps.tsx b/src/pages/Apps.tsx new file mode 100644 index 000000000..908d0349c --- /dev/null +++ b/src/pages/Apps.tsx @@ -0,0 +1,267 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; + +import { commands, type UserSageAppView } from '@/bindings'; +import { useApps } from '@/contexts/AppsContext.tsx'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; + +import { + AppTaskBar, + type AppTaskBarTab, +} from '@/components/apps/AppTaskBar.tsx'; + +import { AppHost } from '@/components/apps/AppHost'; +import { AppsLaunchpad } from '@/components/apps/AppsLaunchpad'; +import { SystemAppModalLayer } from '@/components/apps/SystemAppModalLayer'; + +export function Apps() { + const [workspaceActive, setWorkspaceActive] = useState(false); + + useEffect(() => { + let cancelled = false; + + void commands + .appsEnterWorkspace() + .then(() => { + if (!cancelled) { + setWorkspaceActive(true); + } + }) + .catch((err) => { + console.error('Failed to activate apps workspace:', err); + }); + + return () => { + cancelled = true; + + setWorkspaceActive(false); + + void commands.appsLeaveWorkspace().catch((err) => { + console.error('Failed to deactivate apps workspace:', err); + }); + }; + }, []); + + const { + runtimes, + getApp, + getListedApp, + pendingUpdates, + busyAppIds, + activeTaskbarRuntime, + } = useApps(); + + const runtimesRef = useRef(runtimes); + + useEffect(() => { + runtimesRef.current = runtimes; + }, [runtimes]); + + useEffect(() => { + if (!import.meta.env.DEV) { + return; + } + + let cleanup: (() => void) | null = null; + + void import('@/dev/system-apps/setupDevSystemAppsReload').then( + ({ setupDevSystemAppsReload }) => { + cleanup = setupDevSystemAppsReload(() => runtimesRef.current); + }, + ); + + return () => { + cleanup?.(); + }; + }, []); + + const [tabOrder, setTabOrder] = useState([]); + + useEffect(() => { + setTabOrder((prev) => { + const runtimeIds = runtimes + .filter((runtime) => { + const installedApp = getListedApp(runtime.app.common.identity.id); + + if (!installedApp) { + return false; + } + + if (installedApp.kind === 'user') { + return true; + } + + return runtime.presentation.kind === 'Taskbar'; + }) + .map((runtime) => runtime.app.common.identity.id); + + const kept = prev.filter((runtimeAppId) => + runtimeIds.includes(runtimeAppId), + ); + + const added = runtimeIds.filter( + (runtimeAppId) => !kept.includes(runtimeAppId), + ); + + return [...kept, ...added]; + }); + }, [runtimes, getListedApp]); + + const activeRuntimeAppId = activeTaskbarRuntime?.appId ?? null; + + const activeRuntimeExists = activeRuntimeAppId + ? runtimes.some( + (runtime) => runtime.app.common.identity.id === activeRuntimeAppId, + ) + : false; + + const activeApp: UserSageAppView | null = + activeRuntimeAppId && activeRuntimeExists + ? (getApp(activeRuntimeAppId) ?? null) + : null; + + const activeAppId = activeApp?.common.identity.id ?? null; + + const activePendingUpdate = activeAppId + ? (pendingUpdates[activeAppId] ?? { kind: 'none' as const }) + : { kind: 'none' as const }; + + const activeBusy = activeAppId ? (busyAppIds[activeAppId] ?? false) : false; + + const activeManifest = activeApp?.common.activeSnapshot.manifest; + + const hasDonation = !!activeManifest?.donation?.address; + + const tabs = useMemo(() => { + const runtimeByAppId = new Map( + runtimes.map( + (runtime) => [runtime.app.common.identity.id, runtime] as const, + ), + ); + + const out: AppTaskBarTab[] = []; + + for (const runtimeAppId of tabOrder) { + const runtime = runtimeByAppId.get(runtimeAppId); + + if (!runtime) { + continue; + } + + const installedApp = getListedApp(runtime.app.common.identity.id); + + if (!installedApp) { + continue; + } + + if ( + installedApp.kind === 'system' && + runtime.presentation.kind !== 'Taskbar' + ) { + continue; + } + + out.push({ + app: installedApp, + isActive: + runtime.app.common.identity.id === activeTaskbarRuntime?.appId, + }); + } + + return out; + }, [runtimes, tabOrder, getListedApp, activeTaskbarRuntime?.appId]); + + async function handleApplyActiveUpdate() { + if (!activeAppId) { + return; + } + + try { + await commands.appsApplyAppUpdate(activeAppId); + } catch (err) { + console.error('Failed to apply app update:', err); + } + } + + if (!workspaceActive) { + return null; + } + + return ( +
+ { + void commands.appsClearActiveTaskbarRuntime({ + windowLabel: getCurrentWindow().label, + }); + }} + onSelectApp={(tab) => { + void commands.appsFocusTaskbarRuntime({ + appId: tab.app.common.identity.id, + }); + }} + onCloseApp={(tab) => { + void commands.appsKillTaskbarRuntime({ + appId: tab.app.common.identity.id, + }); + }} + onReorderTabs={setTabOrder} + activeAppHasDonation={hasDonation} + onOpenDonation={() => { + if (!activeApp) { + return; + } + + void commands.appsStartSystemApp({ + kind: 'donation', + appId: activeApp.common.identity.id, + }); + }} + /> + + {activeApp?.source.kind === 'url' && + activePendingUpdate.kind !== 'none' ? ( + + + {activePendingUpdate.kind === 'requiresReview' + ? 'Update needs review' + : 'Update ready'} + + + + + {activePendingUpdate.kind === 'requiresReview' + ? `An update is available for ${activeApp.common.activeSnapshot.manifest.name} and needs review before it can be applied.` + : `An update is ready to apply for ${activeApp.common.activeSnapshot.manifest.name}.`} + + + + + + ) : null} + +
+ {activeTaskbarRuntime?.appId ? : } + + +
+
+ ); +}