diff --git a/.cursor/rules/imports.mdc b/.cursor/rules/imports.mdc new file mode 100644 index 000000000..9ad83322f --- /dev/null +++ b/.cursor/rules/imports.mdc @@ -0,0 +1,38 @@ +--- +description: Rust module and import practices +globs: **/*.rs +alwaysApply: false +--- + +# Rust Modules And Imports + +For Rust files: + +- `crates/sage-apps/src/lib.rs` is exempt from this rule. +- Group imports as `std`, external crates, then `crate`, with blank lines between groups. +- Put `mod` groups first, then `pub use`, then less-visible `use` groups. +- Within each `mod`/`use` group, don't add blank lines; only separate groups. +- After `mod`, group statements of the same kind by visibility, most visible first (`pub`, `pub(crate)`, private). +- Prefer `use` imports over repeated long paths in signatures/bodies. +- Import crate-local items directly from `crate` (`use crate::{A, b};`), not `crate::submodule`; use at most one crate brace group per scope/module. +- Keep conventional qualifiers when clearer, e.g. `use std::fmt; impl fmt::Display ...`. +- Put visibility on the item definition, not the re-export, where possible. +- Re-export child modules with `pub use child::*;` where possible; only use more restrictive re-export visibility if linting shows none of the child items are public. +- Re-export intended child items from parent modules instead of forcing callers through long internal paths. +- Avoid unrelated import/re-export churn. + +```rust +mod registry; + +pub use registry::*; + +use std::fmt; + +use crate::SageApp; + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // ... + } +} +``` diff --git a/.gitignore b/.gitignore index 13ee7f29c..345d1e575 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ target .env /test.sqlite* +/builtin-apps/build/ diff --git a/Cargo.lock b/Cargo.lock index badb16b27..8a69c3fad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,27 +239,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "ashpd" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "tokio", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "asn1-rs" version = "0.6.2" @@ -914,6 +893,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -1743,6 +1731,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.4.0" @@ -2045,6 +2039,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "der" version = "0.7.10" @@ -2226,15 +2226,6 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8975ffdaa0ef3661bfe02dbdcc06c9f829dfafe6a3c474de366a8d5e44276921" -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading 0.8.9", -] - [[package]] name = "dlopen2" version = "0.8.1" @@ -2622,6 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", + "libz-rs-sys", "libz-sys", "miniz_oxide", ] @@ -2717,11 +2709,26 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2729,15 +2736,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2757,9 +2764,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2776,9 +2783,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -2787,15 +2794,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -2805,10 +2812,11 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2816,7 +2824,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2982,11 +2989,26 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -3888,6 +3910,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -3918,6 +3946,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.178" @@ -3992,6 +4026,15 @@ dependencies = [ "glob", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "libz-sys" version = "1.1.23" @@ -4059,6 +4102,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +dependencies = [ + "sha2", +] + [[package]] name = "mac" version = "0.1.1" @@ -4162,6 +4214,16 @@ dependencies = [ "url", ] +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4361,9 +4423,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -4919,6 +4981,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -5219,6 +5291,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -5475,6 +5553,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -5855,11 +5939,10 @@ dependencies = [ [[package]] name = "rfd" -version = "0.15.4" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "ashpd", "block2 0.6.2", "dispatch2", "glib-sys", @@ -5875,7 +5958,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6261,10 +6344,43 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "sage-apps" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "erased-serde", + "futures", + "hex", + "mime_guess", + "parking_lot", + "reqwest 0.12.24", + "sage", + "sage-api", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "specta", + "specta-typescript", + "sqlx", + "tauri", + "tauri-plugin-dialog", + "tauri-specta", + "tempfile", + "tokio", + "tracing", + "url", + "uuid", + "zip", +] + [[package]] name = "sage-assets" version = "0.12.10" dependencies = [ + "anyhow", "base64 0.22.1", "chia-wallet-sdk", "futures-lite", @@ -6388,19 +6504,25 @@ name = "sage-tauri" version = "0.12.10" dependencies = [ "anyhow", + "async-trait", "aws-lc-rs", "chia-wallet-sdk", + "futures", "glob", + "hex", + "mime_guess", "reqwest 0.12.24", "rustls", "sage", "sage-api", "sage-api-macro", + "sage-apps", "sage-config", "sage-rpc", "sage-wallet", "serde", "serde_json", + "sha2", "specta", "specta-typescript", "tauri", @@ -6417,8 +6539,12 @@ dependencies = [ "tauri-plugin-sharesheet", "tauri-plugin-window-state", "tauri-specta", + "tempfile", "tokio", "tracing", + "url", + "uuid", + "zip", ] [[package]] @@ -6508,12 +6634,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -6670,9 +6790,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -6929,6 +7049,7 @@ dependencies = [ "paste", "specta-macros", "thiserror 1.0.69", + "url", ] [[package]] @@ -7553,9 +7674,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.4.2" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" dependencies = [ "log", "raw-window-handle", @@ -7571,13 +7692,15 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.4.4" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", "glob", + "log", + "objc2-foundation 0.3.2", "percent-encoding", "schemars 0.8.22", "serde", @@ -7587,7 +7710,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.17", - "toml 0.9.8", + "toml 1.1.0+spec-1.1.0", "url", ] @@ -7819,7 +7942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", @@ -7949,30 +8072,31 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -8017,7 +8141,6 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -8103,13 +8226,28 @@ checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ "indexmap 2.12.1", "serde_core", - "serde_spanned 1.0.3", + "serde_spanned 1.1.1", "toml_datetime 0.7.3", "toml_parser", "toml_writer", "winnow 0.7.14", ] +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +dependencies = [ + "indexmap 2.12.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -8128,6 +8266,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -8178,11 +8325,11 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 0.7.14", + "winnow 1.0.2", ] [[package]] @@ -8193,9 +8340,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -8376,6 +8523,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -8440,6 +8593,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -8688,7 +8847,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -8758,6 +8926,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.1", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -8771,6 +8961,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.12.1", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.11" @@ -8780,7 +8982,6 @@ dependencies = [ "cc", "downcast-rs", "rustix 1.1.3", - "scoped-tls", "smallvec", "wayland-sys", ] @@ -8839,8 +9040,6 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ - "dlib", - "log", "pkg-config", ] @@ -9506,6 +9705,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + [[package]] name = "winreg" version = "0.55.0" @@ -9522,6 +9727,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.1", + "prettyplease", + "syn 2.0.111", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.111", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.12.1", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.1", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wl-clipboard-rs" version = "0.9.2" @@ -9730,7 +10023,6 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", @@ -9862,6 +10154,79 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zip" +version = "8.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.2", + "hmac", + "indexmap 2.12.1", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" @@ -9910,7 +10275,6 @@ dependencies = [ "endi", "enumflags2", "serde", - "url", "winnow 0.7.14", "zvariant_derive", "zvariant_utils", diff --git a/Cargo.toml b/Cargo.toml index 8479a1a63..2c8cebd44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ sage-database = { path = "./crates/sage-database" } sage-keychain = { path = "./crates/sage-keychain" } sage-wallet = { path = "./crates/sage-wallet" } sage-assets = { path = "./crates/sage-assets" } +sage-apps = { path = "./crates/sage-apps" } sage-rpc = { path = "./crates/sage-rpc" } # Serialization @@ -67,7 +68,7 @@ hex = "0.4.3" base64 = "0.22.1" # Tauri -tauri = "2.10.2" +tauri = { version = "2.10.2", features = ["unstable"] } # Unstable reason: Multiwebview support tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-opener = "2.5.3" tauri-plugin-os = "2.3.2" diff --git a/README.md b/README.md index d82cb8b55..bc0b85a1a 100644 --- a/README.md +++ b/README.md @@ -53,16 +53,16 @@ You can run the app with: ```bash # For development purposes: -pnpm tauri dev +pnpm tauri:dev # If you need optimizations: -pnpm tauri dev --release +pnpm tauri:dev --release ``` And build the application with: ```bash -pnpm tauri build +pnpm tauri:build ``` You can also run the app in the iOS or Android simulator, though it may take some prior setup: diff --git a/builtin-apps/src/runtime/origin-cleanup/app.js b/builtin-apps/src/runtime/origin-cleanup/app.js new file mode 100644 index 000000000..f5f51cc30 --- /dev/null +++ b/builtin-apps/src/runtime/origin-cleanup/app.js @@ -0,0 +1,123 @@ +import './bridge.js'; +import { getSageClient } from './sdk.js'; + +const log = (...args) => window.__SAGE_TEST__?.log?.(...args); + +async function deleteIndexedDb(name) { + return await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(name); + + req.onsuccess = () => resolve(null); + req.onerror = () => resolve(`indexedDB ${name}: ${req.error}`); + req.onblocked = () => resolve(`indexedDB ${name}: blocked`); + }); +} + +async function clearOriginData() { + const errors = []; + + try { + localStorage.clear(); + } catch (err) { + errors.push(`localStorage: ${String(err)}`); + } + + try { + sessionStorage.clear(); + } catch (err) { + errors.push(`sessionStorage: ${String(err)}`); + } + + try { + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + } + } catch (err) { + errors.push(`caches: ${String(err)}`); + } + + try { + if (indexedDB.databases) { + const dbs = await indexedDB.databases(); + + for (const db of dbs) { + if (!db.name) continue; + + const error = await deleteIndexedDb(db.name); + if (error) errors.push(error); + } + } else { + errors.push('indexedDB.databases unavailable'); + } + } catch (err) { + errors.push(`indexedDB: ${String(err)}`); + } + + try { + for (const cookie of document.cookie.split(';')) { + const name = cookie.split('=')[0]?.trim(); + if (!name) continue; + + document.cookie = `${name}=; Max-Age=0; path=/`; + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } + } catch (err) { + errors.push(`cookies: ${String(err)}`); + } + + return errors; +} + +async function report(sage, cleanupId, ok, errors) { + const payload = { + kind: 'originCleanup.completed', + cleanupId, + ok, + errors, + }; + + log('bridgeSend originCleanup.completed start', payload); + + const result = await sage.app.bridgeSend(payload); + + log('bridgeSend originCleanup.completed ok', result); +} + +(async () => { + log('start', window.location.href); + + const sage = await getSageClient(); + log('getSageClient ok'); + + const ping = await sage.app.bridgePing(); + log('bridgePing ok', ping); + + const params = new URLSearchParams(window.location.search); + const cleanupId = params.get('cleanupId'); + + if (!cleanupId) { + throw new Error('missing cleanupId'); + } + + const errors = await clearOriginData(); + + await report(sage, cleanupId, errors.length === 0, errors); +})().catch(async (err) => { + log('fatal', err instanceof Error ? err.message : String(err)); + + try { + const sage = await getSageClient(); + const params = new URLSearchParams(window.location.search); + const cleanupId = params.get('cleanupId') ?? ''; + + await report(sage, cleanupId, false, [ + err instanceof Error ? err.message : String(err), + ]); + } catch (fallbackErr) { + log( + 'fallback failed', + fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr), + ); + } +}); diff --git a/builtin-apps/src/runtime/origin-cleanup/index.html b/builtin-apps/src/runtime/origin-cleanup/index.html new file mode 100644 index 000000000..20a67c6eb --- /dev/null +++ b/builtin-apps/src/runtime/origin-cleanup/index.html @@ -0,0 +1,11 @@ + + + + + Origin Cleanup + + + + + + \ No newline at end of file diff --git a/builtin-apps/src/runtime/origin-cleanup/sage-manifest.json b/builtin-apps/src/runtime/origin-cleanup/sage-manifest.json new file mode 100644 index 000000000..e5699dcb6 --- /dev/null +++ b/builtin-apps/src/runtime/origin-cleanup/sage-manifest.json @@ -0,0 +1,23 @@ +{ + "manifestVersion": 0, + "name": "Sage Origin Cleanup", + "sageVersion": { + "min": "0.0.0" + }, + "version": "1.0.0", + "permissions": { + "network": { + "whitelist": { + "required": [], + "optional": [] + } + }, + "capabilities": { + "required": ["bridge.send"], + "optional": [] + } + }, + "files": [], + "entry": "index.html" +} + diff --git a/builtin-apps/src/sandbox-test/_shared/debug-tools.js b/builtin-apps/src/sandbox-test/_shared/debug-tools.js new file mode 100644 index 000000000..ffdd2ad55 --- /dev/null +++ b/builtin-apps/src/sandbox-test/_shared/debug-tools.js @@ -0,0 +1,152 @@ +(function () { + function ensureRoot() { + let root = document.getElementById('__sage_test_debug_root'); + if (root) return root; + + root = document.createElement('div'); + root.id = '__sage_test_debug_root'; + Object.assign(root.style, { + position: 'fixed', + top: '6px', + left: '6px', + right: '6px', + bottom: '6px', + zIndex: '2147483647', + pointerEvents: 'none', + display: 'flex', + flexDirection: 'column', + gap: '6px', + fontFamily: 'monospace', + minHeight: '0', + minWidth: '0', + boxSizing: 'border-box', + }); + + document.body.appendChild(root); + return root; + } + + function mountDebugLabel() { + const params = new URLSearchParams(window.location.search); + const appId = window.location.host || 'unknown-app'; + const phase = params.get('phase'); + const runId = params.get('runId'); + + const parts = [appId]; + if (phase) parts.push(phase); + if (runId) parts.push(runId); + + const el = document.createElement('div'); + el.textContent = parts.join(' • '); + + Object.assign(el.style, { + alignSelf: 'stretch', + flex: '0 0 auto', + pointerEvents: 'none', + padding: '3px 6px', + borderRadius: '4px', + background: 'rgba(0,0,0,0.72)', + color: '#00ff88', + fontSize: '10px', + lineHeight: '1.2', + whiteSpace: 'normal', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + maxWidth: '100%', + boxSizing: 'border-box', + }); + + ensureRoot().appendChild(el); + return el; + } + + function ensureLogPane() { + let pane = document.getElementById('__sage_test_log_pane'); + if (pane) return pane; + + pane = document.createElement('div'); + pane.id = '__sage_test_log_pane'; + + Object.assign(pane.style, { + flex: '1 1 auto', + minHeight: '0', + minWidth: '0', + pointerEvents: 'auto', + overflowY: 'auto', + overflowX: 'hidden', + padding: '6px', + borderRadius: '4px', + background: 'rgba(0,0,0,0.72)', + color: '#e5e7eb', + fontSize: '10px', + lineHeight: '1.35', + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + boxSizing: 'border-box', + overscrollBehavior: 'contain', + }); + + ensureRoot().appendChild(pane); + return pane; + } + + function stringify(value) { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + function log(...args) { + const pane = ensureLogPane(); + const line = document.createElement('div'); + line.textContent = args.map(stringify).join(' '); + pane.appendChild(line); + pane.scrollTop = pane.scrollHeight; + } + + function patchConsole() { + const orig = { + log: console.log.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + console.log = (...args) => { + log('[log]', ...args); + orig.log(...args); + }; + + console.warn = (...args) => { + log('[warn]', ...args); + orig.warn(...args); + }; + + console.error = (...args) => { + log('[error]', ...args); + orig.error(...args); + }; + + window.addEventListener('error', (event) => { + log('[window.error]', event.message); + }); + + window.addEventListener('unhandledrejection', (event) => { + log('[unhandledrejection]', event.reason); + }); + } + + window.__SAGE_TEST__ = { + ...(window.__SAGE_TEST__ || {}), + mountDebugLabel, + ensureLogPane, + log, + }; + + mountDebugLabel(); + ensureLogPane(); + patchConsole(); +})(); diff --git a/builtin-apps/src/sandbox-test/_shared/icon.svg b/builtin-apps/src/sandbox-test/_shared/icon.svg new file mode 100644 index 000000000..1f0dd22f3 --- /dev/null +++ b/builtin-apps/src/sandbox-test/_shared/icon.svg @@ -0,0 +1,4 @@ + + + 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..b73c6619b --- /dev/null +++ b/builtin-apps/src/sandbox-test/_shared/index.html @@ -0,0 +1,12 @@ + + + + + Sage Builtin Test App + + + + + + + \ No newline at end of file diff --git a/builtin-apps/src/sandbox-test/network-allow-a/app.js b/builtin-apps/src/sandbox-test/network-allow-a/app.js new file mode 100644 index 000000000..0f28146e8 --- /dev/null +++ b/builtin-apps/src/sandbox-test/network-allow-a/app.js @@ -0,0 +1,115 @@ +import './bridge.js'; +import { getSageClient } from './sdk.js'; + +const log = (...args) => window.__SAGE_TEST__?.log?.(...args); + +(async () => { + log('start', window.location.href); + + const sage = await getSageClient(); + log('getSageClient ok'); + + const ping = await sage.app.bridgePing(); + log('bridgePing ok', ping); + + const params = new URLSearchParams(window.location.search); + const runId = params.get('runId'); + + if (!runId) { + throw new Error('missing runId'); + } + + const allowedUrl = 'https://example.com/'; + const blockedUrl = 'https://example.org/'; + + async function tryFetch(url) { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 5000); + + try { + await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'no-store', + signal: controller.signal, + }); + + window.clearTimeout(timeout); + return true; + } catch { + window.clearTimeout(timeout); + return false; + } + } + + let allowedOk = false; + let blockedOk = false; + let error = null; + + try { + allowedOk = await tryFetch(allowedUrl); + log('allowedOk', allowedUrl, allowedOk); + + blockedOk = await tryFetch(blockedUrl); + log('blockedOk', blockedUrl, blockedOk); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + log('network probe error', error); + } + + const payload = { + runId, + mode: 'allow-a', + allowedUrl, + blockedUrl, + allowedOk, + blockedOk, + error, + }; + + log('bridgeSend network start', payload); + + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'network', + data: payload, + }, + }); + + log('bridgeSend network ok', result); +})().catch(async (err) => { + log('fatal', err instanceof Error ? err.message : String(err)); + + try { + const sage = await getSageClient(); + const params = new URLSearchParams(window.location.search); + + const payload = { + runId: params.get('runId'), + mode: 'allow-a', + allowedUrl: 'https://example.com/', + blockedUrl: 'https://example.org/', + allowedOk: false, + blockedOk: false, + error: err instanceof Error ? err.message : String(err), + }; + + log('fallback bridgeSend network start', payload); + + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'network', + data: payload, + }, + }); + + log('fallback bridgeSend network ok', result); + } catch (fallbackErr) { + log( + 'fallback failed', + fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr), + ); + } +}); diff --git a/builtin-apps/src/sandbox-test/network-allow-a/sage-manifest.json b/builtin-apps/src/sandbox-test/network-allow-a/sage-manifest.json new file mode 100644 index 000000000..ea554a0ed --- /dev/null +++ b/builtin-apps/src/sandbox-test/network-allow-a/sage-manifest.json @@ -0,0 +1,24 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0", + "testedMax": "99.0.0" + }, + "name": "Network Allow A", + "version": "1.0.0", + "icon": "icon.svg", + "permissions": { + "network": { + "whitelist": { + "required": [ + "https://example.com" + ] + } + }, + "capabilities": { + "required": ["bridge.send"], + "optional": [] + } + }, + "files": [] +} diff --git a/builtin-apps/src/sandbox-test/network-allow-b/app.js b/builtin-apps/src/sandbox-test/network-allow-b/app.js new file mode 100644 index 000000000..744ac72ba --- /dev/null +++ b/builtin-apps/src/sandbox-test/network-allow-b/app.js @@ -0,0 +1,115 @@ +import './bridge.js'; +import { getSageClient } from './sdk.js'; + +const log = (...args) => window.__SAGE_TEST__?.log?.(...args); + +(async () => { + log('start', window.location.href); + + const sage = await getSageClient(); + log('getSageClient ok'); + + const ping = await sage.app.bridgePing(); + log('bridgePing ok', ping); + + const params = new URLSearchParams(window.location.search); + const runId = params.get('runId'); + + if (!runId) { + throw new Error('missing runId'); + } + + const allowedUrl = 'https://example.org/'; + const blockedUrl = 'https://example.com/'; + + async function tryFetch(url) { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 5000); + + try { + await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'no-store', + signal: controller.signal, + }); + + window.clearTimeout(timeout); + return true; + } catch { + window.clearTimeout(timeout); + return false; + } + } + + let allowedOk = false; + let blockedOk = false; + let error = null; + + try { + allowedOk = await tryFetch(allowedUrl); + log('allowedOk', allowedUrl, allowedOk); + + blockedOk = await tryFetch(blockedUrl); + log('blockedOk', blockedUrl, blockedOk); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + log('network probe error', error); + } + + const payload = { + runId, + mode: 'allow-b', + allowedUrl, + blockedUrl, + allowedOk, + blockedOk, + error, + }; + + log('bridgeSend network start', payload); + + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'network', + data: payload, + }, + }); + + log('bridgeSend network ok', result); +})().catch(async (err) => { + log('fatal', err instanceof Error ? err.message : String(err)); + + try { + const sage = await getSageClient(); + const params = new URLSearchParams(window.location.search); + + const payload = { + runId: params.get('runId'), + mode: 'allow-b', + allowedUrl: 'https://example.org/', + blockedUrl: 'https://example.com/', + allowedOk: false, + blockedOk: false, + error: err instanceof Error ? err.message : String(err), + }; + + log('fallback bridgeSend network start', payload); + + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'network', + data: payload, + }, + }); + + log('fallback bridgeSend network ok', result); + } catch (fallbackErr) { + log( + 'fallback failed', + fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr), + ); + } +}); diff --git a/builtin-apps/src/sandbox-test/network-allow-b/sage-manifest.json b/builtin-apps/src/sandbox-test/network-allow-b/sage-manifest.json new file mode 100644 index 000000000..a556b760a --- /dev/null +++ b/builtin-apps/src/sandbox-test/network-allow-b/sage-manifest.json @@ -0,0 +1,22 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0", + "testedMax": "99.0.0" + }, + "name": "Network Allow B", + "version": "1.0.0", + "icon": "icon.svg", + "permissions": { + "network": { + "whitelist": { + "required": ["https://example.org"] + } + }, + "capabilities": { + "required": ["bridge.send"], + "optional": [] + } + }, + "files": [] +} diff --git a/builtin-apps/src/sandbox-test/sage-storage-isolation/app.js b/builtin-apps/src/sandbox-test/sage-storage-isolation/app.js new file mode 100644 index 000000000..795eb29fe --- /dev/null +++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/app.js @@ -0,0 +1,146 @@ +import './bridge.js'; +import { getSageClient } from './sdk.js'; + +const log = (...args) => window.__SAGE_TEST__?.log?.(...args); + +(async () => { + log('start', window.location.href); + + const sage = await getSageClient(); + log('getSageClient ok'); + + const ping = await sage.app.bridgePing(); + log('bridgePing ok', ping); + + const params = new URLSearchParams(window.location.search); + const runId = params.get('runId'); + + if (!runId) { + throw new Error('missing runId'); + } + + const LOCAL_STORAGE_KEY = 'sage_probe_local_storage'; + const DB_NAME = 'sage_probe_db'; + const STORE_NAME = 'probe_store'; + const DB_KEY = 'sage_probe_key'; + + async function readIndexedDbProbe() { + try { + return await new Promise((resolve) => { + const open = indexedDB.open(DB_NAME); + + open.onerror = () => resolve(false); + + open.onupgradeneeded = () => { + try { + const db = open.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + } catch {} + }; + + open.onsuccess = () => { + try { + const db = open.result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.close(); + resolve(false); + return; + } + + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(DB_KEY); + + req.onerror = () => { + db.close(); + resolve(false); + }; + + req.onsuccess = () => { + db.close(); + resolve(typeof req.result === 'string' && req.result.length > 0); + }; + } catch { + resolve(false); + } + }; + }); + } catch { + return false; + } + } + + async function report(data) { + log('bridgeSend isolation start', data); + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'isolation', + data, + }, + }); + log('bridgeSend isolation ok', result); + } + + let localStorageVisible = false; + let indexedDbVisible = false; + let error = null; + + try { + try { + const value = localStorage.getItem(LOCAL_STORAGE_KEY); + localStorageVisible = typeof value === 'string' && value.length > 0; + log('localStorageVisible', localStorageVisible); + } catch { + localStorageVisible = false; + log('localStorage read failed'); + } + + indexedDbVisible = await readIndexedDbProbe(); + log('indexedDbVisible', indexedDbVisible); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + log('probe error', error); + } + + await report({ + runId, + localStorageVisible, + indexedDbVisible, + error, + }); +})().catch(async (err) => { + log('fatal', err instanceof Error ? err.message : String(err)); + + try { + const sage = await getSageClient(); + const params = new URLSearchParams(window.location.search); + + const payload = { + runId: params.get('runId'), + localStorageVisible: false, + indexedDbVisible: false, + error: err instanceof Error ? err.message : String(err), + }; + + log('fallback bridgeSend isolation start', payload); + + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'isolation', + data: payload, + }, + }); + + log('fallback bridgeSend isolation ok', result); + } catch (fallbackErr) { + log( + 'fallback failed', + fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr), + ); + } +}); diff --git a/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.incognito.json b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.incognito.json new file mode 100644 index 000000000..c50a494cc --- /dev/null +++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.incognito.json @@ -0,0 +1,18 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0", + "testedMax": "99.0.0" + }, + "name": "Sage Storage Isolation", + "version": "1.0.0", + "icon": "icon.svg", + "permissions": { + "capabilities": { + "required": ["bridge.send"], + "optional": [] + } + }, + "files": [] +} + diff --git a/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.persistent.json b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.persistent.json new file mode 100644 index 000000000..a4cfdb8fb --- /dev/null +++ b/builtin-apps/src/sandbox-test/sage-storage-isolation/sage-manifest.persistent.json @@ -0,0 +1,18 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0", + "testedMax": "99.0.0" + }, + "name": "Sage Storage Isolation", + "version": "1.0.0", + "icon": "icon.svg", + "permissions": { + "capabilities": { + "required": ["bridge.send", "storage.persistent_webview"], + "optional": [] + } + }, + "files": [] +} + diff --git a/builtin-apps/src/sandbox-test/storage-persistence/app.js b/builtin-apps/src/sandbox-test/storage-persistence/app.js new file mode 100644 index 000000000..4c9ad77b9 --- /dev/null +++ b/builtin-apps/src/sandbox-test/storage-persistence/app.js @@ -0,0 +1,268 @@ +import './bridge.js'; +import { getSageClient } from './sdk.js'; + +const log = (...args) => window.__SAGE_TEST__?.log?.(...args); + +(async () => { + log('start', window.location.href); + + const sage = await getSageClient(); + log('getSageClient ok'); + + const ping = await sage.app.bridgePing(); + log('bridgePing ok', ping); + + const params = new URLSearchParams(window.location.search); + const runId = params.get('runId'); + const phase = params.get('phase'); + + if (!runId) { + throw new Error('missing runId'); + } + + if (phase !== 'write' && phase !== 'read') { + throw new Error('missing or invalid phase'); + } + + const LOCAL_STORAGE_KEY = `sandbox_persistence_local_storage:${runId}:incognito`; + const DB_NAME = `sandbox_persistence_db_${runId}_incognito`; + const STORE_NAME = 'probe_store'; + const DB_KEY = 'probe_key'; + + async function writeIndexedDbValue() { + try { + return await new Promise((resolve) => { + const open = indexedDB.open(DB_NAME); + + open.onerror = () => resolve(false); + + open.onupgradeneeded = () => { + try { + const db = open.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + } catch {} + }; + + open.onsuccess = () => { + try { + const db = open.result; + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put('present', DB_KEY); + + req.onerror = () => { + db.close(); + resolve(false); + }; + + req.onsuccess = () => { + tx.oncomplete = () => { + db.close(); + resolve(true); + }; + + tx.onerror = () => { + db.close(); + resolve(false); + }; + }; + } catch { + resolve(false); + } + }; + }); + } catch { + return false; + } + } + + async function readIndexedDbValue() { + try { + return await new Promise((resolve) => { + const open = indexedDB.open(DB_NAME); + + open.onerror = () => resolve(false); + + open.onupgradeneeded = () => { + try { + const db = open.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + } catch {} + }; + + open.onsuccess = () => { + try { + const db = open.result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.close(); + resolve(false); + return; + } + + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(DB_KEY); + + req.onerror = () => { + db.close(); + resolve(false); + }; + + req.onsuccess = () => { + db.close(); + resolve(req.result === 'present'); + }; + } catch { + resolve(false); + } + }; + }); + } catch { + return false; + } + } + + async function reportWrite(data) { + log('bridgeSend persistence_write start', data); + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'persistence_write', + data, + }, + }); + log('bridgeSend persistence_write ok', result); + } + + async function reportRead(data) { + log('bridgeSend persistence_read start', data); + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'persistence_read', + data, + }, + }); + log('bridgeSend persistence_read ok', result); + } + + if (phase === 'write') { + let localStorageWrote = false; + let indexedDbWrote = false; + let error = null; + + try { + try { + localStorage.setItem(LOCAL_STORAGE_KEY, 'present'); + localStorageWrote = + localStorage.getItem(LOCAL_STORAGE_KEY) === 'present'; + log('localStorageWrote', localStorageWrote); + } catch { + localStorageWrote = false; + log('localStorage write failed'); + } + + indexedDbWrote = await writeIndexedDbValue(); + log('indexedDbWrote', indexedDbWrote); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + log('write phase error', error); + } + + await reportWrite({ + runId, + localStorageWrote, + indexedDbWrote, + error, + }); + + return; + } + + let localStoragePresent = false; + let indexedDbPresent = false; + let error = null; + + try { + try { + localStoragePresent = + localStorage.getItem(LOCAL_STORAGE_KEY) === 'present'; + log('localStoragePresent', localStoragePresent); + } catch { + localStoragePresent = false; + log('localStorage read failed'); + } + + indexedDbPresent = await readIndexedDbValue(); + log('indexedDbPresent', indexedDbPresent); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + log('read phase error', error); + } + + await reportRead({ + runId, + localStoragePresent, + indexedDbPresent, + error, + }); +})().catch(async (err) => { + log('fatal', err instanceof Error ? err.message : String(err)); + + try { + const sage = await getSageClient(); + const params = new URLSearchParams(window.location.search); + const phase = params.get('phase'); + + if (phase === 'write') { + const payload = { + runId: params.get('runId'), + localStorageWrote: false, + indexedDbWrote: false, + error: err instanceof Error ? err.message : String(err), + }; + + log('fallback bridgeSend persistence_write start', payload); + + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'persistence_write', + data: payload, + }, + }); + + log('fallback bridgeSend persistence_write ok', result); + return; + } + + const payload = { + runId: params.get('runId'), + localStoragePresent: false, + indexedDbPresent: false, + error: err instanceof Error ? err.message : String(err), + }; + + log('fallback bridgeSend persistence_read start', payload); + + const result = await sage.app.bridgeSend({ + kind: 'sandbox_report', + report: { + type: 'persistence_read', + data: payload, + }, + }); + + log('fallback bridgeSend persistence_read ok', result); + } catch (fallbackErr) { + log( + 'fallback failed', + fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr), + ); + } +}); diff --git a/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.incognito.json b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.incognito.json new file mode 100644 index 000000000..6a8ac2077 --- /dev/null +++ b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.incognito.json @@ -0,0 +1,17 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0", + "testedMax": "99.0.0" + }, + "name": "Storage Persistence", + "version": "1.0.0", + "icon": "icon.svg", + "permissions": { + "capabilities": { + "required": ["bridge.send"], + "optional": [] + } + }, + "files": [] +} diff --git a/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.persistent.json b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.persistent.json new file mode 100644 index 000000000..202e08fdb --- /dev/null +++ b/builtin-apps/src/sandbox-test/storage-persistence/sage-manifest.persistent.json @@ -0,0 +1,17 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0", + "testedMax": "99.0.0" + }, + "name": "Storage Persistence", + "version": "1.0.0", + "icon": "icon.svg", + "permissions": { + "capabilities": { + "required": ["bridge.send", "storage.persistent_webview"], + "optional": [] + } + }, + "files": [] +} diff --git a/builtin-apps/src/system/apps/app-install/index.html b/builtin-apps/src/system/apps/app-install/index.html new file mode 100644 index 000000000..ccd273c36 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/index.html @@ -0,0 +1,15 @@ + + + + + + Sage Update Review + + +
+ + + diff --git a/builtin-apps/src/system/apps/app-install/package.json b/builtin-apps/src/system/apps/app-install/package.json new file mode 100644 index 000000000..7a973cd0f --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/package.json @@ -0,0 +1,5 @@ +{ + "name": "app-install-system-app", + "private": true, + "type": "module" +} diff --git a/builtin-apps/src/system/apps/app-install/postcss.config.js b/builtin-apps/src/system/apps/app-install/postcss.config.js new file mode 100644 index 000000000..c97c6b959 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/postcss.config.js @@ -0,0 +1,13 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + tailwindcss: { + config: join(dir, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +}; diff --git a/builtin-apps/src/system/apps/app-install/public/icon.svg b/builtin-apps/src/system/apps/app-install/public/icon.svg new file mode 100644 index 000000000..af1297f54 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/public/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/builtin-apps/src/system/apps/app-install/sage-manifest.json b/builtin-apps/src/system/apps/app-install/sage-manifest.json new file mode 100644 index 000000000..00493b54b --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/sage-manifest.json @@ -0,0 +1,27 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0" + }, + "name": "Install App", + "version": "0.1.0", + "permissions": { + "network": { + "whitelist": { + "required": [], + "optional": [] + } + }, + "capabilities": { + "required": [ + "environment.theme.get_current", + "environment.theme.listen_changed", + "environment.theme.css_vars" + ], + "optional": [] + } + }, + "files": [], + "entry": "index.html", + "icon": "icon.svg" +} diff --git a/builtin-apps/src/system/apps/app-install/src/App.tsx b/builtin-apps/src/system/apps/app-install/src/App.tsx new file mode 100644 index 000000000..d7f707087 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/App.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import { formatSageError, getSageSystemClient } from '@sage-system-app/sdk'; +import { previewUrl } from './api'; +import { ErrorState } from './components/ErrorState'; +import { LoadingState } from './components/LoadingState'; +import { ReviewInstallView } from './components/ReviewInstallView'; +import { SelectSourceView } from './components/SelectSourceView'; +import type { LoadState } from './types'; + +export function App() { + const [state, setState] = useState({ kind: 'loading' }); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const client = await getSageSystemClient(); + + const definitions = await client.capabilities.listUserDefinitions(); + + const params = new URLSearchParams(window.location.search); + const mode = params.get('mode') ?? 'select-source'; + const appUrl = params.get('appUrl'); + + if (mode === 'url' && appUrl) { + const source = await previewUrl(appUrl); + + if (!cancelled) { + setState({ + kind: 'review', + definitions, + source, + }); + } + + return; + } + + if (!cancelled) { + setState({ kind: 'selecting', definitions }); + } + } catch (err) { + if (!cancelled) { + setState({ kind: 'error', error: formatSageError(err) }); + } + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, []); + + if (state.kind === 'loading') return ; + if (state.kind === 'error') return ; + + if (state.kind === 'selecting') { + return ( + + setState({ + kind: 'review', + definitions: state.definitions, + source, + }) + } + /> + ); + } + + return ( + + ); +} diff --git a/builtin-apps/src/system/apps/app-install/src/api.ts b/builtin-apps/src/system/apps/app-install/src/api.ts new file mode 100644 index 000000000..4d6bc9e5d --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/api.ts @@ -0,0 +1,73 @@ +import { + getSageSystemClient, + type SageGrantedPermissionsInput, + type SageAppWalletScope, + type WalletListWalletsResult, +} from '@sage-system-app/sdk'; +import type { InstallSource } from './types'; + +export async function closeSelf() { + const client = await getSageSystemClient(); + void client.runtimeManager.closeSelf(); +} + +export async function listWallets(): Promise { + const client = await getSageSystemClient(); + return await client.wallet.listWallets(); +} + +export async function previewUrl(appUrl: string): Promise { + const client = await getSageSystemClient(); + const preview = await client.appInstall.previewUrl({ appUrl }); + + return { + kind: 'url', + appUrl: preview.appUrl, + preview, + }; +} + +export async function selectAndPreviewZip(): Promise { + const client = await getSageSystemClient(); + + const selected = await client.fileSystem.selectFile({ + title: 'Select Sage app package', + filters: [{ name: 'Zip Archive', extensions: ['zip'] }], + }); + + if (!selected.path) { + return null; + } + + const manifest = await client.appInstall.previewZip({ + zipPath: selected.path, + }); + + return { + kind: 'zip', + zipPath: selected.path, + manifest, + }; +} + +export async function installSource( + source: InstallSource, + grantedPermissions: SageGrantedPermissionsInput, + walletScope: SageAppWalletScope, +) { + const client = await getSageSystemClient(); + + if (source.kind === 'zip') { + await client.appInstall.installZip({ + zipPath: source.zipPath, + grantedPermissions, + walletScope, + }); + } else { + await client.appInstall.installUrl({ + appUrl: source.appUrl, + grantedPermissions, + walletScope, + }); + } +} diff --git a/builtin-apps/src/system/apps/app-install/src/components/ErrorState.tsx b/builtin-apps/src/system/apps/app-install/src/components/ErrorState.tsx new file mode 100644 index 000000000..e78078aff --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/components/ErrorState.tsx @@ -0,0 +1,14 @@ +import { AppModalShell } from '@sage-app/ui'; + +export function ErrorState({ error }: { error: string }) { + return ( + +
+ {error} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-install/src/components/LoadingState.tsx b/builtin-apps/src/system/apps/app-install/src/components/LoadingState.tsx new file mode 100644 index 000000000..c743fe244 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/components/LoadingState.tsx @@ -0,0 +1,12 @@ +import { AppModalShell } from '@sage-app/ui'; + +export function LoadingState() { + return ( + +
Loading installer…
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-install/src/components/ReviewInstallView.tsx b/builtin-apps/src/system/apps/app-install/src/components/ReviewInstallView.tsx new file mode 100644 index 000000000..7b1fd8a1d --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/components/ReviewInstallView.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + AppModalShell, + AppPermissionEditor, + WalletScopeEditor, +} from '@sage-app/ui'; +import { + formatSageError, + type SageAppCapabilityDefinitionView, + type SageAppWalletScope, + type SageGrantedPermissionsInput, + type SystemWalletView, +} from '@sage-system-app/sdk'; +import { closeSelf, installSource, listWallets } from '../api'; +import type { InstallSource } from '../types'; +import { resolveInstallIcon } from '../utils/icons'; +import { + buildPreviewApp, + emptyGrantedPermissions, + initialGrantedPermissions, + installManifest, +} from '../utils/permissions'; +import { UnsupportedManifestView } from './UnsupportedManifestView'; + +type Step = 'permissions' | 'wallets'; + +export function ReviewInstallView({ + source, + definitions, +}: { + source: InstallSource; + definitions: SageAppCapabilityDefinitionView[]; +}) { + const manifest = installManifest(source); + + const [step, setStep] = useState('permissions'); + const [installing, setInstalling] = useState(false); + const [error, setError] = useState(null); + const [wallets, setWallets] = useState([]); + const [walletsLoading, setWalletsLoading] = useState(true); + const [permissionsViewed, setPermissionsViewed] = useState(false); + + const [walletScope, setWalletScope] = useState({ + kind: 'allWallets', + }); + + const [grantedPermissions, setGrantedPermissions] = + useState(() => + manifest + ? initialGrantedPermissions(manifest, definitions) + : emptyGrantedPermissions(), + ); + + useEffect(() => { + let disposed = false; + + async function loadWallets() { + try { + setWalletsLoading(true); + const result = await listWallets(); + + if (!disposed) { + setWallets(result.wallets); + } + } catch (err) { + if (!disposed) { + setError(formatSageError(err)); + } + } finally { + if (!disposed) { + setWalletsLoading(false); + } + } + } + + void loadWallets(); + + return () => { + disposed = true; + }; + }, []); + + const previewApp = useMemo(() => { + if (!manifest) return null; + return buildPreviewApp(manifest, grantedPermissions); + }, [manifest, grantedPermissions]); + + const canInstall = + !installing && + !walletsLoading && + (walletScope.kind === 'allWallets' || walletScope.fingerprints.length > 0); + + async function install() { + if (!manifest || !canInstall) return; + + setInstalling(true); + setError(null); + + try { + await installSource(source, grantedPermissions, walletScope); + await closeSelf(); + } catch (err) { + setError(formatSageError(err)); + } finally { + setInstalling(false); + } + } + + if (!manifest || !previewApp) { + return ; + } + + return ( + + + +
+ {step === 'wallets' ? ( + + ) : null} + + {step === 'permissions' ? ( + + ) : ( + + )} +
+ + } + > +
+ {step === 'permissions' ? ( + + ) : walletsLoading ? ( +
+ Loading wallets… +
+ ) : ( + <> + + + )} + + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-install/src/components/SelectSourceView.tsx b/builtin-apps/src/system/apps/app-install/src/components/SelectSourceView.tsx new file mode 100644 index 000000000..c307845eb --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/components/SelectSourceView.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react'; +import { SystemModalShell } from '@sage-app/ui'; +import { formatSageError } from '@sage-system-app/sdk'; +import { closeSelf, previewUrl, selectAndPreviewZip } from '../api'; +import type { InstallSource } from '../types'; + +export function SelectSourceView({ + onReview, +}: { + onReview: (source: InstallSource) => void; +}) { + const [urlInput, setUrlInput] = useState(''); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + async function handlePreviewUrl() { + setWorking(true); + setError(null); + + try { + onReview(await previewUrl(urlInput.trim())); + } catch (err) { + setError(formatSageError(err)); + } finally { + setWorking(false); + } + } + + async function handleSelectZip() { + setWorking(true); + setError(null); + + try { + const source = await selectAndPreviewZip(); + if (source) onReview(source); + } catch (err) { + setError(formatSageError(err)); + } finally { + setWorking(false); + } + } + + return ( + +
+
+ {/* Install from URL */} +
+
Install from URL
+

+ Best for published apps and updates. +

+ +
+ setUrlInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && urlInput.trim()) { + event.preventDefault(); + void handlePreviewUrl(); + } + }} + /> + + +
+
+ + {/* Install from ZIP */} +
+
Install from ZIP
+

+ Useful for local builds, testing, or manual package installs. +

+ + +
+ + {/* Error */} + {error ? ( +
+ {error} +
+ ) : null} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-install/src/components/UnsupportedManifestView.tsx b/builtin-apps/src/system/apps/app-install/src/components/UnsupportedManifestView.tsx new file mode 100644 index 000000000..4240f8f0f --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/components/UnsupportedManifestView.tsx @@ -0,0 +1,60 @@ +import { AppModalShell } from '@sage-app/ui'; +import { closeSelf } from '../api'; +import type { InstallSource } from '../types'; + +export function UnsupportedManifestView({ + source, + error, +}: { + source: InstallSource; + error: string | null; +}) { + const partial = + source.kind === 'url' && source.preview.manifest.kind === 'partial' + ? source.preview.manifest + : null; + + return ( + + + + } + > +
+ {partial ? ( + <> +
+ Requires Sage {partial.manifest_header.sageVersion.min} + {partial.manifest_header.sageVersion.testedMax + ? ` · tested up to ${partial.manifest_header.sageVersion.testedMax}` + : null} +
+ +
+              {partial.parse_error}
+            
+ + ) : ( +
+ This app manifest cannot be installed by this Sage version. +
+ )} + + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-install/src/main.tsx b/builtin-apps/src/system/apps/app-install/src/main.tsx new file mode 100644 index 000000000..ed0dc6330 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/builtin-apps/src/system/apps/app-install/src/styles.css b/builtin-apps/src/system/apps/app-install/src/styles.css new file mode 100644 index 000000000..ad480ae12 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/styles.css @@ -0,0 +1,19 @@ +@import "@sage-app/sdk/theme.css"; +@config "../tailwind.config.js"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: transparent; +} diff --git a/builtin-apps/src/system/apps/app-install/src/types.ts b/builtin-apps/src/system/apps/app-install/src/types.ts new file mode 100644 index 000000000..8dd55ef4c --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/types.ts @@ -0,0 +1,20 @@ +import type { + SageAppCapabilityDefinitionView, + SageAppPackageManifest, + SageAppUrlPreview, +} from '@sage-system-app/sdk'; + +export type InstallSource = + | { kind: 'zip'; zipPath: string; manifest: SageAppPackageManifest } + | { kind: 'url'; appUrl: string; preview: SageAppUrlPreview }; + +export type LoadState = + | { kind: 'loading' } + | { kind: 'selecting'; definitions: SageAppCapabilityDefinitionView[] } + | { + kind: 'review'; + definitions: SageAppCapabilityDefinitionView[]; + source: InstallSource; + } + | { kind: 'error'; error: string }; + diff --git a/builtin-apps/src/system/apps/app-install/src/utils/format.ts b/builtin-apps/src/system/apps/app-install/src/utils/format.ts new file mode 100644 index 000000000..338fac151 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/utils/format.ts @@ -0,0 +1,22 @@ +import type { SageAppPackageManifest } from '@sage-system-app/sdk'; + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown'; + if (bytes < 1024) return `${bytes} B`; + + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes / 1024; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} ${units[unitIndex]}`; +} + +export function manifestSize(manifest: SageAppPackageManifest): number { + return manifest.files.reduce((sum, file) => sum + (file.size ?? 0), 0); +} diff --git a/builtin-apps/src/system/apps/app-install/src/utils/icons.ts b/builtin-apps/src/system/apps/app-install/src/utils/icons.ts new file mode 100644 index 000000000..fc57a4c19 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/utils/icons.ts @@ -0,0 +1,17 @@ +import type { AppIcon } from '@sage-app/ui'; +import type { InstallSource } from '../types'; + +export function resolveInstallIcon(source: InstallSource): AppIcon | null { + if (source.kind === 'url') { + if (source.preview.icon === null) { + return null; + } + + return { + kind: 'bytes', + icon: source.preview.icon, + }; + } + + return null; +} diff --git a/builtin-apps/src/system/apps/app-install/src/utils/permissions.ts b/builtin-apps/src/system/apps/app-install/src/utils/permissions.ts new file mode 100644 index 000000000..c38545fc0 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/src/utils/permissions.ts @@ -0,0 +1,43 @@ +import { + emptyGrantedPermissionsInput, + initialGrantedPermissionsInput, + inputToGrantedPermissionsView, +} from '@sage-app/ui'; +import type { + SageAppCapabilityDefinitionView, + SageAppPackageManifest, + SageGrantedPermissionsInput, + UserSageAppView, +} from '@sage-system-app/sdk'; +import type { InstallSource } from '../types'; + +export const emptyGrantedPermissions = emptyGrantedPermissionsInput; +export const initialGrantedPermissions = initialGrantedPermissionsInput; + +export function installManifest( + source: InstallSource, +): SageAppPackageManifest | null { + if (source.kind === 'zip') return source.manifest; + if (source.preview.manifest.kind !== 'full') return null; + return source.preview.manifest.manifest; +} + +export function buildPreviewApp( + manifest: SageAppPackageManifest, + grantedPermissions: SageGrantedPermissionsInput, +): UserSageAppView { + return { + common: { + identity: { + id: '__install_preview__', + originId: '__install_preview__', + }, + grantedPermissions: inputToGrantedPermissionsView(grantedPermissions), + walletScope: { kind: 'selectedWallets', fingerprints: [] }, + activeSnapshot: { manifest }, + icon: null, + }, + source: { kind: 'zip' }, + pendingUpdate: null, + }; +} diff --git a/builtin-apps/src/system/apps/app-install/tailwind.config.js b/builtin-apps/src/system/apps/app-install/tailwind.config.js new file mode 100644 index 000000000..13edcb743 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/tailwind.config.js @@ -0,0 +1,14 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSageTailwindConfig } from '../../tailwind.shared.js'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(dir, '../../../../..'); + +export default createSageTailwindConfig({ + content: [ + join(dir, 'index.html'), + join(dir, 'src/**/*.{ts,tsx}'), + join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'), + ], +}); diff --git a/builtin-apps/src/system/apps/app-install/tsconfig.json b/builtin-apps/src/system/apps/app-install/tsconfig.json new file mode 100644 index 000000000..3d61827d7 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/builtin-apps/src/system/apps/app-install/vite.config.ts b/builtin-apps/src/system/apps/app-install/vite.config.ts new file mode 100644 index 000000000..c2498e6d6 --- /dev/null +++ b/builtin-apps/src/system/apps/app-install/vite.config.ts @@ -0,0 +1,3 @@ +import { defineSageSystemAppConfig } from '../../vite.system-app.config'; + +export default defineSageSystemAppConfig(import.meta.url); diff --git a/builtin-apps/src/system/apps/app-update/index.html b/builtin-apps/src/system/apps/app-update/index.html new file mode 100644 index 000000000..ccd273c36 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/index.html @@ -0,0 +1,15 @@ + + + + + + Sage Update Review + + +
+ + + diff --git a/builtin-apps/src/system/apps/app-update/package.json b/builtin-apps/src/system/apps/app-update/package.json new file mode 100644 index 000000000..0be8e9458 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/package.json @@ -0,0 +1,5 @@ +{ + "name": "app-update-system-app", + "private": true, + "type": "module" +} diff --git a/builtin-apps/src/system/apps/app-update/postcss.config.js b/builtin-apps/src/system/apps/app-update/postcss.config.js new file mode 100644 index 000000000..c97c6b959 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/postcss.config.js @@ -0,0 +1,13 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + tailwindcss: { + config: join(dir, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +}; diff --git a/builtin-apps/src/system/apps/app-update/sage-manifest.json b/builtin-apps/src/system/apps/app-update/sage-manifest.json new file mode 100644 index 000000000..992f99964 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/sage-manifest.json @@ -0,0 +1,26 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0" + }, + "name": "Update App", + "version": "0.1.0", + "permissions": { + "network": { + "whitelist": { + "required": [], + "optional": [] + } + }, + "capabilities": { + "required": [ + "environment.theme.get_current", + "environment.theme.listen_changed", + "environment.theme.css_vars" + ], + "optional": [] + } + }, + "files": [], + "entry": "index.html" +} diff --git a/builtin-apps/src/system/apps/app-update/src/App.tsx b/builtin-apps/src/system/apps/app-update/src/App.tsx new file mode 100644 index 000000000..47d2bccbc --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/App.tsx @@ -0,0 +1,32 @@ +import { SystemModalShell } from '@sage-app/ui'; +import { useLoadState } from './hooks/useLoadState'; +import { UpdateReviewBody } from './components/UpdateReviewBody'; +import { PermissionsReviewBody } from './components/PermissionsReviewBody'; +import { useSageSystemClient } from '@sage-system-app/sdk'; + +export function App() { + const sage = useSageSystemClient(); + const { state, reload } = useLoadState(); + + if (state.kind === 'loading') { + return ( + +
Loading review…
+
+ ); + } + + if (state.kind === 'error') { + return ( + +
{state.error}
+
+ ); + } + + if (state.mode === 'review-permissions') { + return ; + } + + return ; +} diff --git a/builtin-apps/src/system/apps/app-update/src/components/NoUpdateBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/NoUpdateBody.tsx new file mode 100644 index 000000000..b16978556 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/components/NoUpdateBody.tsx @@ -0,0 +1,18 @@ +export function NoUpdateBody({ name, onClose }: any) { + return ( +
+

App is up to date

+

+ No installable update is available for {name}. +

+
+ +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-update/src/components/PartialUpdateBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/PartialUpdateBody.tsx new file mode 100644 index 000000000..5d5197ddf --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/components/PartialUpdateBody.tsx @@ -0,0 +1,15 @@ +export function PartialUpdateBody({ header, error }: any) { + return ( +
+

Update cannot be installed

+ +
+ {header.name} requires unsupported manifest features. +
+ +
+        {error}
+      
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-update/src/components/PermissionsReviewBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/PermissionsReviewBody.tsx new file mode 100644 index 000000000..ebe265299 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/components/PermissionsReviewBody.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { + appIconFromCommonView, + AppModalShell, + AppPermissionEditor, + inputToGrantedPermissionsView, + WalletScopeEditor, +} from '@sage-app/ui'; +import { + formatSageError, + getSageSystemClient, + type SageAppWalletScope, + type SageGrantedPermissionsInput, +} from '@sage-system-app/sdk'; +import type { LoadState } from '../types'; + +type PermissionsReadyState = Extract; +type ReviewTab = 'permissions' | 'wallets'; + +export function PermissionsReviewBody({ + state, +}: { + state: PermissionsReadyState; +}) { + const [tab, setTab] = useState('permissions'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const [grantedPermissions, setGrantedPermissions] = + useState(state.app.common.grantedPermissions); + + const [walletScope, setWalletScope] = useState( + state.app.common.walletScope, + ); + + async function close() { + const client = await getSageSystemClient(); + await client.runtimeManager.closeSelf(); + } + + async function submit() { + setSubmitting(true); + setError(null); + + try { + const client = await getSageSystemClient(); + + await client.appPermissions.applyPermissions({ + appId: state.app.common.identity.id, + grantedPermissions, + walletScope, + }); + + await close(); + } catch (err) { + setError(formatSageError(err)); + } finally { + setSubmitting(false); + } + } + + return ( + + + + + + } + > +
+
+ + + +
+ + {tab === 'permissions' ? ( + + ) : ( + + )} + + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx b/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx new file mode 100644 index 000000000..668fb76cf --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/components/UpdateReviewBody.tsx @@ -0,0 +1,170 @@ +import { useMemo, useState } from 'react'; +import { + appIconFromCommonView, + AppModalShell, + UpdateDecisionPermissionEditor, +} from '@sage-app/ui'; +import { + formatSageError, + getSageSystemClient, + type SageGrantedPermissionsInput, +} from '@sage-system-app/sdk'; +import { NoUpdateBody } from './NoUpdateBody'; +import { PartialUpdateBody } from './PartialUpdateBody'; + +export function UpdateReviewBody({ state, onReload }: any) { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [permissionsViewed, setPermissionsViewed] = useState(false); + + const preview = state.updateContext?.preview ?? null; + + const additionalGrantedPermissions = + useMemo(() => { + const decision = state.app.pendingUpdate?.decision; + + if (decision?.kind !== 'review') { + return { + capabilities: [], + network: { + whitelist: [], + whitelistByNetwork: {}, + }, + }; + } + + return { + capabilities: decision.requiredUserGrantableCapabilities ?? [], + network: { + whitelist: decision.requiredNetworkWhitelist ?? [], + whitelistByNetwork: decision.requiredNetworkWhitelistByNetwork ?? {}, + }, + }; + }, [state.app.pendingUpdate?.decision]); + + async function close() { + const client = await getSageSystemClient(); + await client.runtimeManager.closeSelf(); + } + + async function submit() { + setSubmitting(true); + setError(null); + + try { + const client = await getSageSystemClient(); + + await client.appUpdate.applyUpdate({ + appId: state.app.common.identity.id, + additionalGrantedPermissions, + }); + + await close(); + } catch (err) { + setError(formatSageError(err)); + } finally { + setSubmitting(false); + } + } + + if (!preview) { + return ( + } + > + + + ); + } + + if (preview.manifest.kind === 'partial') { + return ( + } + > + + + ); + } + + return ( + + + + + + } + > +
+ + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} + +function UpdateIssueFooter({ + onReload, + onClose, +}: { + onReload: () => void; + onClose: () => void; +}) { + return ( +
+ + + +
+ ); +} diff --git a/builtin-apps/src/system/apps/app-update/src/hooks/useLoadState.ts b/builtin-apps/src/system/apps/app-update/src/hooks/useLoadState.ts new file mode 100644 index 000000000..bbad62159 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/hooks/useLoadState.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from 'react'; +import { formatSageError, useSageSystemClient } from '@sage-system-app/sdk'; +import type { LoadState, Mode } from '../types'; + +export function useLoadState() { + const [state, setState] = useState({ kind: 'loading' }); + const sage = useSageSystemClient(); + + const reload = useCallback(() => { + let cancelled = false; + + async function load() { + setState({ kind: 'loading' }); + + try { + const params = new URLSearchParams(window.location.search); + const appId = params.get('appId'); + const mode = (params.get('mode') ?? 'review-update') as Mode; + + if (!appId) { + setState({ kind: 'error', error: 'Missing appId' }); + return; + } + + const [definitions, walletsResult] = await Promise.all([ + sage.capabilities.listUserDefinitions(), + sage.wallet.listWallets(), + ]); + + if (mode === 'review-permissions') { + const permissionsContext = await sage.appPermissions.getReviewContext( + { + appId, + }, + ); + + if (!cancelled) { + setState({ + kind: 'ready', + mode, + app: permissionsContext.app, + permissionsContext, + updateContext: null, + definitions, + wallets: walletsResult.wallets, + }); + } + + return; + } + + const updateContext = await sage.appUpdate.getReviewContext({ appId }); + + if (!cancelled) { + setState({ + kind: 'ready', + mode: 'review-update', + app: updateContext.app, + updateContext, + permissionsContext: null, + definitions, + wallets: walletsResult.wallets, + }); + } + } catch (err) { + if (!cancelled) { + setState({ kind: 'error', error: formatSageError(err) }); + } + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, [sage]); + + useEffect(() => reload(), [reload]); + + return { state, reload }; +} diff --git a/builtin-apps/src/system/apps/app-update/src/hooks/useUpdatePermissions.ts b/builtin-apps/src/system/apps/app-update/src/hooks/useUpdatePermissions.ts new file mode 100644 index 000000000..4881e8a97 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/hooks/useUpdatePermissions.ts @@ -0,0 +1,47 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { + AppUpdateReviewContext, + SageAppCapabilityDefinitionView, + SageGrantedPermissionsInput, + UserSageAppView, +} from '@sage-system-app/sdk'; +import { definitionMap } from '../utils/definitions'; +import { nextPermissionsForUpdate } from '../utils/permissions'; + +export function useUpdatePermissions(args: { + app: UserSageAppView; + context: AppUpdateReviewContext | null; + definitions: SageAppCapabilityDefinitionView[]; +}) { + const { app, context, definitions } = args; + + const [grantedPermissions, setGrantedPermissions] = + useState(app.common.grantedPermissions); + + const definitionsByKey = useMemo( + () => definitionMap(definitions), + [definitions], + ); + + useEffect(() => { + if (!context) { + setGrantedPermissions(app.common.grantedPermissions); + return; + } + + const next = nextPermissionsForUpdate({ + app, + context, + definitionsByKey, + }); + + if (next) { + setGrantedPermissions(next); + } + }, [app, context, definitionsByKey]); + + return { + grantedPermissions, + setGrantedPermissions, + }; +} diff --git a/builtin-apps/src/system/apps/app-update/src/main.tsx b/builtin-apps/src/system/apps/app-update/src/main.tsx new file mode 100644 index 000000000..ed0dc6330 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/builtin-apps/src/system/apps/app-update/src/styles.css b/builtin-apps/src/system/apps/app-update/src/styles.css new file mode 100644 index 000000000..ad480ae12 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/styles.css @@ -0,0 +1,19 @@ +@import "@sage-app/sdk/theme.css"; +@config "../tailwind.config.js"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: transparent; +} diff --git a/builtin-apps/src/system/apps/app-update/src/types.ts b/builtin-apps/src/system/apps/app-update/src/types.ts new file mode 100644 index 000000000..98d0caeed --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/types.ts @@ -0,0 +1,22 @@ +import type { + AppPermissionsReviewContext, + AppUpdateReviewContext, + SageAppCapabilityDefinitionView, + SystemWalletView, + UserSageAppView, +} from '@sage-system-app/sdk'; + +export type Mode = 'review-update' | 'review-permissions'; + +export type LoadState = + | { kind: 'loading' } + | { kind: 'error'; error: string } + | { + kind: 'ready'; + mode: Mode; + app: UserSageAppView; + updateContext: AppUpdateReviewContext | null; + permissionsContext: AppPermissionsReviewContext | null; + definitions: SageAppCapabilityDefinitionView[]; + wallets: SystemWalletView[]; + }; diff --git a/builtin-apps/src/system/apps/app-update/src/utils/definitions.ts b/builtin-apps/src/system/apps/app-update/src/utils/definitions.ts new file mode 100644 index 000000000..ae91ffb63 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/utils/definitions.ts @@ -0,0 +1,15 @@ +import type { + SageAppCapabilityDefinitionView, + UserBridgeCapability, +} from '@sage-system-app/sdk'; + +export function definitionMap(definitions: SageAppCapabilityDefinitionView[]) { + return new Map(definitions.map((d) => [d.key as UserBridgeCapability, d])); +} + +export function isUserGrantable( + definitions: Map, + capability: UserBridgeCapability, +) { + return definitions.get(capability)?.flags.userGrantable === true; +} diff --git a/builtin-apps/src/system/apps/app-update/src/utils/permissions.ts b/builtin-apps/src/system/apps/app-update/src/utils/permissions.ts new file mode 100644 index 000000000..56060af56 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/src/utils/permissions.ts @@ -0,0 +1,120 @@ +import type { + SageGrantedPermissionsView, + SageNetworkWhitelistEntry, + UserBridgeCapability, +} from '@sage-system-app/sdk'; +import { isUserGrantable } from './definitions'; + +type RequestedWhitelistByNetwork = Record< + string, + { + required?: SageNetworkWhitelistEntry[]; + optional?: SageNetworkWhitelistEntry[]; + } +>; + +function networkKey(entry: SageNetworkWhitelistEntry) { + return `${entry.scheme}://${entry.host}`; +} + +function sortCapabilities(values: Iterable) { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} + +function sortNetwork(values: Iterable) { + return [...values].sort((a, b) => networkKey(a).localeCompare(networkKey(b))); +} + +export function nextPermissionsForUpdate(args: { + app: any; + context: any; + definitionsByKey: Map; +}): SageGrantedPermissionsView | null { + if (!args.context.preview || args.context.preview.manifest.kind !== 'full') { + return null; + } + + const nextRequested = args.context.preview.manifest.manifest.permissions; + + const nextCaps = { + required: nextRequested.capabilities.required ?? [], + optional: nextRequested.capabilities.optional ?? [], + }; + + const nextNetwork = { + required: nextRequested.network.whitelist.required ?? [], + optional: nextRequested.network.whitelist.optional ?? [], + byNetwork: (nextRequested.network.whitelistByNetwork ?? + {}) as RequestedWhitelistByNetwork, + }; + + const nextAllowedCaps = new Set([...nextCaps.required, ...nextCaps.optional]); + const nextAllowedNetwork = new Set([ + ...nextNetwork.required.map(networkKey), + ...nextNetwork.optional.map(networkKey), + ]); + + const retainedCapabilities = ( + args.app.common.grantedPermissions.capabilities ?? [] + ) + .filter((c: UserBridgeCapability) => nextAllowedCaps.has(c)) + .filter((c: UserBridgeCapability) => + isUserGrantable(args.definitionsByKey, c), + ); + + const requiredGrantable = nextCaps.required.filter( + (c: UserBridgeCapability) => isUserGrantable(args.definitionsByKey, c), + ); + + const retainedNetwork = ( + args.app.common.grantedPermissions.network.whitelist ?? [] + ).filter((e: SageNetworkWhitelistEntry) => + nextAllowedNetwork.has(networkKey(e)), + ); + + const networkMap = new Map(); + + for (const e of retainedNetwork) networkMap.set(networkKey(e), e); + for (const e of nextNetwork.required) networkMap.set(networkKey(e), e); + + const whitelistByNetwork: Record = {}; + + for (const [networkId, requestedWhitelist] of Object.entries( + nextNetwork.byNetwork, + )) { + const allowedNetworkKeys = new Set([ + ...(requestedWhitelist.required ?? []).map(networkKey), + ...(requestedWhitelist.optional ?? []).map(networkKey), + ]); + + const retained = ( + args.app.common.grantedPermissions.network.whitelistByNetwork?.[ + networkId + ] ?? [] + ).filter((entry: SageNetworkWhitelistEntry) => + allowedNetworkKeys.has(networkKey(entry)), + ); + + const entries = new Map(); + + for (const entry of retained) entries.set(networkKey(entry), entry); + for (const entry of requestedWhitelist.required ?? []) { + entries.set(networkKey(entry), entry); + } + + if (entries.size > 0) { + whitelistByNetwork[networkId] = sortNetwork(entries.values()); + } + } + + return { + capabilities: sortCapabilities([ + ...retainedCapabilities, + ...requiredGrantable, + ]), + network: { + whitelist: sortNetwork(networkMap.values()), + whitelistByNetwork, + }, + }; +} diff --git a/builtin-apps/src/system/apps/app-update/tailwind.config.js b/builtin-apps/src/system/apps/app-update/tailwind.config.js new file mode 100644 index 000000000..13edcb743 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/tailwind.config.js @@ -0,0 +1,14 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSageTailwindConfig } from '../../tailwind.shared.js'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(dir, '../../../../..'); + +export default createSageTailwindConfig({ + content: [ + join(dir, 'index.html'), + join(dir, 'src/**/*.{ts,tsx}'), + join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'), + ], +}); diff --git a/builtin-apps/src/system/apps/app-update/tsconfig.json b/builtin-apps/src/system/apps/app-update/tsconfig.json new file mode 100644 index 000000000..3d61827d7 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/builtin-apps/src/system/apps/app-update/vite.config.ts b/builtin-apps/src/system/apps/app-update/vite.config.ts new file mode 100644 index 000000000..c2498e6d6 --- /dev/null +++ b/builtin-apps/src/system/apps/app-update/vite.config.ts @@ -0,0 +1,3 @@ +import { defineSageSystemAppConfig } from '../../vite.system-app.config'; + +export default defineSageSystemAppConfig(import.meta.url); diff --git a/builtin-apps/src/system/apps/bridge-approval/index.html b/builtin-apps/src/system/apps/bridge-approval/index.html new file mode 100644 index 000000000..02397c5e0 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/index.html @@ -0,0 +1,12 @@ + + + + + + Bridge Approval + + +
+ + + diff --git a/builtin-apps/src/system/apps/bridge-approval/package.json b/builtin-apps/src/system/apps/bridge-approval/package.json new file mode 100644 index 000000000..43ed1fc5b --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/package.json @@ -0,0 +1,8 @@ +{ + "name": "bridge-approval-system-app", + "private": true, + "type": "module", + "dependencies": { + "@sage-system-app/sdk": "workspace:*" + } +} diff --git a/builtin-apps/src/system/apps/bridge-approval/postcss.config.js b/builtin-apps/src/system/apps/bridge-approval/postcss.config.js new file mode 100644 index 000000000..c97c6b959 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/postcss.config.js @@ -0,0 +1,13 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + tailwindcss: { + config: join(dir, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +}; diff --git a/builtin-apps/src/system/apps/bridge-approval/sage-manifest.json b/builtin-apps/src/system/apps/bridge-approval/sage-manifest.json new file mode 100644 index 000000000..0506163a8 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/sage-manifest.json @@ -0,0 +1,26 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0" + }, + "name": "Bridge Approval", + "version": "0.1.0", + "permissions": { + "network": { + "whitelist": { + "required": [], + "optional": [] + } + }, + "capabilities": { + "required": [ + "environment.theme.get_current", + "environment.theme.listen_changed", + "environment.theme.css_vars" + ], + "optional": [] + } + }, + "files": [], + "entry": "index.html" +} diff --git a/builtin-apps/src/system/apps/bridge-approval/src/App.tsx b/builtin-apps/src/system/apps/bridge-approval/src/App.tsx new file mode 100644 index 000000000..8f9c88223 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/App.tsx @@ -0,0 +1,277 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { AppIcon, appIconFromCommonView, AppModalShell } from '@sage-app/ui'; +import { + useSageSystemClient, + type PendingBridgeApprovalView, + type SageAppRuntimeRecordView, +} from '@sage-system-app/sdk'; +import { Clock } from 'lucide-react'; +import { AppApprovalBody } from './approval/AppApprovalBody'; + +function appNameFromRuntime( + runtime: SageAppRuntimeRecordView | null, +): string | null { + return runtime?.app.common.activeSnapshot.manifest.name ?? null; +} + +function appIconFromRuntime( + runtime: SageAppRuntimeRecordView | null, +): AppIcon | null { + if (!runtime) return null; + return appIconFromCommonView(runtime.app.common); +} + +function appIdFromRuntime( + runtime: SageAppRuntimeRecordView | null, +): string | null { + return runtime?.app.common.identity.id ?? null; +} + +function titleForApproval(approval: PendingBridgeApprovalView) { + switch (approval.approval.kind) { + case 'sendXch': + return 'Approve XCH transaction'; + case 'getSecretKey': + return 'Approve secret key access'; + case 'capabilityGrant': + return 'Approve permission grant'; + case 'networkWhitelistGrant': + return 'Approve network access'; + } +} + +function formatCountdown(expiresAt: number, now: number) { + const seconds = Math.max(0, Math.ceil((expiresAt - now) / 1000)); + return seconds <= 0 ? 'Expires now' : `Expires in ${seconds}s`; +} + +function queueText(count: number) { + if (count <= 0) return null; + return `+${count} pending`; +} + +function MetaPill({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function App() { + const sage = useSageSystemClient(); + + const [approvals, setApprovals] = useState([]); + const [activeAppId, setActiveAppId] = useState(null); + const [activeAppName, setActiveAppName] = useState(null); + const [activeAppIcon, setActiveAppIcon] = useState(null); + const [expanded, setExpanded] = useState(false); + const [working, setWorking] = useState(false); + const [loaded, setLoaded] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const [error, setError] = useState(null); + + async function refreshActiveRuntime() { + const active = await sage.runtimeManager.getActiveTaskbarRuntime(); + + setActiveAppId(appIdFromRuntime(active)); + setActiveAppName(appNameFromRuntime(active)); + setActiveAppIcon(appIconFromRuntime(active)); + + return active; + } + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 250); + return () => window.clearInterval(id); + }, []); + + useEffect(() => { + async function refreshInitialState() { + try { + const [pending, active] = await Promise.all([ + sage.bridgeApprovals.listPending(), + sage.runtimeManager.getActiveTaskbarRuntime(), + ]); + + setApprovals(pending); + setActiveAppId(appIdFromRuntime(active)); + setActiveAppName(appNameFromRuntime(active)); + setActiveAppIcon(appIconFromRuntime(active)); + } catch (err) { + console.error('[approval] refreshInitialState failed', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoaded(true); + } + } + + void refreshInitialState(); + }, [sage]); + + useEffect(() => { + const offApprovals = sage.bridgeApprovals.onChanged((event) => { + setApprovals(event.approvals); + }); + + const offActiveRuntime = sage.runtimeManager.onActiveTaskbarRuntimeChanged( + () => { + void refreshActiveRuntime().catch((err) => { + console.error('[approval] failed to refresh active runtime', err); + }); + }, + ); + + return () => { + offApprovals(); + offActiveRuntime(); + }; + }, [sage]); + + const activeApprovals = useMemo(() => { + if (!activeAppId) return []; + + return approvals + .filter((item) => item.appId === activeAppId) + .sort((a, b) => a.expiresAtMs - b.expiresAtMs); + }, [approvals, activeAppId]); + + const activeApproval = activeApprovals[0] ?? null; + const pendingForActiveAppCount = activeApprovals.length; + const queuedApprovalCount = Math.max(0, pendingForActiveAppCount - 1); + + const countdownText = activeApproval + ? formatCountdown(activeApproval.expiresAtMs, now) + : null; + + useEffect(() => { + setExpanded(false); + setError(null); + }, [activeApproval?.approvalId]); + + async function resolve(approved: boolean) { + if (!activeApproval || working) return; + + setWorking(true); + setError(null); + + try { + await sage.bridgeApprovals.resolve({ + approvalId: activeApproval.approvalId, + approved, + reason: approved ? null : 'User denied the request', + }); + + setApprovals(await sage.bridgeApprovals.listPending()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setWorking(false); + } + } + + if (!loaded) { + return null; + } + + if (!activeApproval) { + return ( + + + + } + > +
+ There are no approval requests to review. +
+
+ ); + } + + if (!activeAppId || !activeAppName) { + return ( + +
+ Approval exists, but no active taskbar app was resolved. +
+
+ ); + } + + const moreText = queueText(queuedApprovalCount); + + return ( + +
+ + + +
+ + } + > +
+
+
Approval required
+ + {countdownText ? ( + + + {countdownText} + + ) : null} + + {moreText ? {moreText} : null} +
+ + + + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/AppApprovalBody.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/AppApprovalBody.tsx new file mode 100644 index 000000000..42b69e321 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/AppApprovalBody.tsx @@ -0,0 +1,51 @@ +import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk'; +import { CapabilityGrantApprovalCard } from './CapabilityGrantApprovalCard'; +import { GetSecretKeyApprovalCard } from './GetSecretKeyApprovalCard'; +import { NetworkWhitelistGrantApprovalCard } from './NetworkWhitelistGrantApprovalCard'; +import { SendXchApprovalCard } from './SendXchApprovalCard'; + +interface Props { + approval: RustBridgeApprovalRequest; + appName: string; + expanded: boolean; +} + +export function AppApprovalBody({ approval, appName, expanded }: Props) { + switch (approval.kind) { + case 'getSecretKey': + return ( + + ); + + case 'sendXch': + return ( + + ); + + case 'capabilityGrant': + return ( + + ); + + case 'networkWhitelistGrant': + return ( + + ); + } +} diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/CapabilityGrantApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/CapabilityGrantApprovalCard.tsx new file mode 100644 index 000000000..7a9026a29 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/CapabilityGrantApprovalCard.tsx @@ -0,0 +1,38 @@ +import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk'; +import { ApprovalDetailRow } from './shared'; + +interface Props { + approval: Extract; + appName: string; + expanded: boolean; +} + +export function CapabilityGrantApprovalCard({ + approval, + appName, + expanded, +}: Props) { + const label = approval.definition.label; + const description = approval.definition.description; + + return ( +
+
{label}
+ +
+ {description ?? `${appName} wants access to this permission.`} +
+ + {expanded ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/GetSecretKeyApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/GetSecretKeyApprovalCard.tsx new file mode 100644 index 000000000..1b0598e80 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/GetSecretKeyApprovalCard.tsx @@ -0,0 +1,38 @@ +import { Wallet } from 'lucide-react'; +import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk'; +import { ApprovalDetailRow, ApprovalMetaPill } from './shared'; + +interface Props { + approval: Extract; + appName: string; + expanded: boolean; +} + +export function GetSecretKeyApprovalCard({ approval, appName }: Props) { + const fingerprint = approval.fingerprint; + + return ( +
+
+
+ +
+ +
+
+
Get Secret Key
+ Wallet +
+ +
+ {appName} wants to get your secret key. +
+
+
+ +
+ +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/NetworkWhitelistGrantApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/NetworkWhitelistGrantApprovalCard.tsx new file mode 100644 index 000000000..2ab0bfb7a --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/NetworkWhitelistGrantApprovalCard.tsx @@ -0,0 +1,24 @@ +import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk'; + +interface Props { + approval: Extract< + RustBridgeApprovalRequest, + { kind: 'networkWhitelistGrant' } + >; + appName: string; + expanded: boolean; +} + +export function NetworkWhitelistGrantApprovalCard({ approval }: Props) { + const target = `${approval.entry.scheme}://${approval.entry.host}`; + + return ( +
+
Network access
+ +
+ {target} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/bridge-approval/src/approval/SendXchApprovalCard.tsx b/builtin-apps/src/system/apps/bridge-approval/src/approval/SendXchApprovalCard.tsx new file mode 100644 index 000000000..2153bbd23 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/approval/SendXchApprovalCard.tsx @@ -0,0 +1,101 @@ +import { Wallet } from 'lucide-react'; +import type { RustBridgeApprovalRequest } from '@sage-system-app/sdk'; +import { ApprovalDetailRow, ApprovalMetaPill } from './shared'; + +interface Props { + approval: Extract; + appName: string; + expanded: boolean; +} + +function truncateMiddle(value: string, maxLength = 120) { + if (value.length <= maxLength) { + return value; + } + + const head = Math.ceil((maxLength - 1) / 2); + const tail = Math.floor((maxLength - 1) / 2); + return `${value.slice(0, head)}…${value.slice(value.length - tail)}`; +} + +function memoKey(memo: string, indexWithinSameValue: number) { + return `${memo}::${indexWithinSameValue}`; +} + +export function SendXchApprovalCard({ approval, appName, expanded }: Props) { + const summary = approval.summary; + + const hasFee = summary.fee !== '0'; + const memos = summary.memos ?? []; + const hasMemos = memos.length > 0; + + const memoEntries = memos.map( + (memo: string, index: number, all: string[]) => { + const duplicateIndex = all + .slice(0, index) + .filter((previous: string) => previous === memo).length; + + return { + key: memoKey(memo, duplicateIndex), + value: memo, + }; + }, + ); + + return ( +
+
+
+ +
+ +
+
+
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..ad480ae12 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/src/styles.css @@ -0,0 +1,19 @@ +@import "@sage-app/sdk/theme.css"; +@config "../tailwind.config.js"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: transparent; +} diff --git a/builtin-apps/src/system/apps/bridge-approval/tailwind.config.js b/builtin-apps/src/system/apps/bridge-approval/tailwind.config.js new file mode 100644 index 000000000..13edcb743 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/tailwind.config.js @@ -0,0 +1,14 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSageTailwindConfig } from '../../tailwind.shared.js'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(dir, '../../../../..'); + +export default createSageTailwindConfig({ + content: [ + join(dir, 'index.html'), + join(dir, 'src/**/*.{ts,tsx}'), + join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'), + ], +}); diff --git a/builtin-apps/src/system/apps/bridge-approval/tsconfig.json b/builtin-apps/src/system/apps/bridge-approval/tsconfig.json new file mode 100644 index 000000000..3d61827d7 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/builtin-apps/src/system/apps/bridge-approval/vite.config.ts b/builtin-apps/src/system/apps/bridge-approval/vite.config.ts new file mode 100644 index 000000000..c2498e6d6 --- /dev/null +++ b/builtin-apps/src/system/apps/bridge-approval/vite.config.ts @@ -0,0 +1,3 @@ +import { defineSageSystemAppConfig } from '../../vite.system-app.config'; + +export default defineSageSystemAppConfig(import.meta.url); diff --git a/builtin-apps/src/system/apps/donation/index.html b/builtin-apps/src/system/apps/donation/index.html new file mode 100644 index 000000000..1ad69eba6 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/index.html @@ -0,0 +1,12 @@ + + + + + + Donation + + +
+ + + diff --git a/builtin-apps/src/system/apps/donation/package.json b/builtin-apps/src/system/apps/donation/package.json new file mode 100644 index 000000000..43ed1fc5b --- /dev/null +++ b/builtin-apps/src/system/apps/donation/package.json @@ -0,0 +1,8 @@ +{ + "name": "bridge-approval-system-app", + "private": true, + "type": "module", + "dependencies": { + "@sage-system-app/sdk": "workspace:*" + } +} diff --git a/builtin-apps/src/system/apps/donation/postcss.config.js b/builtin-apps/src/system/apps/donation/postcss.config.js new file mode 100644 index 000000000..c97c6b959 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/postcss.config.js @@ -0,0 +1,13 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + tailwindcss: { + config: join(dir, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +}; diff --git a/builtin-apps/src/system/apps/donation/sage-manifest.json b/builtin-apps/src/system/apps/donation/sage-manifest.json new file mode 100644 index 000000000..602f49082 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/sage-manifest.json @@ -0,0 +1,28 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0" + }, + "name": "Donation", + "version": "0.1.0", + "permissions": { + "network": { + "whitelist": { + "required": [], + "optional": [] + } + }, + "capabilities": { + "required": [ + "environment.theme.get_current", + "environment.theme.listen_changed", + "environment.theme.css_vars", + "wallet.get_xch_usd_price", + "wallet.send_xch" + ], + "optional": [] + } + }, + "files": [], + "entry": "index.html" +} diff --git a/builtin-apps/src/system/apps/donation/src/App.tsx b/builtin-apps/src/system/apps/donation/src/App.tsx new file mode 100644 index 000000000..fbc667fec --- /dev/null +++ b/builtin-apps/src/system/apps/donation/src/App.tsx @@ -0,0 +1,289 @@ +import { AppModalShell } from '@sage-app/ui'; +import { + formatSageError, + useSageSystemClient, + type DonationDetails, +} from '@sage-system-app/sdk'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AmountPicker } from './components/AmountPicker'; +import { DeveloperCard } from './components/DeveloperCard'; +import { FeeInput } from './components/FeeInput'; +import { + appIconFromInline, + DEFAULT_FEE_XCH, + DEFAULT_USD, + DEFAULT_XCH, + getTargetAppId, + parsePositiveNumber, + type DonationMode, + xchToMojos, +} from './utils'; + +export function App() { + const sage = useSageSystemClient(); + + const [details, setDetails] = useState(null); + const [mode, setMode] = useState('usd'); + const [usdInput, setUsdInput] = useState(DEFAULT_USD); + const [xchInput, setXchInput] = useState(DEFAULT_XCH); + const [feeInput, setFeeInput] = useState(DEFAULT_FEE_XCH); + const [priceUsd, setPriceUsd] = useState(null); + const [priceLoading, setPriceLoading] = useState(true); + const [sending, setSending] = useState(false); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(null); + + const targetAppId = getTargetAppId(); + + const loadPrice = useCallback(async () => { + try { + setPriceLoading(true); + const price = await sage.wallet.getXchUsdPrice(); + + setPriceUsd(price.usd); + } catch { + setPriceUsd(null); + setMode('xch'); + } finally { + setPriceLoading(false); + } + }, [sage]); + + useEffect(() => { + let disposed = false; + + async function load() { + try { + if (!targetAppId) { + throw new Error('Missing donation target app id.'); + } + + const next = await sage.donations.getDetails({ appId: targetAppId }); + + if (!disposed) { + setDetails(next); + } + } catch (err) { + if (!disposed) { + setError(formatSageError(err)); + } + } finally { + if (!disposed) { + setLoaded(true); + } + } + } + + void load(); + + return () => { + disposed = true; + }; + }, [sage, targetAppId]); + + useEffect(() => { + let disposed = false; + + async function load() { + try { + setPriceLoading(true); + const price = await sage.wallet.getXchUsdPrice(); + + if (!disposed) { + setPriceUsd(price.usd); + } + } catch { + if (!disposed) { + setPriceUsd(null); + setMode('xch'); + } + } finally { + if (!disposed) { + setPriceLoading(false); + } + } + } + + void load(); + + return () => { + disposed = true; + }; + }, [sage]); + + const derived = useMemo(() => { + if (mode === 'usd') { + const usd = parsePositiveNumber(usdInput); + + if (!usd || !priceUsd) { + return { + usd, + xch: null as number | null, + mojos: null as string | null, + }; + } + + const xch = usd / priceUsd; + + return { + usd, + xch, + mojos: xchToMojos(xch), + }; + } + + const xch = parsePositiveNumber(xchInput); + + if (!xch) { + return { + usd: null as number | null, + xch, + mojos: null as string | null, + }; + } + + return { + usd: priceUsd ? xch * priceUsd : null, + xch, + mojos: xchToMojos(xch), + }; + }, [mode, usdInput, xchInput, priceUsd]); + + const feeMojos = useMemo(() => { + const trimmed = feeInput.trim(); + + if (trimmed === '' || trimmed === '0') { + return '0'; + } + + const feeXch = parsePositiveNumber(trimmed); + return feeXch ? xchToMojos(feeXch) : null; + }, [feeInput]); + + const canSend = + !!details?.donationAddress && + !!derived.mojos && + feeMojos !== null && + !sending; + + async function sendDonation() { + if ( + !canSend || + !details?.donationAddress || + !derived.mojos || + feeMojos === null + ) { + return; + } + + setSending(true); + setError(null); + + try { + await sage.wallet.sendXch({ + address: details.donationAddress, + amount: derived.mojos, + fee: feeMojos, + memos: [], + }); + + await sage.runtimeManager.closeSelf(); + } catch (err) { + setError(formatSageError(err)); + } finally { + setSending(false); + } + } + + if (!loaded) { + return ( + +
Loading donation…
+
+ ); + } + + if (!details) { + return ( + + + + } + > +
+ {error ?? 'This app does not have a donation address.'} +
+
+ ); + } + + return ( + + + + + + } + > +
+ + + void loadPrice()} + derivedXch={derived.xch} + derivedUsd={derived.usd} + /> + + + + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/donation/src/components/AmountPicker.tsx b/builtin-apps/src/system/apps/donation/src/components/AmountPicker.tsx new file mode 100644 index 000000000..29243f817 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/src/components/AmountPicker.tsx @@ -0,0 +1,192 @@ +import { RefreshCw } from 'lucide-react'; +import { useMemo } from 'react'; +import type { DonationMode } from '../utils'; +import { formatXchAmount, USD_PRESETS } from '../utils'; + +interface Props { + mode: DonationMode; + setMode: (mode: DonationMode) => void; + usdInput: string; + setUsdInput: (value: string) => void; + xchInput: string; + setXchInput: (value: string) => void; + priceUsd: number | null; + priceLoading: boolean; + onRefreshPrice: () => void; + derivedXch: number | null; + derivedUsd: number | null; +} + +function numericInputEquals(a: string, b: string): boolean { + const aNumber = Number(a.trim()); + const bNumber = Number(b.trim()); + + return ( + a.trim() !== '' && + b.trim() !== '' && + Number.isFinite(aNumber) && + Number.isFinite(bNumber) && + aNumber === bNumber + ); +} + +export function AmountPicker({ + mode, + setMode, + usdInput, + setUsdInput, + xchInput, + setXchInput, + priceUsd, + priceLoading, + onRefreshPrice, + derivedXch, + derivedUsd, +}: Props) { + const presets = useMemo(() => { + if (mode === 'usd') { + return USD_PRESETS.map((usd) => ({ + key: `usd-${usd}`, + label: `$${usd}`, + value: String(usd), + disabled: false, + })); + } + + return USD_PRESETS.map((usd) => { + const xch = priceUsd ? usd / priceUsd : null; + const formatted = xch ? formatXchAmount(xch) : null; + + return { + key: `xch-${usd}`, + label: formatted ? `${formatted} XCH` : `$${usd}`, + value: formatted ?? '', + disabled: !formatted, + }; + }); + }, [mode, priceUsd]); + + const showPresets = mode === 'usd' || priceUsd !== null; + const activeInput = mode === 'usd' ? usdInput : xchInput; + + return ( +
+
+
Amount
+ +
+ {priceLoading ? ( + 'Loading XCH price…' + ) : priceUsd !== null ? ( + `1 XCH ≈ $${priceUsd.toFixed(2)}` + ) : ( + <> + XCH price unavailable + + + )} +
+
+ + {showPresets ? ( +
+ {presets.map((preset) => { + const selected = + !preset.disabled && numericInputEquals(activeInput, preset.value); + + return ( + + ); + })} +
+ ) : null} + +
+
+ + + +
+ +
+ { + if (mode === 'usd') { + setUsdInput(event.target.value); + } else { + setXchInput(event.target.value); + } + }} + placeholder={mode === 'usd' ? '10.00' : '0.025'} + inputMode='decimal' + className='min-w-0 flex-1 bg-transparent text-right text-sm outline-none' + /> + + {mode === 'usd' ? 'USD' : 'XCH'} + +
+
+ +
+ {mode === 'usd' + ? derivedXch !== null + ? `≈ ${formatXchAmount(derivedXch)} XCH` + : '—' + : derivedUsd !== null + ? `≈ $${derivedUsd.toFixed(2)}` + : '—'} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/donation/src/components/DeveloperCard.tsx b/builtin-apps/src/system/apps/donation/src/components/DeveloperCard.tsx new file mode 100644 index 000000000..dfa7b0177 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/src/components/DeveloperCard.tsx @@ -0,0 +1,31 @@ +import type { DonationDetails } from '@sage-system-app/sdk'; +import { inlineImageSrc } from '../utils'; + +export function DeveloperCard({ details }: { details: DonationDetails }) { + const authorAvatarSrc = inlineImageSrc(details.authorAvatar); + + return ( +
+ {authorAvatarSrc ? ( + + ) : ( +
+ {(details.authorName ?? details.appName).slice(0, 1).toUpperCase()} +
+ )} + +
+
+ Support {details.authorName ?? details.appName} +
+
+ Developer of {details.appName} +
+
+
+ ); +} diff --git a/builtin-apps/src/system/apps/donation/src/components/FeeInput.tsx b/builtin-apps/src/system/apps/donation/src/components/FeeInput.tsx new file mode 100644 index 000000000..c42dcb57b --- /dev/null +++ b/builtin-apps/src/system/apps/donation/src/components/FeeInput.tsx @@ -0,0 +1,54 @@ +import { ChevronDown } from 'lucide-react'; +import { useState } from 'react'; + +interface Props { + value: string; + onChange: (value: string) => void; +} + +export function FeeInput({ value, onChange }: Props) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + + {expanded ? ( + + ) : null} +
+ ); +} diff --git a/builtin-apps/src/system/apps/donation/src/main.tsx b/builtin-apps/src/system/apps/donation/src/main.tsx new file mode 100644 index 000000000..cb0593d81 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/src/main.tsx @@ -0,0 +1,12 @@ +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + Loading approval...}> + + + , +); diff --git a/builtin-apps/src/system/apps/donation/src/styles.css b/builtin-apps/src/system/apps/donation/src/styles.css new file mode 100644 index 000000000..ad480ae12 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/src/styles.css @@ -0,0 +1,19 @@ +@import "@sage-app/sdk/theme.css"; +@config "../tailwind.config.js"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: transparent; +} diff --git a/builtin-apps/src/system/apps/donation/src/utils.ts b/builtin-apps/src/system/apps/donation/src/utils.ts new file mode 100644 index 000000000..e777d5b31 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/src/utils.ts @@ -0,0 +1,73 @@ +import type { AppIcon } from '@sage-app/ui'; +import type { SageAppIconView } from '@sage-system-app/sdk'; + +export type DonationMode = 'usd' | 'xch'; + +export const DEFAULT_USD = '10'; +export const DEFAULT_XCH = '0.05'; +export const DEFAULT_FEE_XCH = '0.0001'; +export const USD_PRESETS = [2, 5, 10, 20]; + +export function getTargetAppId() { + return new URL(window.location.href).searchParams.get('appId'); +} + +export function xchToMojos(xch: number): string { + return String(Math.floor(xch * 1_000_000_000_000)); +} + +export function parsePositiveNumber(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +export function formatXchAmount(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0'; + + for (let decimals = 0; decimals <= 12; decimals++) { + const rounded = value.toFixed(decimals); + const roundedNumber = Number(rounded); + + if (roundedNumber <= 0) continue; + + const relativeError = Math.abs(roundedNumber - value) / value; + + if (relativeError < 0.0005) { + return rounded.replace(/\.?0+$/, ''); + } + } + + return value.toFixed(12).replace(/\.?0+$/, ''); +} + +export function appIconFromInline( + icon: SageAppIconView | null | undefined, +): AppIcon | null { + if (!icon) return null; + + return { + kind: 'bytes', + icon: { + bytes: icon.bytes, + mime: icon.mime, + }, + }; +} + +export function inlineImageSrc( + icon: SageAppIconView | null | undefined, +): string | null { + if (!icon) return null; + + const bytes = new Uint8Array(icon.bytes); + let binary = ''; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return `data:${icon.mime};base64,${btoa(binary)}`; +} diff --git a/builtin-apps/src/system/apps/donation/tailwind.config.js b/builtin-apps/src/system/apps/donation/tailwind.config.js new file mode 100644 index 000000000..13edcb743 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/tailwind.config.js @@ -0,0 +1,14 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSageTailwindConfig } from '../../tailwind.shared.js'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(dir, '../../../../..'); + +export default createSageTailwindConfig({ + content: [ + join(dir, 'index.html'), + join(dir, 'src/**/*.{ts,tsx}'), + join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'), + ], +}); diff --git a/builtin-apps/src/system/apps/donation/tsconfig.json b/builtin-apps/src/system/apps/donation/tsconfig.json new file mode 100644 index 000000000..3d61827d7 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/builtin-apps/src/system/apps/donation/vite.config.ts b/builtin-apps/src/system/apps/donation/vite.config.ts new file mode 100644 index 000000000..c2498e6d6 --- /dev/null +++ b/builtin-apps/src/system/apps/donation/vite.config.ts @@ -0,0 +1,3 @@ +import { defineSageSystemAppConfig } from '../../vite.system-app.config'; + +export default defineSageSystemAppConfig(import.meta.url); diff --git a/builtin-apps/src/system/apps/sandbox-tests/index.html b/builtin-apps/src/system/apps/sandbox-tests/index.html new file mode 100644 index 000000000..2d6a74f26 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/index.html @@ -0,0 +1,12 @@ + + + + + + Sandbox Tests + + +
+ + + diff --git a/builtin-apps/src/system/apps/sandbox-tests/package.json b/builtin-apps/src/system/apps/sandbox-tests/package.json new file mode 100644 index 000000000..43ed1fc5b --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/package.json @@ -0,0 +1,8 @@ +{ + "name": "bridge-approval-system-app", + "private": true, + "type": "module", + "dependencies": { + "@sage-system-app/sdk": "workspace:*" + } +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/postcss.config.js b/builtin-apps/src/system/apps/sandbox-tests/postcss.config.js new file mode 100644 index 000000000..c97c6b959 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/postcss.config.js @@ -0,0 +1,13 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + tailwindcss: { + config: join(dir, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +}; diff --git a/builtin-apps/src/system/apps/sandbox-tests/public/icon.svg b/builtin-apps/src/system/apps/sandbox-tests/public/icon.svg new file mode 100644 index 000000000..d61a27e08 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/public/icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/builtin-apps/src/system/apps/sandbox-tests/sage-manifest.json b/builtin-apps/src/system/apps/sandbox-tests/sage-manifest.json new file mode 100644 index 000000000..7a927399a --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/sage-manifest.json @@ -0,0 +1,27 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0" + }, + "name": "Sandbox Tests", + "icon": "icon.svg", + "version": "0.1.0", + "permissions": { + "network": { + "whitelist": { + "required": [], + "optional": [] + } + }, + "capabilities": { + "required": [ + "environment.theme.get_current", + "environment.theme.listen_changed", + "environment.theme.css_vars" + ], + "optional": [] + } + }, + "files": [], + "entry": "index.html" +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/App.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/App.tsx new file mode 100644 index 000000000..a9cfe0b2d --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/src/App.tsx @@ -0,0 +1,170 @@ +import { AppIcon, AppModalShell } from '@sage-app/ui'; +import { formatSageError } from '@sage-system-app/sdk'; +import { useEffect, useMemo, useState } from 'react'; +import { + closeSelf, + getSandboxState, + onSandboxStateChanged, + rerunSandboxTests, + type SandboxStateView, +} from './sandboxApi'; +import { SandboxResultList } from './components/SandboxResultList'; +import { SandboxTabs } from './components/SandboxTabs'; +import { + isCurrentSandboxRunActive, + selectedSandboxState, + type SandboxTab, +} from './sandboxState'; + +const appIcon: AppIcon = { + kind: 'url', + iconUrl: '/icon.svg', +}; + +function emptyTextForTab(tab: SandboxTab) { + switch (tab) { + case 'effective': + return 'No effective sandbox gate state is available yet.'; + case 'previous': + return 'No completed sandbox test run is available yet.'; + case 'current': + return 'No sandbox test run is currently active.'; + } +} + +export function App() { + const [state, setState] = useState(null); + const [activeTab, setActiveTab] = useState('effective'); + const [loaded, setLoaded] = useState(false); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + + const currentRunning = isCurrentSandboxRunActive(state); + + const selectedState = useMemo( + () => selectedSandboxState(state, activeTab), + [state, activeTab], + ); + + useEffect(() => { + let disposed = false; + + async function load() { + try { + const next = await getSandboxState(); + + if (!disposed) { + setState(next); + } + } catch (err) { + if (!disposed) { + setError(formatSageError(err)); + } + } finally { + if (!disposed) { + setLoaded(true); + } + } + } + + void load(); + + const off = onSandboxStateChanged((next) => { + setState(next); + + const stillRunning = isCurrentSandboxRunActive(next); + + if (!stillRunning) { + setRunning(false); + + setActiveTab((prev) => (prev === 'current' ? 'previous' : prev)); + } + }); + + return () => { + disposed = true; + off(); + }; + }, []); + + async function rerun() { + setRunning(true); + setError(null); + setActiveTab('current'); + + try { + const next = await rerunSandboxTests(); + setState(next); + + if (!isCurrentSandboxRunActive(next)) { + setRunning(false); + setActiveTab('previous'); + } + } catch (err) { + setRunning(false); + setError(formatSageError(err)); + } + } + + if (!loaded) { + return ( + +
+ Loading sandbox tests… +
+
+ ); + } + + return ( + + + + + + } + > +
+ + + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxResultList.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxResultList.tsx new file mode 100644 index 000000000..15bdfff5c --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxResultList.tsx @@ -0,0 +1,153 @@ +import { + formatCapabilityLabel, + listSandboxCapabilities, + type SandboxGateState, +} from '../sandboxState'; +import { resolveBackgroundTintWithAlpha } from '@sage-app/ui'; + +function statusClass(status: string) { + switch (status) { + case 'passed': + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700'; + case 'failed': + return 'border-destructive/40 bg-destructive/10 text-destructive'; + case 'running': + return 'border-yellow-500/30 bg-yellow-500/10 text-yellow-700'; + case 'pending': + return 'border-border bg-muted text-muted-foreground'; + default: + return 'border-border bg-muted text-muted-foreground'; + } +} + +function statusLabel(status: string) { + return status.slice(0, 1).toUpperCase() + status.slice(1); +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function normalizeCapabilityResult(value: unknown): { + status: string; + details: string | null; +} { + if (!isObject(value)) { + return { + status: 'unknown', + details: null, + }; + } + + const status = typeof value.status === 'string' ? value.status : 'unknown'; + + const details = + typeof value.details === 'string' && value.details.length > 0 + ? value.details + : null; + + return { status, details }; +} + +export function SandboxResultList({ + state, + emptyText, +}: { + state: SandboxGateState | null; + emptyText: string; +}) { + const entries = listSandboxCapabilities(state); + + if (!state || entries.length === 0) { + return ( +
+ {emptyText} +
+ ); + } + + return ( +
+
+
+
+ Overall status +
+
+ {statusLabel(state.overallCriticalStatus)} +
+
+ + + {state.overallCriticalStatus} + +
+ +
+ {entries.map(([capability, rawResult], index) => { + const result = normalizeCapabilityResult(rawResult); + + return ( +
0 ? 'border-t border-border' : '', + ].join(' ')} + > +
+
+
+
+ {formatCapabilityLabel(capability)} +
+ + {result.details ? ( +
+ + +
+ {result.details} +
+
+ ) : null} +
+
+ + + {result.status} + +
+
+ ); + })} +
+
+ ); +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxTabs.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxTabs.tsx new file mode 100644 index 000000000..092f0bdef --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/src/components/SandboxTabs.tsx @@ -0,0 +1,46 @@ +import type { SandboxTab } from '../sandboxState'; + +interface Props { + activeTab: SandboxTab; + currentEnabled: boolean; + onChange: (tab: SandboxTab) => void; +} + +const tabs: Array<{ + id: SandboxTab; + label: string; +}> = [ + { id: 'effective', label: 'Effective gate' }, + { id: 'previous', label: 'Previous completed' }, + { id: 'current', label: 'Current running' }, +]; + +export function SandboxTabs({ activeTab, currentEnabled, onChange }: Props) { + return ( +
+ {tabs.map((tab) => { + const disabled = tab.id === 'current' && !currentEnabled; + + return ( + + ); + })} +
+ ); +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/main.tsx b/builtin-apps/src/system/apps/sandbox-tests/src/main.tsx new file mode 100644 index 000000000..cb0593d81 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/src/main.tsx @@ -0,0 +1,12 @@ +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + Loading approval...}> + + + , +); diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/sandboxApi.ts b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxApi.ts new file mode 100644 index 000000000..107b1b3b0 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxApi.ts @@ -0,0 +1,26 @@ +import { + getSageSystemClient, + type SandboxStateView, +} from '@sage-system-app/sdk'; + +const client = await getSageSystemClient(); + +export type { SandboxStateView }; + +export async function getSandboxState(): Promise { + return await client.sandbox.getState(); +} + +export async function rerunSandboxTests(): Promise { + return await client.sandbox.rerunTests(); +} + +export function onSandboxStateChanged( + handler: (event: SandboxStateView) => void, +): () => void { + return client.sandbox.onStateChanged(handler); +} + +export async function closeSelf(): Promise { + return await client.runtimeManager.closeSelf(); +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/sandboxState.ts b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxState.ts new file mode 100644 index 000000000..d055a54ee --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/src/sandboxState.ts @@ -0,0 +1,58 @@ +import type { SandboxStateView } from './sandboxApi'; + +export type SandboxTab = 'effective' | 'previous' | 'current'; + +export type SandboxGateState = NonNullable; + +export function getLiveSandboxState(state: SandboxStateView | null) { + return state?.currentRun?.state ?? null; +} + +export function getBaselineSandboxState(state: SandboxStateView | null) { + return state?.baseline ?? null; +} + +export function getEffectiveSandboxState(state: SandboxStateView | null) { + return state?.effective ?? null; +} + +export function isCurrentSandboxRunActive(state: SandboxStateView | null) { + return state?.currentRun?.state?.overallCriticalStatus === 'running'; +} + +export function selectedSandboxState( + state: SandboxStateView | null, + tab: SandboxTab, +): SandboxGateState | null { + switch (tab) { + case 'current': + return getLiveSandboxState(state); + case 'previous': + return getBaselineSandboxState(state); + case 'effective': + return getEffectiveSandboxState(state); + } +} + +export function formatCapabilityLabel(value: string): string { + return value + .replace(/^sandbox\./, '') + .replace(/_/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +export function listSandboxCapabilities(state: SandboxGateState | null) { + if (!state) return []; + + return Object.entries(state) + .filter( + ([key]) => + key !== 'overallCriticalStatus' && + key !== 'startedAt' && + key !== 'finishedAt', + ) + .sort(([a], [b]) => + formatCapabilityLabel(a).localeCompare(formatCapabilityLabel(b)), + ); +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/src/styles.css b/builtin-apps/src/system/apps/sandbox-tests/src/styles.css new file mode 100644 index 000000000..ad480ae12 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/src/styles.css @@ -0,0 +1,19 @@ +@import "@sage-app/sdk/theme.css"; +@config "../tailwind.config.js"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: transparent; +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/tailwind.config.js b/builtin-apps/src/system/apps/sandbox-tests/tailwind.config.js new file mode 100644 index 000000000..13edcb743 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/tailwind.config.js @@ -0,0 +1,14 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSageTailwindConfig } from '../../tailwind.shared.js'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(dir, '../../../../..'); + +export default createSageTailwindConfig({ + content: [ + join(dir, 'index.html'), + join(dir, 'src/**/*.{ts,tsx}'), + join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'), + ], +}); diff --git a/builtin-apps/src/system/apps/sandbox-tests/tsconfig.json b/builtin-apps/src/system/apps/sandbox-tests/tsconfig.json new file mode 100644 index 000000000..3d61827d7 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/builtin-apps/src/system/apps/sandbox-tests/vite.config.ts b/builtin-apps/src/system/apps/sandbox-tests/vite.config.ts new file mode 100644 index 000000000..c2498e6d6 --- /dev/null +++ b/builtin-apps/src/system/apps/sandbox-tests/vite.config.ts @@ -0,0 +1,3 @@ +import { defineSageSystemAppConfig } from '../../vite.system-app.config'; + +export default defineSageSystemAppConfig(import.meta.url); diff --git a/builtin-apps/src/system/apps/task-manager/index.html b/builtin-apps/src/system/apps/task-manager/index.html new file mode 100644 index 000000000..fe053d619 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/index.html @@ -0,0 +1,12 @@ + + + + + + Task Manager + + +
+ + + diff --git a/builtin-apps/src/system/apps/task-manager/package.json b/builtin-apps/src/system/apps/task-manager/package.json new file mode 100644 index 000000000..3945cfdf6 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/package.json @@ -0,0 +1,8 @@ +{ + "name": "task-manager-system-app", + "private": true, + "type": "module", + "dependencies": { + "@sage-system-app/sdk": "workspace:*" + } +} diff --git a/builtin-apps/src/system/apps/task-manager/postcss.config.js b/builtin-apps/src/system/apps/task-manager/postcss.config.js new file mode 100644 index 000000000..c97c6b959 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/postcss.config.js @@ -0,0 +1,13 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + tailwindcss: { + config: join(dir, 'tailwind.config.js'), + }, + autoprefixer: {}, + }, +}; diff --git a/builtin-apps/src/system/apps/task-manager/public/icon.svg b/builtin-apps/src/system/apps/task-manager/public/icon.svg new file mode 100644 index 000000000..7d7cbd44a --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/public/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/builtin-apps/src/system/apps/task-manager/sage-manifest.json b/builtin-apps/src/system/apps/task-manager/sage-manifest.json new file mode 100644 index 000000000..895e7d8c1 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/sage-manifest.json @@ -0,0 +1,27 @@ +{ + "manifestVersion": 0, + "sageVersion": { + "min": "0.0.0" + }, + "name": "Task Manager", + "version": "0.1.0", + "permissions": { + "network": { + "whitelist": { + "required": [], + "optional": [] + } + }, + "capabilities": { + "required": [ + "environment.theme.get_current", + "environment.theme.listen_changed", + "environment.theme.css_vars" + ], + "optional": [] + } + }, + "files": [], + "entry": "index.html", + "icon": "icon.svg" +} diff --git a/builtin-apps/src/system/apps/task-manager/src/App.tsx b/builtin-apps/src/system/apps/task-manager/src/App.tsx new file mode 100644 index 000000000..96304d658 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/src/App.tsx @@ -0,0 +1,392 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + focusRuntime, + hideRuntime, + killRuntime, + listRuntimes, + onRuntimesChanged, + type RuntimeRecord, +} from './taskManagerApi'; +import type { SageAppRuntimeRecordView } from '@sage-system-app/sdk'; + +function formatDuration(ms: number) { + const s = Math.floor(Math.max(0, ms) / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + + if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`; + if (m > 0) return `${m}m ${String(sec).padStart(2, '0')}s`; + return `${sec}s`; +} + +function formatTime(value: number) { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(value)); +} + +function runtimeAppId(runtime: SageAppRuntimeRecordView) { + return runtime.app.common.identity.id; +} + +function runtimeAppName(runtime: SageAppRuntimeRecordView) { + return runtime.app.common.activeSnapshot.manifest.name; +} + +function runtimeKind(runtime: SageAppRuntimeRecordView) { + return runtime.app.kind === 'user' ? 'User' : 'System'; +} + +function runtimeVisible(runtime: SageAppRuntimeRecordView) { + return runtime.visibility === 'Visible'; +} + +function ActionButton({ + children, + danger, + disabled, + onClick, +}: { + children: string; + danger?: boolean; + disabled?: boolean; + onClick: () => void | Promise; +}) { + return ( + + ); +} + +function Metric({ label, value }: { label: string; value: string | number }) { + return ( +
+
{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..614527a13 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/src/styles.css @@ -0,0 +1,16 @@ +@import "@sage-app/sdk/theme.css"; +@config "../tailwind.config.js"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; +} diff --git a/builtin-apps/src/system/apps/task-manager/src/taskManagerApi.ts b/builtin-apps/src/system/apps/task-manager/src/taskManagerApi.ts new file mode 100644 index 000000000..615b92e6b --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/src/taskManagerApi.ts @@ -0,0 +1,41 @@ +import { + getSageSystemClient, + type RuntimeManagerRuntimesChangedEvent, + type RuntimeTargetParams, + type SageAppRuntimeRecordView, + type SystemKillRuntimeResult, +} from '@sage-system-app/sdk'; + +const client = await getSageSystemClient(); + +export type { SageAppRuntimeRecordView as RuntimeRecord }; + +export function onRuntimesChanged( + handler: (event: RuntimeManagerRuntimesChangedEvent) => void, +): () => void { + return client.runtimeManager.onRuntimesChanged(handler); +} + +export async function listRuntimes(): Promise { + return await client.runtimeManager.listRuntimes(); +} + +export async function focusRuntime(appId: string): Promise { + return await client.runtimeManager.focusRuntime({ + appId, + } satisfies RuntimeTargetParams); +} + +export async function hideRuntime(appId: string): Promise { + return await client.runtimeManager.hideRuntime({ + appId, + } satisfies RuntimeTargetParams); +} + +export async function killRuntime( + appId: string, +): Promise { + return await client.runtimeManager.killRuntime({ + appId, + } satisfies RuntimeTargetParams); +} diff --git a/builtin-apps/src/system/apps/task-manager/tailwind.config.js b/builtin-apps/src/system/apps/task-manager/tailwind.config.js new file mode 100644 index 000000000..13edcb743 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/tailwind.config.js @@ -0,0 +1,14 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSageTailwindConfig } from '../../tailwind.shared.js'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(dir, '../../../../..'); + +export default createSageTailwindConfig({ + content: [ + join(dir, 'index.html'), + join(dir, 'src/**/*.{ts,tsx}'), + join(repoRoot, 'packages/sage-app-ui/src/**/*.{ts,tsx}'), + ], +}); diff --git a/builtin-apps/src/system/apps/task-manager/tsconfig.json b/builtin-apps/src/system/apps/task-manager/tsconfig.json new file mode 100644 index 000000000..3d61827d7 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/builtin-apps/src/system/apps/task-manager/vite.config.ts b/builtin-apps/src/system/apps/task-manager/vite.config.ts new file mode 100644 index 000000000..c2498e6d6 --- /dev/null +++ b/builtin-apps/src/system/apps/task-manager/vite.config.ts @@ -0,0 +1,3 @@ +import { defineSageSystemAppConfig } from '../../vite.system-app.config'; + +export default defineSageSystemAppConfig(import.meta.url); diff --git a/builtin-apps/src/system/build.mjs b/builtin-apps/src/system/build.mjs new file mode 100644 index 000000000..55ac4a825 --- /dev/null +++ b/builtin-apps/src/system/build.mjs @@ -0,0 +1,113 @@ +import { spawn } from 'node:child_process'; +import { readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const packageRoot = resolve(import.meta.dirname); +const appsRoot = join(packageRoot, 'apps'); +const outRoot = resolve(packageRoot, '../../build/dist/system'); + +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +const onlyApps = process.argv.slice(2); +const concurrency = Number(process.env.SYSTEM_APPS_CONCURRENCY ?? 6); + +function listAllApps() { + return readdirSync(appsRoot) + .map((name) => ({ name, dir: join(appsRoot, name) })) + .filter((entry) => statSync(entry.dir).isDirectory()); +} + +const apps = + onlyApps.length > 0 + ? listAllApps().filter((app) => onlyApps.includes(app.name)) + : listAllApps(); + +if (apps.length === 0) { + console.log('[system-apps] no apps to build'); + process.exit(0); +} + +function run(command, args) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd: packageRoot, + stdio: 'inherit', + shell: false, + }); + + child.on('error', rejectPromise); + + child.on('exit', (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + + rejectPromise( + new Error( + `${command} ${args.join(' ')} failed with ${ + signal ? `signal ${signal}` : `exit code ${code}` + }`, + ), + ); + }); + }); +} + +async function buildApp(app) { + console.log(`\n==> Building system app: ${app.name}`); + + await run(pnpm, [ + 'exec', + 'tsc', + '--noEmit', + '--project', + join(app.dir, 'tsconfig.json'), + ]); + + await run(pnpm, [ + 'exec', + 'vite', + 'build', + '--config', + join(app.dir, 'vite.config.ts'), + ]); + + await run(pnpm, [ + 'exec', + 'sage-app', + 'finalize-manifest', + '--source', + join(app.dir, 'sage-manifest.json'), + '--dist', + join(outRoot, app.name), + ]); + + console.log(`\n✓ Finished system app: ${app.name}`); +} + +async function runWithConcurrency(items, limit, worker) { + let index = 0; + + const workers = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + while (index < items.length) { + const item = items[index]; + index += 1; + await worker(item); + } + }, + ); + + await Promise.all(workers); +} + +try { + await runWithConcurrency(apps, concurrency, buildApp); + console.log('\n[system-apps] all builds finished'); +} catch (error) { + console.error('\n[system-apps] build failed'); + console.error(error); + process.exit(1); +} diff --git a/builtin-apps/src/system/package.json b/builtin-apps/src/system/package.json new file mode 100644 index 000000000..e10abb264 --- /dev/null +++ b/builtin-apps/src/system/package.json @@ -0,0 +1,25 @@ +{ + "name": "sage-system-apps", + "private": true, + "type": "module", + "scripts": { + "build": "node ./build.mjs" + }, + "dependencies": { + "@tauri-apps/api": "^2.10.1", + "@sage-system-app/sdk": "workspace:*", + "@sage-app/ui": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^5.0.4", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "tailwindcss": "^3.4.17", + "postcss": "^8.4.49", + "autoprefixer": "^10.4.20" + } +} diff --git a/builtin-apps/src/system/tailwind.shared.js b/builtin-apps/src/system/tailwind.shared.js new file mode 100644 index 000000000..c4e80bb0d --- /dev/null +++ b/builtin-apps/src/system/tailwind.shared.js @@ -0,0 +1,28 @@ +export function createSageTailwindConfig({ content }) { + return { + content, + theme: { + extend: { + colors: { + background: 'var(--background)', + foreground: 'var(--foreground)', + card: 'var(--card)', + 'card-foreground': 'var(--card-foreground)', + primary: 'var(--primary)', + 'primary-foreground': 'var(--primary-foreground)', + secondary: 'var(--secondary)', + 'secondary-foreground': 'var(--secondary-foreground)', + muted: 'var(--muted)', + 'muted-foreground': 'var(--muted-foreground)', + destructive: 'var(--destructive)', + 'destructive-foreground': 'var(--destructive-foreground)', + border: 'var(--border)', + input: 'var(--input)', + ring: 'var(--ring)', + popover: 'var(--popover)', + 'popover-foreground': 'var(--popover-foreground)', + }, + }, + }, + }; +} diff --git a/builtin-apps/src/system/tsconfig.base.json b/builtin-apps/src/system/tsconfig.base.json new file mode 100644 index 000000000..95f6b32ae --- /dev/null +++ b/builtin-apps/src/system/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "baseUrl": "../../..", + "paths": { + "@sage-app/ui": ["packages/sage-app-ui/src/index.ts"] + }, + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + } +} diff --git a/builtin-apps/src/system/vite.system-app.config.ts b/builtin-apps/src/system/vite.system-app.config.ts new file mode 100644 index 000000000..7dbfe1a75 --- /dev/null +++ b/builtin-apps/src/system/vite.system-app.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { basename, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function defineSageSystemAppConfig(importMetaUrl: string) { + const dir = dirname(fileURLToPath(importMetaUrl)); + const appName = basename(dir); + const workspaceRoot = resolve(dir, '../../../../..'); + + return defineConfig({ + root: dir, + plugins: [react()], + publicDir: resolve(dir, 'public'), + build: { + outDir: resolve( + workspaceRoot, + 'builtin-apps', + 'build', + 'dist', + 'system', + appName, + ), + emptyOutDir: true, + rollupOptions: { + input: resolve(dir, 'index.html'), + }, + }, + }); +} diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 16263fdd9..0f498f29f 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -98,5 +98,6 @@ "update_nft_collection": true, "redownload_nft": true, "increase_derivation_index": true, - "is_asset_owned": true + "is_asset_owned": true, + "get_xch_usd_price": true } diff --git a/crates/sage-api/src/requests/actions.rs b/crates/sage-api/src/requests/actions.rs index 06e29c222..7e05a0388 100644 --- a/crates/sage-api/src/requests/actions.rs +++ b/crates/sage-api/src/requests/actions.rs @@ -29,6 +29,23 @@ pub struct ResyncCat { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct ResyncCatResponse {} +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Price", description = "Get XCH-USD price") +)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetXchUsdPrice {} + +#[cfg_attr(feature = "openapi", crate::openapi_attr(tag = "Price"))] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetXchUsdPriceResponse { + pub usd: f64, +} + /// Update a `CAT` token's metadata and visibility #[cfg_attr( feature = "openapi", diff --git a/crates/sage-apps/Cargo.toml b/crates/sage-apps/Cargo.toml new file mode 100644 index 000000000..f896ccfbf --- /dev/null +++ b/crates/sage-apps/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "sage-apps" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +sage = { workspace = true } +sage-api = { workspace = true, features = ["tauri"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +specta = { workspace = true, features = ["derive", "function", "url"] } +tauri-specta = { workspace = true, features = ["derive"] } +specta-typescript = { workspace = true } +tauri = { workspace = true, features = ["macos-private-api"] } +tauri-plugin-dialog = "2.7.1" +tokio = { workspace = true } +tracing = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls"] } +anyhow = { workspace = true } +hex = { workspace = true } +sqlx = { workspace = true, features = ["sqlite", "runtime-tokio"] } +sha2 = "0.10.9" +uuid = { version = "1.19.0", features = ["v4", "serde"] } +tempfile = "3" +zip = "8.5.1" +mime_guess = "2" +url = "2" +async-trait = "0.1.89" +erased-serde = "0.4.9" +serde_path_to_error = "0.1.20" +parking_lot = "0.12.5" +futures = "0.3.32" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/sage-apps/src/bin/export_bridge_types.rs b/crates/sage-apps/src/bin/export_bridge_types.rs new file mode 100644 index 000000000..6f6dab3ae --- /dev/null +++ b/crates/sage-apps/src/bin/export_bridge_types.rs @@ -0,0 +1,26 @@ +use sage_apps::{export_system_bridge_typescript, export_user_bridge_typescript}; + +fn main() { + let bridge = std::env::args() + .nth(1) + .unwrap_or_else(|| "system".to_string()); + + let output = match bridge.as_str() { + "system" => export_system_bridge_typescript(), + "user" => export_user_bridge_typescript(), + other => { + eprintln!("unknown bridge kind: {other}"); + std::process::exit(1); + } + }; + + match output { + Ok(ts) => { + println!("{ts}"); + } + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + } +} diff --git a/crates/sage-apps/src/bridge.rs b/crates/sage-apps/src/bridge.rs new file mode 100644 index 000000000..2023d0aaa --- /dev/null +++ b/crates/sage-apps/src/bridge.rs @@ -0,0 +1,20 @@ +mod bridge_request; +mod commands; +mod debug; +mod event_emit; +mod methods; +mod registry; +mod state; +mod ts_exports; +mod types; + +pub use commands::*; +pub use ts_exports::*; +pub use types::*; + +pub(crate) use bridge_request::*; +pub(crate) use debug::*; +pub(crate) use event_emit::*; +pub(crate) use methods::*; +pub(crate) use registry::*; +pub(crate) use state::*; diff --git a/crates/sage-apps/src/bridge/bridge_request.rs b/crates/sage-apps/src/bridge/bridge_request.rs new file mode 100644 index 000000000..b3c42f4ca --- /dev/null +++ b/crates/sage-apps/src/bridge/bridge_request.rs @@ -0,0 +1,385 @@ +use tauri::{AppHandle, Manager, State, Webview}; + +use crate::{ + AppState, AppsHostState, BridgeApprovalsChangedEvent, BridgeCapability, BridgeContext, + BridgeMethod, BridgeMethodCapability, BridgeOrigin, BridgeRegistry, BridgeRegistryKind, + BridgeTools, ResolveBridgeApprovalArgs, RustBridgeApprovalRequest, RustBridgeInvokeResult, + RustBridgeRequest, RustBridgeResponse, SharedSageApp, SystemBridgeCapability, + UserBridgeCapability, assert_bridge_origin, emit_bridge_response_to_app, + emit_system_runtime_event_to_listeners, ensure_app_is_enabled_for_scope, + ensure_approval_expiry_loop, get_pending_approval, get_system_capability_definition, + get_user_capability_definition, list_pending_approvals, remove_pending_approval, resolve_app, + start_bridge_approval_runtime, sync_bridge_approval_runtime, write_pending_approval, +}; + +pub(crate) async fn process( + app_handle: AppHandle, + webview: Webview, + app_state: State<'_, AppState>, + request: RustBridgeRequest, +) -> Result { + if let Err(result) = assert_bridge_version(&request) { + return Ok(result); + } + + let webview_label = webview.label().to_string(); + + let origin = match assert_bridge_origin(&app_handle, &webview_label).await { + Ok(origin) => origin, + Err(err) => { + return Ok(RustBridgeInvokeResult::error( + &request.id, + "permission_denied", + format!("Bridge origin denied: {err}"), + )); + } + }; + + process_shared( + &app_handle, + &app_state, + &origin, + BridgeRegistryKind::User, + &request, + false, + ) + .await +} + +pub(crate) async fn process_system( + app_handle: AppHandle, + webview: Webview, + app_state: State<'_, AppState>, + request: RustBridgeRequest, +) -> Result { + if let Err(result) = assert_bridge_version(&request) { + return Ok(result); + } + let webview_label = webview.label().to_string(); + + let origin = match assert_system_bridge_origin(&app_handle, &webview_label).await { + Ok(origin) => origin, + Err(err) => { + return Ok(RustBridgeInvokeResult::error( + &request.id, + "permission_denied", + format!("Bridge origin denied: {err}"), + )); + } + }; + + process_shared( + &app_handle, + &app_state, + &origin, + BridgeRegistryKind::System, + &request, + false, + ) + .await +} + +pub(crate) async fn process_after_approval( + app_handle: &AppHandle, + app_state: &State<'_, AppState>, + apps_state: &State<'_, AppsHostState>, + args: ResolveBridgeApprovalArgs, +) -> Result<(), String> { + let pending = get_pending_approval(apps_state, &args.approval_id).await?; + remove_pending_approval(apps_state, &args.approval_id).await; + + sync_bridge_approval_runtime(app_handle, apps_state).await?; + + let approvals_changed_event = + BridgeApprovalsChangedEvent::new_from_list(list_pending_approvals(apps_state).await); + + emit_system_runtime_event_to_listeners(app_handle, apps_state, approvals_changed_event).await; + + let app = resolve_app(app_handle, &pending.app_id) + .await + .map_err(|err| format!("Failed to resolve app: {err}"))?; + + let origin = + assert_bridge_origin(app_handle, &app.with_app(SharedSageApp::webview_label)).await?; + + let invoke_result = if args.approved { + process_shared( + app_handle, + app_state, + &origin, + pending.registry_kind, + &pending.request, + true, + ) + .await? + } else { + RustBridgeInvokeResult::error( + &pending.request.id, + "user_denied", + args.reason + .unwrap_or_else(|| "User denied the request".to_string()), + ) + }; + + emit_bridge_response_to_app(app_handle, &origin.app, &invoke_result.try_into()?).await?; + Ok(()) +} + +async fn process_shared( + app_handle: &AppHandle, + app_state: &State<'_, AppState>, + origin: &BridgeOrigin, + registry_kind: BridgeRegistryKind, + request: &RustBridgeRequest, + approved: bool, +) -> Result { + let registry = BridgeRegistry::new(registry_kind); + + let app = &origin.app; + if let Err(err) = ensure_app_is_enabled_for_scope(app_state, app).await { + return Ok(RustBridgeInvokeResult::error( + &request.id, + "app_not_enabled_for_scope", + err, + )); + } + + let method = match assert_method(®istry, request) { + Ok(method) => method, + Err(response) => return Ok(response.into()), + }; + + match method.capability() { + BridgeMethodCapability::Ungated => {} + + BridgeMethodCapability::Required(capability) => { + if let Err(response) = verify_capability(&origin.app, request, capability) { + return Ok(response.into()); + } + } + } + + if approved { + let response = + execute_bridge_request(app_handle, app_state, origin, registry, request).await; + + return Ok(response.into()); + } + + match method.approval_request(BridgeContext { app }, request) { + Ok(Some(approval)) => { + request_approval(app_handle, app.id(), registry_kind, approval, request).await?; + Ok(RustBridgeInvokeResult::Pending {}) + } + Ok(None) => { + let response = + execute_bridge_request(app_handle, app_state, origin, registry, request).await; + + Ok(response.into()) + } + Err(err) => Ok(RustBridgeInvokeResult::error( + &request.id, + err.code, + err.message, + )), + } +} + +async fn execute_bridge_request( + app_handle: &AppHandle, + app_state: &State<'_, AppState>, + origin: &BridgeOrigin, + registry: BridgeRegistry, + request: &RustBridgeRequest, +) -> RustBridgeResponse { + let method = match assert_method(®istry, request) { + Ok(method) => method, + Err(response) => return response, + }; + + let result = method + .handle( + BridgeContext { app: &origin.app }, + BridgeTools { + app_handle, + app_state, + host_state: &app_handle.state::(), + }, + request, + ) + .await; + + match result { + Ok(value) => match erased_serde::serialize(&*value, serde_json::value::Serializer) { + Ok(value) => RustBridgeResponse::success(&request.id, &value), + Err(err) => RustBridgeResponse::error( + &request.id, + "internal_error", + format!("failed to encode {} result: {err}", method.name()), + ), + }, + Err(err) => RustBridgeResponse::error(&request.id, err.code, err.message), + } +} + +async fn request_approval( + app_handle: &AppHandle, + app_id: String, + registry_kind: BridgeRegistryKind, + approval: RustBridgeApprovalRequest, + request: &RustBridgeRequest, +) -> Result<(), String> { + let apps_state = app_handle.state::(); + write_pending_approval( + &apps_state, + app_id.clone(), + registry_kind, + &approval, + request, + ) + .await; + + ensure_approval_expiry_loop(app_handle, &apps_state).await; + + let approvals_changed_event = + BridgeApprovalsChangedEvent::new_from_list(list_pending_approvals(&apps_state).await); + emit_system_runtime_event_to_listeners(app_handle, &apps_state, approvals_changed_event).await; + + start_bridge_approval_runtime(app_handle, &apps_state, Vec::from([app_id])).await?; + + Ok(()) +} + +fn verify_capability( + app: &SharedSageApp, + request: &RustBridgeRequest, + capability: BridgeCapability, +) -> Result<(), RustBridgeResponse> { + match capability { + BridgeCapability::User(capability) => { + let definition = get_user_capability_definition(capability); + + verify_user_capability( + app, + request, + capability, + definition.flags().shared_with_app(), + ) + } + + BridgeCapability::System(capability) => { + let definition = get_system_capability_definition(capability); + + verify_system_capability( + app, + request, + capability, + definition.flags().shared_with_app(), + ) + } + } +} + +fn verify_user_capability( + app: &SharedSageApp, + request: &RustBridgeRequest, + capability: UserBridgeCapability, + shared_with_app: bool, +) -> Result<(), RustBridgeResponse> { + if !shared_with_app { + return Err(RustBridgeResponse::error( + &request.id, + "permission_denied", + format!("Capability {} is not shared with apps", capability.key()), + )); + } + + let effective_capabilities = app.with(|app| { + app.common() + .requested_permissions() + .capabilities() + .resolve_effective_grants(app.common().granted_permissions().capabilities().copied()) + }); + + if !effective_capabilities.contains(&capability) { + return Err(RustBridgeResponse::error( + &request.id, + "permission_denied", + format!("Permission denied for {}", capability.key()), + )); + } + + Ok(()) +} + +fn verify_system_capability( + app: &SharedSageApp, + request: &RustBridgeRequest, + capability: SystemBridgeCapability, + shared_with_app: bool, +) -> Result<(), RustBridgeResponse> { + if !shared_with_app { + return Err(RustBridgeResponse::error( + &request.id, + "permission_denied", + format!("Capability {} is not shared with apps", capability.key()), + )); + } + + let granted = app.with(|app| { + app.system_granted_permissions() + .is_some_and(|permissions| permissions.capabilities().contains(&capability)) + }); + + if !granted { + return Err(RustBridgeResponse::error( + &request.id, + "permission_denied", + format!("Permission denied for {}", capability.key()), + )); + } + + Ok(()) +} + +fn assert_method<'a>( + registry: &'a BridgeRegistry, + request: &RustBridgeRequest, +) -> Result<&'a dyn BridgeMethod, RustBridgeResponse> { + let Some(method) = registry.get(&request.method) else { + return Err(RustBridgeResponse::error( + &request.id, + "method_not_found", + format!("Unknown bridge method: {}", request.method), + )); + }; + + Ok(method) +} + +async fn assert_system_bridge_origin( + app_handle: &AppHandle, + webview_label: &String, +) -> Result { + let origin = assert_bridge_origin(app_handle, webview_label).await?; + + if !origin.app.is_system_app() { + return Err("origin app is not a system app".to_string()); + } + + Ok(origin) +} + +fn assert_bridge_version(request: &RustBridgeRequest) -> Result<(), RustBridgeInvokeResult> { + if let Some(version) = &request.bridge_version + && version != "v1" + { + return Err(RustBridgeInvokeResult::error( + &request.id, + "unsupported_bridge_version", + format!("Unsupported Sage bridge version: {version}"), + )); + } + + Ok(()) +} diff --git a/crates/sage-apps/src/bridge/commands.rs b/crates/sage-apps/src/bridge/commands.rs new file mode 100644 index 000000000..d183db535 --- /dev/null +++ b/crates/sage-apps/src/bridge/commands.rs @@ -0,0 +1,25 @@ +use tauri::{AppHandle, State, Webview}; + +use crate::{AppState, RustBridgeInvokeResult, RustBridgeRequest, process, process_system}; + +#[tauri::command] +#[specta::specta] +pub async fn apps_invoke_bridge( + app: AppHandle, + webview: Webview, + app_state: State<'_, AppState>, + request: RustBridgeRequest, +) -> Result { + process(app, webview, app_state, request).await +} + +#[tauri::command] +#[specta::specta] +pub async fn apps_invoke_system_bridge( + app: AppHandle, + webview: Webview, + app_state: State<'_, AppState>, + request: RustBridgeRequest, +) -> Result { + process_system(app, webview, app_state, request).await +} diff --git a/crates/sage-apps/src/bridge/debug.rs b/crates/sage-apps/src/bridge/debug.rs new file mode 100644 index 000000000..f003d4afe --- /dev/null +++ b/crates/sage-apps/src/bridge/debug.rs @@ -0,0 +1,15 @@ +macro_rules! comms_debug { + ($($arg:tt)*) => { + if $crate::sage_apps_comms_debug_enabled() { + tracing::info!(target: "sage_apps_comms", $($arg)*); + } + }; +} + +pub(crate) use comms_debug; + +pub(crate) fn sage_apps_comms_debug_enabled() -> bool { + cfg!(debug_assertions) + && std::env::var("SAGE_APPS_COMMS_DEBUG") + .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) +} diff --git a/crates/sage-apps/src/bridge/event_emit.rs b/crates/sage-apps/src/bridge/event_emit.rs new file mode 100644 index 000000000..bf4d76e65 --- /dev/null +++ b/crates/sage-apps/src/bridge/event_emit.rs @@ -0,0 +1,266 @@ +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::{ + AppsHostState, RustBridgeResponse, SharedSageApp, SystemBridgeCapability, UserBridgeCapability, + comms_debug, ensure_app_is_enabled_for_scope, get_sage_webview, get_webview_in_sage_window, + list_runtimes, resolve_running_app, +}; + +const SAGE_RUNTIME_EVENT_NAME: &str = "apps:runtime-event"; + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeEvent { + #[serde(rename = "type")] + pub event_type: &'static str, + + pub payload: T, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AppRuntimeEventRail { + User, + System, +} + +impl AppRuntimeEventRail { + pub(crate) fn event_name(self) -> &'static str { + match self { + Self::User => "sage-bridge:event", + Self::System => "sage-system-bridge:event", + } + } +} + +pub(crate) trait UserRuntimeEvent: Serialize + Type + Clone { + const TYPE: &'static str; + const REQUIRED_CAPABILITY: UserBridgeCapability; +} + +pub(crate) trait SystemRuntimeEvent: Serialize + Type + Clone { + const TYPE: &'static str; + const REQUIRED_CAPABILITY: SystemBridgeCapability; +} + +fn runtime_event(event_type: &'static str, payload: T) -> RuntimeEvent { + RuntimeEvent { + event_type, + payload, + } +} + +pub(crate) async fn emit_user_runtime_event_to_listeners( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + event: T, +) where + T: UserRuntimeEvent, +{ + let Ok(runtimes) = list_runtimes(apps_state).await else { + return; + }; + + for runtime in runtimes { + let Some((app_id, can_receive)) = runtime.with_runtime(|record| { + if record.internal() { + return None; + } + + let app = record.app(); + + if !app.is_user_app() { + return None; + } + + let can_receive = app.is_capability_granted(T::REQUIRED_CAPABILITY.into()); + + Some((app.id(), can_receive)) + }) else { + continue; + }; + + if can_receive { + let _ = emit_user_runtime_event_to_app_id(app_handle, &app_id, event.clone()).await; + } + } + + let _ = emit_user_runtime_event_to_sage_webview(app_handle, event); +} + +pub(crate) async fn emit_system_runtime_event_to_listeners( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + event: T, +) where + T: SystemRuntimeEvent, +{ + let Ok(runtimes) = list_runtimes(apps_state).await else { + return; + }; + comms_debug!( + "system:event:listeners type={} required_capability={}", + T::TYPE, + T::REQUIRED_CAPABILITY.key(), + ); + + for runtime in runtimes { + let Some((app_id, can_receive)) = runtime.with_runtime(|record| { + if record.internal() { + comms_debug!( + "system:event:skip type={} runtime={} reason=internal", + T::TYPE, + record.app_id(), + ); + return None; + } + + let app = record.app(); + + if !app.is_system_app() { + comms_debug!( + "system:event:skip type={} app={} reason=not_system", + T::TYPE, + app.id(), + ); + return None; + } + + let can_receive = app.is_capability_granted(T::REQUIRED_CAPABILITY.into()); + + comms_debug!( + "system:event:candidate type={} app={} required_capability={} can_receive={}", + T::TYPE, + app.id(), + T::REQUIRED_CAPABILITY.key(), + can_receive, + ); + + Some((app.id(), can_receive)) + }) else { + continue; + }; + + if can_receive { + comms_debug!("system:event:emit type={} app={}", T::TYPE, app_id,); + + let result = + emit_system_runtime_event_to_app_id(app_handle, &app_id, event.clone()).await; + + if let Err(err) = result { + comms_debug!( + "system:event:emit_failed type={} app={} error={}", + T::TYPE, + app_id, + err, + ); + } + } + } + + let _ = emit_system_runtime_event_to_sage_webview(app_handle, event); +} + +pub(crate) async fn emit_user_runtime_event_to_app_id( + app_handle: &AppHandle, + app_id: &str, + event: T, +) -> Result<(), String> +where + T: UserRuntimeEvent, +{ + emit_runtime_event_to_app_id( + app_handle, + app_id, + AppRuntimeEventRail::User, + T::TYPE, + event, + ) + .await +} + +pub(crate) async fn emit_system_runtime_event_to_app_id( + app_handle: &AppHandle, + app_id: &str, + event: T, +) -> Result<(), String> +where + T: SystemRuntimeEvent, +{ + emit_runtime_event_to_app_id( + app_handle, + app_id, + AppRuntimeEventRail::System, + T::TYPE, + event, + ) + .await +} + +pub(crate) fn emit_user_runtime_event_to_sage_webview( + app_handle: &AppHandle, + event: T, +) -> Result<(), String> +where + T: UserRuntimeEvent, +{ + get_sage_webview(app_handle)? + .emit(SAGE_RUNTIME_EVENT_NAME, runtime_event(T::TYPE, event)) + .map_err(|err| format!("failed to emit runtime event to Sage webview: {err}")) +} + +pub(crate) fn emit_system_runtime_event_to_sage_webview( + app_handle: &AppHandle, + event: T, +) -> Result<(), String> +where + T: SystemRuntimeEvent, +{ + get_sage_webview(app_handle)? + .emit(SAGE_RUNTIME_EVENT_NAME, runtime_event(T::TYPE, event)) + .map_err(|err| format!("failed to emit runtime event to Sage webview: {err}")) +} + +pub(crate) async fn emit_bridge_response_to_app( + app_handle: &AppHandle, + app: &SharedSageApp, + response: &RustBridgeResponse, +) -> Result<(), String> { + get_webview_in_sage_window(app_handle, &app.webview_label())? + .emit("sage-bridge:response", response) + .map_err(|err| format!("failed to emit bridge response: {err}")) +} + +async fn emit_runtime_event_to_app_id( + app_handle: &AppHandle, + app_id: &str, + rail: AppRuntimeEventRail, + event_type: &'static str, + event: T, +) -> Result<(), String> +where + T: Serialize + Type + Clone, +{ + let apps_state = app_handle.state::(); + + let runtime = resolve_running_app(&apps_state, app_id) + .await + .map_err(|err| format!("failed to resolve runtime for app {app_id}: {err}"))?; + + let app = runtime.into_app(); + ensure_app_is_enabled_for_scope(&app_handle.state(), &app).await?; + + let webview_label = app.webview_label(); + comms_debug!( + "runtime:event:emit_to_app app_id={} webview_label={} rail={:?} type={}", + app_id, + webview_label, + rail, + event_type, + ); + + get_webview_in_sage_window(app_handle, &webview_label)? + .emit(rail.event_name(), runtime_event(event_type, event)) + .map_err(|err| format!("failed to emit runtime event: {err}")) +} diff --git a/crates/sage-apps/src/bridge/methods.rs b/crates/sage-apps/src/bridge/methods.rs new file mode 100644 index 000000000..50a73af45 --- /dev/null +++ b/crates/sage-apps/src/bridge/methods.rs @@ -0,0 +1,7 @@ +mod shared; +mod system; +mod user; + +pub(crate) use shared::*; +pub(crate) use system::*; +pub(crate) use user::*; diff --git a/crates/sage-apps/src/bridge/methods/shared.rs b/crates/sage-apps/src/bridge/methods/shared.rs new file mode 100644 index 000000000..8851d1c6d --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/shared.rs @@ -0,0 +1,109 @@ +use async_trait::async_trait; +use serde::de::DeserializeOwned; + +use crate::{ + AppState, AppsHostState, BridgeCapability, RustBridgeApprovalRequest, RustBridgeRequest, + SharedSageApp, SystemBridgeCapability, UserBridgeCapability, +}; + +#[async_trait] +pub(crate) trait BridgeMethod: Send + Sync { + fn name(&self) -> &'static str; + fn capability(&self) -> BridgeMethodCapability; + + fn approval_request( + &self, + ctx: BridgeContext<'_>, + request: &RustBridgeRequest, + ) -> BridgeApprovalRequestResult; + + async fn handle( + &self, + ctx: BridgeContext<'_>, + tools: BridgeTools<'_>, + request: &RustBridgeRequest, + ) -> BridgeHandleResult; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BridgeMethodCapability { + Ungated, + Required(BridgeCapability), +} + +#[derive(Debug)] +pub(crate) struct BridgeContext<'a> { + pub app: &'a SharedSageApp, +} + +#[derive(Debug)] +pub(crate) struct BridgeTools<'a> { + pub app_handle: &'a tauri::AppHandle, + pub app_state: &'a tauri::State<'a, AppState>, + pub host_state: &'a tauri::State<'a, AppsHostState>, +} + +#[derive(Debug, Clone)] +pub(crate) struct BridgeMethodHandleError { + pub code: &'static str, + pub message: String, +} + +pub(crate) type BridgeApprovalRequestResult = + Result, 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..ecc3cd44a --- /dev/null +++ b/crates/sage-apps/src/bridge/methods/system/app_update/apply_update.rs @@ -0,0 +1,70 @@ +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, +} + +#[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), + ) + .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..de89c9306 --- /dev/null +++ b/crates/sage-apps/src/bridge/state.rs @@ -0,0 +1,206 @@ +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, +) -> 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, + }, + ); + + approval_id +} + +pub(crate) async fn find_pending_approval( + apps_state: &State<'_, AppsHostState>, + approval_id: &str, +) -> Option { + let pending = apps_state.bridge.pending_approvals.lock().await; + pending.get(approval_id).cloned() +} + +pub(crate) async fn get_pending_approval( + apps_state: &State<'_, AppsHostState>, + approval_id: &str, +) -> Result { + find_pending_approval(apps_state, approval_id) + .await + .ok_or_else(|| format!("No pending approval with id {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 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..bcb81fd80 --- /dev/null +++ b/crates/sage-apps/src/bridge/types.rs @@ -0,0 +1,178 @@ +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, +} + +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..5cc67f768 --- /dev/null +++ b/crates/sage-apps/src/build/docs.rs @@ -0,0 +1,210 @@ +use std::fmt::Write; +use std::{fs, path::PathBuf}; + +use crate::{ + BridgeCapability, BridgeMethodCapability, BridgeRegistry, BridgeRegistryKind, + 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)) + } + } +} + +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(); + + out.push_str("| Flag | Value |\n"); + out.push_str("|---|---|\n"); + + writeln!( + out, + "| Requestable by app | {} |", + bool_cell(definition.flags().requestable_by_app()) + ) + .unwrap(); + + writeln!( + out, + "| User grantable | {} |", + bool_cell(definition.flags().user_grantable()) + ) + .unwrap(); + + writeln!( + out, + "| Shared with app | {} |", + bool_cell(definition.flags().shared_with_app()) + ) + .unwrap(); + + writeln!( + out, + "| Externally observable | {} |", + bool_cell(definition.flags().externally_observable()) + ) + .unwrap(); + + writeln!( + out, + "| Accesses sensitive secret | {} |\n", + bool_cell(definition.flags().accesses_sensitive_secret()) + ) + .unwrap(); + } + + 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(); + + out.push_str("| Flag | Value |\n"); + out.push_str("|---|---|\n"); + + writeln!( + out, + "| Requestable by app | {} |", + bool_cell(definition.flags().requestable_by_app()) + ) + .unwrap(); + + writeln!( + out, + "| User grantable | {} |", + bool_cell(definition.flags().user_grantable()) + ) + .unwrap(); + + writeln!( + out, + "| Shared with app | {} |", + bool_cell(definition.flags().shared_with_app()) + ) + .unwrap(); + + writeln!( + out, + "| Externally observable | {} |", + bool_cell(definition.flags().externally_observable()) + ) + .unwrap(); + + writeln!( + out, + "| Accesses sensitive secret | {} |\n", + bool_cell(definition.flags().accesses_sensitive_secret()) + ) + .unwrap(); + } + + 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(); + + out.push_str("| Field | Value |\n"); + out.push_str("|---|---|\n"); + + writeln!( + out, + "| Capability | {} |", + method_capability_cell(method.capability()) + ) + .unwrap(); + + out.push('\n'); + } + + 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(()) +} 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..2a613b5c7 --- /dev/null +++ b/crates/sage-apps/src/lib.rs @@ -0,0 +1,62 @@ +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; + +// 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::*; +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..4b61e4392 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/install.rs @@ -0,0 +1,415 @@ +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, SageAppCommon, SageAppIdentity, SageAppPackageManifest, SageAppSnapshot, + SageAppStorage, SageAppWalletScope, SageGrantedPermissionsInput, UserSageApp, + UserSageAppSource, allocate_new_storage, apps_root, 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)?; + + 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) +} + +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(); + } + + #[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..7b138df7b --- /dev/null +++ b/crates/sage-apps/src/lifecycle/package.rs @@ -0,0 +1,495 @@ +use std::{ + collections::BTreeSet, + fs, io, + 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<()> { + 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"); + } + + if out_dir.exists() { + fs::remove_dir_all(out_dir)?; + } + + fs::create_dir_all(out_dir)?; + + let root = out_dir + .canonicalize() + .with_context(|| format!("failed to canonicalize output dir {}", out_dir.display()))?; + + let mut total_uncompressed = 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(); + + if uncompressed_size > MAX_ZIP_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()); + } + + total_uncompressed = total_uncompressed + .checked_add(uncompressed_size) + .context("zip uncompressed size overflow")?; + + if total_uncompressed > MAX_ZIP_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()))?; + + io::copy(&mut entry, &mut out) + .with_context(|| format!("failed to extract zip entry {}", entry.name()))?; + } + + 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 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(), + "" + ); + } + + #[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()); + } + + #[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}" + ); + } + + #[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..16c41f29b --- /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, +}; + +pub(crate) async fn download_bytes_with_limit(url: &str, max_bytes: u64) -> AnyResult> { + let response = reqwest::get(url) + .await + .with_context(|| format!("failed to GET {url}"))? + .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..5acd5cf91 --- /dev/null +++ b/crates/sage-apps/src/lifecycle/storage.rs @@ -0,0 +1,281 @@ +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}; +use uuid::Uuid; +#[cfg(target_os = "windows")] +use {anyhow::Context, std::fs}; + +#[cfg(target_os = "windows")] +use crate::data_directory_for; +use crate::{ + AppMutationManager, AppsHostState, CreateInstalledRuntimeArgs, OriginCleanupRuntimeTarget, + ResolvedStoppedApp, SageAppStorage, SharedSageApp, find_runtime_by_app_id_optional, + fresh_origin_id, parse_data_store_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 { + let storage = allocate_new_os_storage(app, base_path).await?; + 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 async 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 async fn allocate_new_os_storage( + _app: &AppHandle, + _base_path: &Path, +) -> AnyResult { + Ok(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?; + } + } + + _ => { + clear_app_storage_by_target(app, &target.storage) + .await + .map_err(anyhow::Error::msg)?; + } + } + 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(()) +} + +pub async fn clear_app_storage_by_target( + app: &AppHandle, + target: &SageAppStorage, +) -> Result<(), String> { + match target { + #[cfg(any(target_os = "macos", target_os = "ios"))] + 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}"))?; + } + } + + #[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::Unmanaged => {} + + #[allow(unreachable_patterns)] + _ => {} + } + + Ok(()) +} + +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..a1e6e97ea --- /dev/null +++ b/crates/sage-apps/src/lifecycle/update/apply.rs @@ -0,0 +1,311 @@ +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, +) -> 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())); + } + + 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).await?; + + Ok(true) +} + +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, + }) + } +} 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..356cd6061 --- /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).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..020bd2379 --- /dev/null +++ b/crates/sage-apps/src/runtime/start.rs @@ -0,0 +1,386 @@ +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; +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, parse_data_store_id, + 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, + ) + .await +} + +async fn create_runtime( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + args: CreateRuntimeArgs, +) -> Result { + let app = 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(), + }; + + create_runtime_for_app(app_handle, apps_state, app, args, true).await +} + +async fn create_runtime_for_app( + app_handle: &AppHandle, + apps_state: &State<'_, AppsHostState>, + app: SharedSageApp, + args: CreateRuntimeArgs, + apply_user_lifecycle: bool, +) -> 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); + 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}"))?; + } + + 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 +} + +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) +} + +fn build_persistent_storage_target( + mut builder: WebviewBuilder, + app: &SharedSageApp, +) -> Result, String> { + let storage = app.with(|app| app.storage().clone()); + + match storage { + #[cfg(any(target_os = "macos", target_os = "ios"))] + SageAppStorage::AppleDataStore { identifier_hex } => { + let identifier = parse_data_store_id(&identifier_hex)?; + builder = builder.data_store_identifier(identifier); + } + + #[cfg(target_os = "windows")] + SageAppStorage::WindowsProfile { directory_name } => { + builder = builder.data_directory(data_directory_for(directory_name)); + } + + SageAppStorage::Unmanaged => {} + + #[allow(unreachable_patterns)] + _ => {} + } + + Ok(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..5e2480f0a --- /dev/null +++ b/crates/sage-apps/src/sandbox/builtin_apps.rs @@ -0,0 +1,254 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::{fs, path::PathBuf}; + +use anyhow::{Context, Result as AnyResult}; +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 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..33b63794d --- /dev/null +++ b/crates/sage-apps/src/sandbox/probes/isolation.rs @@ -0,0 +1,81 @@ +use tauri::{AppHandle, State}; + +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, +}; + +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; + + 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 Sage 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..4c8585849 --- /dev/null +++ b/crates/sage-apps/src/security.rs @@ -0,0 +1,8 @@ +mod bridge; +mod csp; +mod protocol; + +pub use csp::*; +pub use protocol::*; + +pub(crate) use bridge::*; 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/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..827ab5536 --- /dev/null +++ b/crates/sage-apps/src/system_apps.rs @@ -0,0 +1,285 @@ +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 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..10b9b2cab --- /dev/null +++ b/crates/sage-apps/src/types/app/user_apps.rs @@ -0,0 +1,491 @@ +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 + } +} + +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 }) + } +} 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..9096e5068 --- /dev/null +++ b/crates/sage-apps/src/types/app/view/common.rs @@ -0,0 +1,187 @@ +use anyhow::Context; +use serde::Serialize; +use specta::Type; +use url::Url; + +use crate::{ + SageAppCommon, SageAppIdentity, SageAppPackageManifest, SageAppPackageManifestPreview, + SageAppSnapshotView, SageAppUrl, SageAppWalletScope, SageGrantedPermissionsView, +}; + +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 = reqwest::get(resolved).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..506c394ad --- /dev/null +++ b/crates/sage-apps/src/types/network.rs @@ -0,0 +1,174 @@ +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 { + !host.is_empty() + && host.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '*' | ':' | '[' | ']') + }) + } + + #[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" + ); + } + } +} 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..86c7eb9ad --- /dev/null +++ b/crates/sage-apps/src/types/storage.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, 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 { directory_name: String }, + Unmanaged, +} 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..022c9286f --- /dev/null +++ b/crates/sage-apps/src/utils.rs @@ -0,0 +1,56 @@ +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; + +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 builtin_apps_root() -> PathBuf { + if let Some(path) = option_env!("SAGE_BUILTIN_APPS_DIST") { + return PathBuf::from(path); + } + + 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/Cargo.toml b/crates/sage-assets/Cargo.toml index 88269ada8..58c430f33 100644 --- a/crates/sage-assets/Cargo.toml +++ b/crates/sage-assets/Cargo.toml @@ -29,3 +29,4 @@ image = { workspace = true } webp = { workspace = true } tokio = { workspace = true, features = ["sync"] } base64 = { workspace = true } +anyhow = { workspace = true } 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..d91bcf8fa --- /dev/null +++ b/docs/apps_readme.md @@ -0,0 +1,112 @@ +# 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. \ No newline at end of file diff --git a/docs/generated/system-bridge-capabilities.md b/docs/generated/system-bridge-capabilities.md new file mode 100644 index 000000000..daea9e572 --- /dev/null +++ b/docs/generated/system-bridge-capabilities.md @@ -0,0 +1,366 @@ +# 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..cd1613f48 --- /dev/null +++ b/docs/generated/system-bridge-methods.md @@ -0,0 +1,140 @@ +# 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..c8ebfbf4b --- /dev/null +++ b/docs/generated/user-bridge-capabilities.md @@ -0,0 +1,380 @@ +# 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..e79061097 --- /dev/null +++ b/docs/generated/user-bridge-methods.md @@ -0,0 +1,146 @@ +# 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 7d0f9fc84..02039a9ad 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,17 @@ "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": "cargo run -p sage-apps --bin export_bridge_types >/dev/null", + "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 build:packages && pnpm run build:system-apps && pnpm run build:builtin-static", + "build": "pnpm run build:builtin-apps && tsc -b && vite build", + "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 +50,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 +114,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..f30550c74 --- /dev/null +++ b/packages/sage-app-sdk/cli/finalize-manifest.mjs @@ -0,0 +1,203 @@ +#!/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..1440ec1a3 --- /dev/null +++ b/packages/sage-app-sdk/src/bridge/core.ts @@ -0,0 +1,222 @@ +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..79f0933bb --- /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..bccfd560d --- /dev/null +++ b/packages/sage-app-sdk/src/index.ts @@ -0,0 +1,19 @@ +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..d29f7fb1f --- /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..4ef7ed38e --- /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..e54460c9e --- /dev/null +++ b/packages/sage-app-ui/src/components/AppIcon.tsx @@ -0,0 +1,130 @@ +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..f5ac1c8b0 --- /dev/null +++ b/packages/sage-app-ui/src/components/PermissionsEditor/permissionEntries.ts @@ -0,0 +1,196 @@ +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..6e1e981c2 --- /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..7f9cdb8f6 --- /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..4e28f86ea --- /dev/null +++ b/packages/sage-system-app-sdk/src/types.ts @@ -0,0 +1,110 @@ +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 f9aef00af..23e8d25e3 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..891d86051 --- /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/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 349d0028b..b01effa27 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,13 +36,25 @@ tracing = { workspace = true } anyhow = { workspace = true } rustls = { workspace = true } reqwest = { workspace = true } +zip = "8.5.1" +mime_guess = "2" +url = "2" # This is to ensure that the bindgen feature is enabled for the aws-lc-rs crate. # https://aws.github.io/aws-lc-rs/platform_support.html#tested-platforms aws-lc-rs = { version = "1", features = ["bindgen"] } tauri-plugin-sharesheet = "0.0.1" +futures = "0.3.32" +uuid = { version = "1.19.0", features = ["v4"] } +sha2 = "0.10.9" +hex = { workspace = true } +async-trait = "0.1.89" + +[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" @@ -57,5 +69,8 @@ tauri-plugin-sage = { workspace = true } tauri-build = { workspace = true, features = [] } glob = { workspace = true } +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.build-dependencies] +sage-apps = { workspace = true } + [package.metadata.cargo-machete] ignored = ["serde_json", "aws-lc-rs"] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 6ce22e224..574f8bcd2 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,14 @@ fn setup_x86_64_android_workaround() { fn main() { setup_x86_64_android_workaround(); + + println!("cargo:rerun-if-changed=../crates/sage-apps/src/bridge"); + println!("cargo:rerun-if-changed=../crates/sage-apps/src/permissions"); + println!("cargo:rerun-if-changed=../crates/sage-apps/src/build/docs.rs"); + + if let Err(err) = sage_apps::generate_docs() { + panic!("failed to generate Sage app docs: {err}"); + } + tauri_build::build(); } diff --git a/src-tauri/capabilities/apps.json b/src-tauri/capabilities/apps.json new file mode 100644 index 000000000..4a223fa66 --- /dev/null +++ b/src-tauri/capabilities/apps.json @@ -0,0 +1,10 @@ +{ + "$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..4c40d65ca --- /dev/null +++ b/src-tauri/capabilities/system-apps.json @@ -0,0 +1,12 @@ +{ + "$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..bebdec84b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,19 +10,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 +142,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 +220,81 @@ 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 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.conf.json b/src-tauri/tauri.conf.json index a165b5e91..3621ce462 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": { 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 45ad7bbf5..412a2be6b 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: number | 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..f123a0fc4 --- /dev/null +++ b/src/components/apps/AppTaskBar.tsx @@ -0,0 +1,420 @@ +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..f6dadb36a --- /dev/null +++ b/src/components/apps/AppsLaunchpad.tsx @@ -0,0 +1,551 @@ +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/pages/Apps.tsx b/src/pages/Apps.tsx new file mode 100644 index 000000000..e17a3a352 --- /dev/null +++ b/src/pages/Apps.tsx @@ -0,0 +1,269 @@ +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 ? : } + + +
+
+ ); +}