max / balanced_breakfast
20 files changed,
+565 insertions,
-57 deletions
| @@ -116,6 +116,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 116 | 116 | checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" | |
| 117 | 117 | ||
| 118 | 118 | [[package]] | |
| 119 | + | name = "arbitrary" | |
| 120 | + | version = "1.4.2" | |
| 121 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 122 | + | checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" | |
| 123 | + | dependencies = [ | |
| 124 | + | "derive_arbitrary", | |
| 125 | + | ] | |
| 126 | + | ||
| 127 | + | [[package]] | |
| 119 | 128 | name = "argon2" | |
| 120 | 129 | version = "0.5.3" | |
| 121 | 130 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -193,6 +202,7 @@ dependencies = [ | |||
| 193 | 202 | "tauri-build", | |
| 194 | 203 | "tauri-plugin-dialog", | |
| 195 | 204 | "tauri-plugin-shell", | |
| 205 | + | "tauri-plugin-updater", | |
| 196 | 206 | "tauri-plugin-window-state", | |
| 197 | 207 | "tokio", | |
| 198 | 208 | "toml 0.8.2", | |
| @@ -852,6 +862,17 @@ dependencies = [ | |||
| 852 | 862 | ] | |
| 853 | 863 | ||
| 854 | 864 | [[package]] | |
| 865 | + | name = "derive_arbitrary" | |
| 866 | + | version = "1.4.2" | |
| 867 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 868 | + | checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" | |
| 869 | + | dependencies = [ | |
| 870 | + | "proc-macro2", | |
| 871 | + | "quote", | |
| 872 | + | "syn 2.0.114", | |
| 873 | + | ] | |
| 874 | + | ||
| 875 | + | [[package]] | |
| 855 | 876 | name = "derive_more" | |
| 856 | 877 | version = "0.99.20" | |
| 857 | 878 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1104,6 +1125,17 @@ dependencies = [ | |||
| 1104 | 1125 | ] | |
| 1105 | 1126 | ||
| 1106 | 1127 | [[package]] | |
| 1128 | + | name = "filetime" | |
| 1129 | + | version = "0.2.27" | |
| 1130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1131 | + | checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" | |
| 1132 | + | dependencies = [ | |
| 1133 | + | "cfg-if", | |
| 1134 | + | "libc", | |
| 1135 | + | "libredox", | |
| 1136 | + | ] | |
| 1137 | + | ||
| 1138 | + | [[package]] | |
| 1107 | 1139 | name = "find-msvc-tools" | |
| 1108 | 1140 | version = "0.1.9" | |
| 1109 | 1141 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2465,6 +2497,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2465 | 2497 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | |
| 2466 | 2498 | ||
| 2467 | 2499 | [[package]] | |
| 2500 | + | name = "minisign-verify" | |
| 2501 | + | version = "0.2.5" | |
| 2502 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2503 | + | checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" | |
| 2504 | + | ||
| 2505 | + | [[package]] | |
| 2468 | 2506 | name = "miniz_oxide" | |
| 2469 | 2507 | version = "0.8.9" | |
| 2470 | 2508 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2819,6 +2857,18 @@ dependencies = [ | |||
| 2819 | 2857 | ] | |
| 2820 | 2858 | ||
| 2821 | 2859 | [[package]] | |
| 2860 | + | name = "objc2-osa-kit" | |
| 2861 | + | version = "0.3.2" | |
| 2862 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2863 | + | checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" | |
| 2864 | + | dependencies = [ | |
| 2865 | + | "bitflags 2.10.0", | |
| 2866 | + | "objc2", | |
| 2867 | + | "objc2-app-kit", | |
| 2868 | + | "objc2-foundation", | |
| 2869 | + | ] | |
| 2870 | + | ||
| 2871 | + | [[package]] | |
| 2822 | 2872 | name = "objc2-quartz-core" | |
| 2823 | 2873 | version = "0.3.2" | |
| 2824 | 2874 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2957,6 +3007,20 @@ dependencies = [ | |||
| 2957 | 3007 | ] | |
| 2958 | 3008 | ||
| 2959 | 3009 | [[package]] | |
| 3010 | + | name = "osakit" | |
| 3011 | + | version = "0.3.1" | |
| 3012 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3013 | + | checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" | |
| 3014 | + | dependencies = [ | |
| 3015 | + | "objc2", | |
| 3016 | + | "objc2-foundation", | |
| 3017 | + | "objc2-osa-kit", | |
| 3018 | + | "serde", | |
| 3019 | + | "serde_json", | |
| 3020 | + | "thiserror 2.0.18", | |
| 3021 | + | ] | |
| 3022 | + | ||
| 3023 | + | [[package]] | |
| 2960 | 3024 | name = "pango" | |
| 2961 | 3025 | version = "0.18.3" | |
| 2962 | 3026 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3641,15 +3705,20 @@ dependencies = [ | |||
| 3641 | 3705 | "http-body", | |
| 3642 | 3706 | "http-body-util", | |
| 3643 | 3707 | "hyper", | |
| 3708 | + | "hyper-rustls", | |
| 3644 | 3709 | "hyper-util", | |
| 3645 | 3710 | "js-sys", | |
| 3646 | 3711 | "log", | |
| 3647 | 3712 | "percent-encoding", | |
| 3648 | 3713 | "pin-project-lite", | |
| 3714 | + | "rustls", | |
| 3715 | + | "rustls-pki-types", | |
| 3716 | + | "rustls-platform-verifier", | |
| 3649 | 3717 | "serde", | |
| 3650 | 3718 | "serde_json", | |
| 3651 | 3719 | "sync_wrapper", | |
| 3652 | 3720 | "tokio", | |
| 3721 | + | "tokio-rustls", | |
| 3653 | 3722 | "tokio-util", | |
| 3654 | 3723 | "tower", | |
| 3655 | 3724 | "tower-http", | |
| @@ -3793,6 +3862,18 @@ dependencies = [ | |||
| 3793 | 3862 | ] | |
| 3794 | 3863 | ||
| 3795 | 3864 | [[package]] | |
| 3865 | + | name = "rustls-native-certs" | |
| 3866 | + | version = "0.8.3" | |
| 3867 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3868 | + | checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" | |
| 3869 | + | dependencies = [ | |
| 3870 | + | "openssl-probe", | |
| 3871 | + | "rustls-pki-types", | |
| 3872 | + | "schannel", | |
| 3873 | + | "security-framework", | |
| 3874 | + | ] | |
| 3875 | + | ||
| 3876 | + | [[package]] | |
| 3796 | 3877 | name = "rustls-pki-types" | |
| 3797 | 3878 | version = "1.14.0" | |
| 3798 | 3879 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3802,6 +3883,33 @@ dependencies = [ | |||
| 3802 | 3883 | ] | |
| 3803 | 3884 | ||
| 3804 | 3885 | [[package]] | |
| 3886 | + | name = "rustls-platform-verifier" | |
| 3887 | + | version = "0.6.2" | |
| 3888 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3889 | + | checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" | |
| 3890 | + | dependencies = [ | |
| 3891 | + | "core-foundation 0.10.1", | |
| 3892 | + | "core-foundation-sys", | |
| 3893 | + | "jni", | |
| 3894 | + | "log", | |
| 3895 | + | "once_cell", | |
| 3896 | + | "rustls", | |
| 3897 | + | "rustls-native-certs", | |
| 3898 | + | "rustls-platform-verifier-android", | |
| 3899 | + | "rustls-webpki", | |
| 3900 | + | "security-framework", | |
| 3901 | + | "security-framework-sys", | |
| 3902 | + | "webpki-root-certs", | |
| 3903 | + | "windows-sys 0.61.2", | |
| 3904 | + | ] | |
| 3905 | + | ||
| 3906 | + | [[package]] | |
| 3907 | + | name = "rustls-platform-verifier-android" | |
| 3908 | + | version = "0.1.1" | |
| 3909 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3910 | + | checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" | |
| 3911 | + | ||
| 3912 | + | [[package]] | |
| 3805 | 3913 | name = "rustls-webpki" | |
| 3806 | 3914 | version = "0.103.9" | |
| 3807 | 3915 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4680,13 +4788,13 @@ dependencies = [ | |||
| 4680 | 4788 | "reqwest 0.12.28", | |
| 4681 | 4789 | "serde", | |
| 4682 | 4790 | "serde_json", | |
| 4683 | - | "sha2", | |
| 4684 | - | "thiserror 1.0.69", | |
| 4791 | + | "thiserror 2.0.18", | |
| 4685 | 4792 | "tokio", | |
| 4686 | 4793 | "tracing", | |
| 4687 | 4794 | "unicode-normalization", | |
| 4688 | 4795 | "urlencoding", | |
| 4689 | 4796 | "uuid", | |
| 4797 | + | "zeroize", | |
| 4690 | 4798 | ] | |
| 4691 | 4799 | ||
| 4692 | 4800 | [[package]] | |
| @@ -4786,6 +4894,17 @@ dependencies = [ | |||
| 4786 | 4894 | ] | |
| 4787 | 4895 | ||
| 4788 | 4896 | [[package]] | |
| 4897 | + | name = "tar" | |
| 4898 | + | version = "0.4.44" | |
| 4899 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4900 | + | checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" | |
| 4901 | + | dependencies = [ | |
| 4902 | + | "filetime", | |
| 4903 | + | "libc", | |
| 4904 | + | "xattr", | |
| 4905 | + | ] | |
| 4906 | + | ||
| 4907 | + | [[package]] | |
| 4789 | 4908 | name = "target-lexicon" | |
| 4790 | 4909 | version = "0.12.16" | |
| 4791 | 4910 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4984,6 +5103,39 @@ dependencies = [ | |||
| 4984 | 5103 | ] | |
| 4985 | 5104 | ||
| 4986 | 5105 | [[package]] | |
| 5106 | + | name = "tauri-plugin-updater" | |
| 5107 | + | version = "2.10.0" | |
| 5108 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5109 | + | checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" | |
| 5110 | + | dependencies = [ | |
| 5111 | + | "base64 0.22.1", | |
| 5112 | + | "dirs", | |
| 5113 | + | "flate2", | |
| 5114 | + | "futures-util", | |
| 5115 | + | "http", | |
| 5116 | + | "infer", | |
| 5117 | + | "log", | |
| 5118 | + | "minisign-verify", | |
| 5119 | + | "osakit", | |
| 5120 | + | "percent-encoding", | |
| 5121 | + | "reqwest 0.13.2", | |
| 5122 | + | "rustls", | |
| 5123 | + | "semver", | |
| 5124 | + | "serde", | |
| 5125 | + | "serde_json", | |
| 5126 | + | "tar", | |
| 5127 | + | "tauri", | |
| 5128 | + | "tauri-plugin", | |
| 5129 | + | "tempfile", | |
| 5130 | + | "thiserror 2.0.18", | |
| 5131 | + | "time", | |
| 5132 | + | "tokio", | |
| 5133 | + | "url", | |
| 5134 | + | "windows-sys 0.60.2", | |
| 5135 | + | "zip", | |
| 5136 | + | ] | |
| 5137 | + | ||
| 5138 | + | [[package]] | |
| 4987 | 5139 | name = "tauri-plugin-window-state" | |
| 4988 | 5140 | version = "2.4.1" | |
| 4989 | 5141 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5973,6 +6125,15 @@ dependencies = [ | |||
| 5973 | 6125 | ] | |
| 5974 | 6126 | ||
| 5975 | 6127 | [[package]] | |
| 6128 | + | name = "webpki-root-certs" | |
| 6129 | + | version = "1.0.6" | |
| 6130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6131 | + | checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" | |
| 6132 | + | dependencies = [ | |
| 6133 | + | "rustls-pki-types", | |
| 6134 | + | ] | |
| 6135 | + | ||
| 6136 | + | [[package]] | |
| 5976 | 6137 | name = "webpki-roots" | |
| 5977 | 6138 | version = "0.26.11" | |
| 5978 | 6139 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -6654,6 +6815,16 @@ dependencies = [ | |||
| 6654 | 6815 | ] | |
| 6655 | 6816 | ||
| 6656 | 6817 | [[package]] | |
| 6818 | + | name = "xattr" | |
| 6819 | + | version = "1.6.1" | |
| 6820 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6821 | + | checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" | |
| 6822 | + | dependencies = [ | |
| 6823 | + | "libc", | |
| 6824 | + | "rustix", | |
| 6825 | + | ] | |
| 6826 | + | ||
| 6827 | + | [[package]] | |
| 6657 | 6828 | name = "yoke" | |
| 6658 | 6829 | version = "0.8.1" | |
| 6659 | 6830 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -6757,6 +6928,18 @@ dependencies = [ | |||
| 6757 | 6928 | ] | |
| 6758 | 6929 | ||
| 6759 | 6930 | [[package]] | |
| 6931 | + | name = "zip" | |
| 6932 | + | version = "4.6.1" | |
| 6933 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6934 | + | checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" | |
| 6935 | + | dependencies = [ | |
| 6936 | + | "arbitrary", | |
| 6937 | + | "crc32fast", | |
| 6938 | + | "indexmap 2.13.0", | |
| 6939 | + | "memchr", | |
| 6940 | + | ] | |
| 6941 | + | ||
| 6942 | + | [[package]] | |
| 6760 | 6943 | name = "zmij" | |
| 6761 | 6944 | version = "1.0.19" | |
| 6762 | 6945 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -10,7 +10,7 @@ members = [ | |||
| 10 | 10 | default-members = ["src-tauri"] | |
| 11 | 11 | ||
| 12 | 12 | [workspace.package] | |
| 13 | - | version = "0.2.1" | |
| 13 | + | version = "0.3.0" | |
| 14 | 14 | edition = "2021" | |
| 15 | 15 | authors = ["BalancedBreakfast Contributors"] | |
| 16 | 16 | license-file = "LICENSE" | |
| @@ -50,3 +50,4 @@ bb-core = { path = "crates/bb-core" } | |||
| 50 | 50 | bb-feed = { path = "crates/bb-feed" } | |
| 51 | 51 | bb-db = { path = "crates/bb-db" } | |
| 52 | 52 | synckit-client = { path = "../synckit-client" } | |
| 53 | + | tauri-plugin-updater = "2" |
| @@ -96,6 +96,7 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc< | |||
| 96 | 96 | check_request_limit(&counter)?; | |
| 97 | 97 | ||
| 98 | 98 | let response = ureq::get(url) | |
| 99 | + | .set("User-Agent", "BalancedBreakfast/0.2.1 (feed reader)") | |
| 99 | 100 | .timeout(HTTP_TIMEOUT) | |
| 100 | 101 | .call() | |
| 101 | 102 | .map_err(|e| format!("HTTP request failed: {}", e))?; | |
| @@ -118,6 +119,7 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc< | |||
| 118 | 119 | check_request_limit(&counter)?; | |
| 119 | 120 | ||
| 120 | 121 | let response = ureq::get(url) | |
| 122 | + | .set("User-Agent", "BalancedBreakfast/0.2.1 (feed reader)") | |
| 121 | 123 | .timeout(HTTP_TIMEOUT) | |
| 122 | 124 | .call() | |
| 123 | 125 | .map_err(|e| format!("HTTP request failed: {}", e))?; |
| @@ -147,6 +147,10 @@ impl RhaiPluginManager { | |||
| 147 | 147 | /// catching infinite loops. | |
| 148 | 148 | /// - `max_expr_depths(128, 128)`: limits AST nesting depth for both expressions | |
| 149 | 149 | /// and functions, preventing stack overflows from deeply recursive scripts. | |
| 150 | + | /// - `max_call_levels(32)`: limits function call nesting depth. | |
| 151 | + | /// - `max_string_size(10_000)`: prevents scripts from building huge strings. | |
| 152 | + | /// - `max_array_size(1_000)`: prevents scripts from building huge arrays. | |
| 153 | + | /// - `max_map_size(100)`: prevents scripts from building huge maps. | |
| 150 | 154 | /// - HTTP limits: 15s timeout, 2 MB response cap, 100 requests per fetch, | |
| 151 | 155 | /// http/https only, no localhost/internal addresses. | |
| 152 | 156 | pub fn new() -> Self { | |
| @@ -154,6 +158,10 @@ impl RhaiPluginManager { | |||
| 154 | 158 | ||
| 155 | 159 | engine.set_max_expr_depths(128, 128); | |
| 156 | 160 | engine.set_max_operations(100_000); | |
| 161 | + | engine.set_max_call_levels(32); | |
| 162 | + | engine.set_max_string_size(10_000); | |
| 163 | + | engine.set_max_array_size(1_000); | |
| 164 | + | engine.set_max_map_size(100); | |
| 157 | 165 | ||
| 158 | 166 | let request_counter = Arc::new(AtomicUsize::new(0)); | |
| 159 | 167 | register_host_functions(&mut engine, request_counter.clone()); | |
| @@ -239,6 +247,10 @@ pub fn create_engine() -> Engine { | |||
| 239 | 247 | let mut engine = Engine::new(); | |
| 240 | 248 | engine.set_max_expr_depths(128, 128); | |
| 241 | 249 | engine.set_max_operations(100_000); | |
| 250 | + | engine.set_max_call_levels(32); | |
| 251 | + | engine.set_max_string_size(10_000); | |
| 252 | + | engine.set_max_array_size(1_000); | |
| 253 | + | engine.set_max_map_size(100); | |
| 242 | 254 | let counter = Arc::new(AtomicUsize::new(0)); | |
| 243 | 255 | register_host_functions(&mut engine, counter); | |
| 244 | 256 | engine | |
| @@ -346,4 +358,91 @@ mod tests { | |||
| 346 | 358 | let e = RhaiPluginError::JsonError("unexpected token".into()); | |
| 347 | 359 | assert_eq!(e.to_string(), "JSON parse error: unexpected token"); | |
| 348 | 360 | } | |
| 361 | + | ||
| 362 | + | #[test] | |
| 363 | + | fn sandbox_enforces_operation_limit() { | |
| 364 | + | let manager = RhaiPluginManager::new(); | |
| 365 | + | let result = manager.engine.eval::<()>("let x = 0; loop { x += 1; }"); | |
| 366 | + | assert!(result.is_err(), "infinite loop should be stopped by operation limit"); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn sandbox_enforces_call_level_limit() { | |
| 371 | + | let manager = RhaiPluginManager::new(); | |
| 372 | + | // Recursive function that exceeds 32 call levels | |
| 373 | + | let script = r#" | |
| 374 | + | fn recurse(n) { recurse(n + 1) } | |
| 375 | + | recurse(0) | |
| 376 | + | "#; | |
| 377 | + | let result = manager.engine.eval::<()>(script); | |
| 378 | + | assert!(result.is_err(), "deep recursion should be stopped by call level limit"); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | #[test] | |
| 382 | + | fn sandbox_enforces_string_size_limit() { | |
| 383 | + | let manager = RhaiPluginManager::new(); | |
| 384 | + | let script = r#" | |
| 385 | + | let s = "x"; | |
| 386 | + | for i in 0..20 { s += s; } | |
| 387 | + | s | |
| 388 | + | "#; | |
| 389 | + | let result = manager.engine.eval::<String>(script); | |
| 390 | + | assert!(result.is_err(), "huge string should be stopped by string size limit"); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | #[test] | |
| 394 | + | fn sandbox_enforces_array_size_limit() { | |
| 395 | + | let manager = RhaiPluginManager::new(); | |
| 396 | + | let script = r#" | |
| 397 | + | let a = []; | |
| 398 | + | for i in 0..2000 { a.push(i); } | |
| 399 | + | a | |
| 400 | + | "#; | |
| 401 | + | let result = manager.engine.eval::<rhai::Array>(script); | |
| 402 | + | assert!(result.is_err(), "huge array should be stopped by array size limit"); | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | #[test] | |
| 406 | + | fn sandbox_enforces_map_size_limit() { | |
| 407 | + | let manager = RhaiPluginManager::new(); | |
| 408 | + | // Build a map by merging — Rhai checks map size limits on object map literals | |
| 409 | + | // and merge operations, not index assignment. Use += to trigger the check. | |
| 410 | + | let mut entries = String::new(); | |
| 411 | + | for i in 0..200 { | |
| 412 | + | entries.push_str(&format!("m += #{{ k{i}: {i} }};\n")); | |
| 413 | + | } | |
| 414 | + | let script = format!("let m = #{{}};\n{entries}m"); | |
| 415 | + | let result = manager.engine.eval::<rhai::Map>(&script); | |
| 416 | + | assert!(result.is_err(), "huge map should be stopped by map size limit"); | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | #[test] | |
| 420 | + | fn create_engine_enforces_operation_limit() { | |
| 421 | + | let engine = create_engine(); | |
| 422 | + | let result = engine.eval::<()>("let x = 0; loop { x += 1; }"); | |
| 423 | + | assert!(result.is_err(), "infinite loop should be stopped by operation limit"); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | #[test] | |
| 427 | + | fn create_engine_enforces_call_level_limit() { | |
| 428 | + | let engine = create_engine(); | |
| 429 | + | let script = r#" | |
| 430 | + | fn recurse(n) { recurse(n + 1) } | |
| 431 | + | recurse(0) | |
| 432 | + | "#; | |
| 433 | + | let result = engine.eval::<()>(script); | |
| 434 | + | assert!(result.is_err(), "deep recursion should be stopped by call level limit"); | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn create_engine_enforces_string_size_limit() { | |
| 439 | + | let engine = create_engine(); | |
| 440 | + | let script = r#" | |
| 441 | + | let s = "x"; | |
| 442 | + | for i in 0..20 { s += s; } | |
| 443 | + | s | |
| 444 | + | "#; | |
| 445 | + | let result = engine.eval::<String>(script); | |
| 446 | + | assert!(result.is_err(), "huge string should be stopped by string size limit"); | |
| 447 | + | } | |
| 349 | 448 | } |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "balanced-breakfast-desktop" | |
| 3 | - | version = "0.2.1" | |
| 3 | + | version = "0.3.0" | |
| 4 | 4 | edition = "2021" | |
| 5 | 5 | ||
| 6 | 6 | [[bin]] | |
| @@ -62,6 +62,7 @@ tracing-subscriber.workspace = true | |||
| 62 | 62 | [target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] | |
| 63 | 63 | tauri-plugin-shell = "2.3.5" | |
| 64 | 64 | tauri-plugin-window-state = "2.4.1" | |
| 65 | + | tauri-plugin-updater = { workspace = true } | |
| 65 | 66 | ||
| 66 | 67 | [dev-dependencies] | |
| 67 | 68 | bb-core.workspace = true |
| @@ -115,12 +115,11 @@ body { | |||
| 115 | 115 | cursor: pointer; | |
| 116 | 116 | font-weight: 500; | |
| 117 | 117 | font-size: 0.875rem; | |
| 118 | - | transition: all 0.2s; | |
| 118 | + | transition: background-color 0.2s, border-color 0.2s; | |
| 119 | 119 | } | |
| 120 | 120 | .btn:hover { | |
| 121 | 121 | background-color: var(--bg-tertiary); | |
| 122 | 122 | border-color: var(--border-dark); | |
| 123 | - | box-shadow: 0 1px 3px var(--shadow); | |
| 124 | 123 | } | |
| 125 | 124 | .btn-primary { | |
| 126 | 125 | background-color: var(--accent); | |
| @@ -826,6 +825,16 @@ body { | |||
| 826 | 825 | border-top: 1px solid var(--border); | |
| 827 | 826 | } | |
| 828 | 827 | ||
| 828 | + | /* Settings modal */ | |
| 829 | + | .settings-content .form-group { margin-bottom: 1rem; } | |
| 830 | + | .settings-content .form-group label { | |
| 831 | + | display: block; | |
| 832 | + | margin-bottom: 0.35rem; | |
| 833 | + | color: var(--text-secondary); | |
| 834 | + | font-size: 0.8rem; | |
| 835 | + | font-weight: 500; | |
| 836 | + | } | |
| 837 | + | ||
| 829 | 838 | /* Scrollbar */ | |
| 830 | 839 | ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| 831 | 840 | ::-webkit-scrollbar-track { background: var(--bg-secondary); } |
| @@ -22,6 +22,7 @@ | |||
| 22 | 22 | </select> | |
| 23 | 23 | <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds">Refresh</button> | |
| 24 | 24 | <button id="add-feed-btn" class="btn btn-success" title="Add a new feed source">+ Add Feed</button> | |
| 25 | + | <button class="btn btn-small" title="Settings" onclick="BB.app.showSettings()">⚙</button> | |
| 25 | 26 | </div> | |
| 26 | 27 | </header> | |
| 27 | 28 | ||
| @@ -86,6 +87,7 @@ | |||
| 86 | 87 | <script src="js/detail.js"></script> | |
| 87 | 88 | <script src="js/feeds.js"></script> | |
| 88 | 89 | <script src="js/settings-sync.js"></script> | |
| 90 | + | <script src="js/updater.js"></script> | |
| 89 | 91 | <script src="js/app.js"></script> | |
| 90 | 92 | </body> | |
| 91 | 93 | </html> |
| @@ -16,7 +16,6 @@ | |||
| 16 | 16 | async function init() { | |
| 17 | 17 | // Load theme before rendering content | |
| 18 | 18 | await BB.themes.init(); | |
| 19 | - | await BB.themes.buildSelector(); | |
| 20 | 19 | ||
| 21 | 20 | // Load data | |
| 22 | 21 | await BB.sources.load(); | |
| @@ -34,7 +33,7 @@ | |||
| 34 | 33 | const searchSpinner = document.getElementById('search-spinner'); | |
| 35 | 34 | searchInput.addEventListener('input', BB.utils.debounce(async () => { | |
| 36 | 35 | BB.state.set('currentSearch', searchInput.value); | |
| 37 | - | BB.state.set('currentPage', 0); | |
| 36 | + | BB.state.resetPagination(); | |
| 38 | 37 | searchSpinner.classList.add('active'); | |
| 39 | 38 | await BB.items.load(); | |
| 40 | 39 | searchSpinner.classList.remove('active'); | |
| @@ -43,7 +42,7 @@ | |||
| 43 | 42 | // Sort select | |
| 44 | 43 | document.getElementById('sort-select').addEventListener('change', (e) => { | |
| 45 | 44 | BB.state.set('currentOrder', e.target.value); | |
| 46 | - | BB.state.set('currentPage', 0); | |
| 45 | + | BB.state.resetPagination(); | |
| 47 | 46 | BB.items.load(); | |
| 48 | 47 | }); | |
| 49 | 48 | ||
| @@ -165,24 +164,44 @@ | |||
| 165 | 164 | listen('menu:export_opml', () => BB.feeds.exportOpml()); | |
| 166 | 165 | listen('menu:view_all', () => { | |
| 167 | 166 | BB.state.set('currentSource', ''); | |
| 168 | - | BB.state.set('currentPage', 0); | |
| 167 | + | BB.state.resetPagination(); | |
| 169 | 168 | BB.sources.render(BB.state.sources); | |
| 170 | 169 | BB.items.load(); | |
| 171 | 170 | }); | |
| 172 | 171 | listen('menu:view_unread', () => { | |
| 173 | 172 | BB.state.set('currentOrder', 'unread'); | |
| 174 | 173 | document.getElementById('sort-select').value = 'unread'; | |
| 175 | - | BB.state.set('currentPage', 0); | |
| 174 | + | BB.state.resetPagination(); | |
| 176 | 175 | BB.items.load(); | |
| 177 | 176 | }); | |
| 178 | 177 | listen('menu:view_starred', () => { | |
| 179 | 178 | BB.state.set('currentOrder', 'starred'); | |
| 180 | 179 | document.getElementById('sort-select').value = 'starred'; | |
| 181 | - | BB.state.set('currentPage', 0); | |
| 180 | + | BB.state.resetPagination(); | |
| 182 | 181 | BB.items.load(); | |
| 183 | 182 | }); | |
| 184 | 183 | } | |
| 185 | 184 | ||
| 185 | + | /** Show the settings modal with theme selector. */ | |
| 186 | + | function showSettings() { | |
| 187 | + | const body = document.getElementById('modal-body'); | |
| 188 | + | const title = document.getElementById('modal-title'); | |
| 189 | + | title.textContent = 'Settings'; | |
| 190 | + | body.innerHTML = ` | |
| 191 | + | <div class="settings-content"> | |
| 192 | + | <div class="form-group"> | |
| 193 | + | <label>Theme</label> | |
| 194 | + | <div id="settings-theme-container"></div> | |
| 195 | + | </div> | |
| 196 | + | <div class="form-actions"> | |
| 197 | + | <button class="btn" onclick="BB.ui.closeModal();">Close</button> | |
| 198 | + | </div> | |
| 199 | + | </div> | |
| 200 | + | `; | |
| 201 | + | BB.themes.buildSelector(document.getElementById('settings-theme-container')); | |
| 202 | + | BB.ui.openModal(); | |
| 203 | + | } | |
| 204 | + | ||
| 186 | 205 | /** Show the keyboard shortcuts help modal. */ | |
| 187 | 206 | function showHelp() { | |
| 188 | 207 | const body = document.getElementById('modal-body'); | |
| @@ -225,13 +244,13 @@ | |||
| 225 | 244 | <div class="welcome-content"> | |
| 226 | 245 | <p>Your personal feed reader for RSS, Atom, podcasts, and more.</p> | |
| 227 | 246 | <h3>Getting Started</h3> | |
| 228 | - | <p>Click <strong>+ Add Feed</strong> to subscribe to your first source. You can also import existing subscriptions via <strong>File > Import OPML</strong>.</p> | |
| 247 | + | <p>Subscribe to feeds with the <strong>+ Add Feed</strong> button, or import existing subscriptions via <strong>File > Import OPML</strong>.</p> | |
| 229 | 248 | <h3>Keyboard Shortcuts</h3> | |
| 230 | - | <p>Press <kbd>?</kbd> anytime to see all shortcuts. Use <kbd>j</kbd>/<kbd>k</kbd> to navigate, <kbd>s</kbd> to star, <kbd>r</kbd> to toggle read.</p> | |
| 231 | - | <h3>Themes</h3> | |
| 232 | - | <p>Balanced Breakfast supports multiple themes. Switch via the menu bar.</p> | |
| 249 | + | <p>Press <kbd>?</kbd> anytime to see all shortcuts. Navigate with <kbd>j</kbd>/<kbd>k</kbd>, star with <kbd>s</kbd>, toggle read with <kbd>r</kbd>.</p> | |
| 250 | + | <h3>Themes & Settings</h3> | |
| 251 | + | <p>Click the gear icon (<strong>⚙</strong>) in the header to change themes and configure preferences.</p> | |
| 233 | 252 | <div class="welcome-cta"> | |
| 234 | - | <button class="btn btn-success" onclick="BB.ui.closeModal(); BB.feeds.openAddFeed();">Add Your First Feed</button> | |
| 253 | + | <button class="btn btn-success" onclick="BB.feeds.openAddFeed();">Add Your First Feed</button> | |
| 235 | 254 | <button class="btn" onclick="BB.ui.closeModal();">Explore First</button> | |
| 236 | 255 | </div> | |
| 237 | 256 | </div> | |
| @@ -240,7 +259,7 @@ | |||
| 240 | 259 | invoke('set_config', { key: 'bb-welcomed', value: '1' }); | |
| 241 | 260 | } | |
| 242 | 261 | ||
| 243 | - | BB.app = { init, showHelp, showWelcome }; | |
| 262 | + | BB.app = { init, showHelp, showSettings, showWelcome }; | |
| 244 | 263 | ||
| 245 | 264 | // Auto-init when DOM ready | |
| 246 | 265 | if (document.readyState === 'loading') { |
| @@ -174,5 +174,17 @@ | |||
| 174 | 174 | if (firstInput) firstInput.focus(); | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | - | BB.ui = { showToast, showProgress, openModal, closeModal, openFormModal }; | |
| 177 | + | /** | |
| 178 | + | * Show an error toast with a Retry button. | |
| 179 | + | * @param {string} message - Error message. | |
| 180 | + | * @param {function} retryFn - Called when Retry is clicked. | |
| 181 | + | */ | |
| 182 | + | function showErrorWithRetry(message, retryFn) { | |
| 183 | + | showToast(message, 'error', { | |
| 184 | + | action: { label: 'Retry', fn: retryFn }, | |
| 185 | + | duration: 5000, | |
| 186 | + | }); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | BB.ui = { showToast, showProgress, openModal, closeModal, openFormModal, showErrorWithRetry }; | |
| 178 | 190 | })(); |
| @@ -30,10 +30,7 @@ | |||
| 30 | 30 | panel.style.display = 'flex'; | |
| 31 | 31 | renderDetail(item); | |
| 32 | 32 | } catch (err) { | |
| 33 | - | BB.ui.showToast('Failed to load item: ' + err, 'error', { | |
| 34 | - | action: { label: 'Retry', fn: () => load(id) }, | |
| 35 | - | duration: 5000, | |
| 36 | - | }); | |
| 33 | + | BB.ui.showErrorWithRetry('Failed to load item: ' + err, () => load(id)); | |
| 37 | 34 | } | |
| 38 | 35 | } | |
| 39 | 36 |
| @@ -126,10 +126,7 @@ | |||
| 126 | 126 | let msg = 'Fetched ' + result.itemsFetched + ' item' + (result.itemsFetched !== 1 ? 's' : ''); | |
| 127 | 127 | if (result.errors && result.errors.length > 0) { | |
| 128 | 128 | msg += ' (' + result.errors.length + ' feed' + (result.errors.length !== 1 ? 's' : '') + ' failed)'; | |
| 129 | - | BB.ui.showToast(msg, 'error', { | |
| 130 | - | action: { label: 'Retry', fn: refresh }, | |
| 131 | - | duration: 5000, | |
| 132 | - | }); | |
| 129 | + | BB.ui.showErrorWithRetry(msg, refresh); | |
| 133 | 130 | } else { | |
| 134 | 131 | BB.ui.showToast(msg); | |
| 135 | 132 | } | |
| @@ -138,10 +135,7 @@ | |||
| 138 | 135 | BB.items.load(); | |
| 139 | 136 | } catch (err) { | |
| 140 | 137 | progress.set(100); | |
| 141 | - | BB.ui.showToast('Failed to refresh: ' + err, 'error', { | |
| 142 | - | action: { label: 'Retry', fn: refresh }, | |
| 143 | - | duration: 5000, | |
| 144 | - | }); | |
| 138 | + | BB.ui.showErrorWithRetry('Failed to refresh: ' + err, refresh); | |
| 145 | 139 | } finally { | |
| 146 | 140 | setTimeout(() => progress.remove(), 400); | |
| 147 | 141 | btn.disabled = false; |
| @@ -39,10 +39,7 @@ | |||
| 39 | 39 | } | |
| 40 | 40 | } catch (err) { | |
| 41 | 41 | clearSkeletons(); | |
| 42 | - | BB.ui.showToast('Failed to load items: ' + err, 'error', { | |
| 43 | - | action: { label: 'Retry', fn: () => load(append) }, | |
| 44 | - | duration: 5000, | |
| 45 | - | }); | |
| 42 | + | BB.ui.showErrorWithRetry('Failed to load items: ' + err, () => load(append)); | |
| 46 | 43 | } | |
| 47 | 44 | } | |
| 48 | 45 |
| @@ -198,8 +198,7 @@ | |||
| 198 | 198 | BB.state.set('currentQueryFeed', id); | |
| 199 | 199 | BB.state.set('currentSource', ''); | |
| 200 | 200 | BB.state.set('currentTag', ''); | |
| 201 | - | BB.state.set('currentPage', 0); | |
| 202 | - | BB.state.set('selectedItemId', null); | |
| 201 | + | BB.state.resetPagination(true); | |
| 203 | 202 | BB.sources.render(BB.state.sources); | |
| 204 | 203 | BB.items.load(); | |
| 205 | 204 | } |
| @@ -21,10 +21,7 @@ | |||
| 21 | 21 | // Load query feeds in parallel (non-blocking). | |
| 22 | 22 | BB.queryFeeds.load(); | |
| 23 | 23 | } catch (err) { | |
| 24 | - | BB.ui.showToast('Failed to load sources: ' + err, 'error', { | |
| 25 | - | action: { label: 'Retry', fn: load }, | |
| 26 | - | duration: 5000, | |
| 27 | - | }); | |
| 24 | + | BB.ui.showErrorWithRetry('Failed to load sources: ' + err, load); | |
| 28 | 25 | } | |
| 29 | 26 | } | |
| 30 | 27 | ||
| @@ -194,8 +191,7 @@ | |||
| 194 | 191 | function select(sourceId) { | |
| 195 | 192 | BB.state.set('currentQueryFeed', null); | |
| 196 | 193 | BB.state.set('currentSource', sourceId); | |
| 197 | - | BB.state.set('currentPage', 0); | |
| 198 | - | BB.state.set('selectedItemId', null); | |
| 194 | + | BB.state.resetPagination(true); | |
| 199 | 195 | render(BB.state.sources); | |
| 200 | 196 | BB.items.load(); | |
| 201 | 197 | } | |
| @@ -293,8 +289,7 @@ | |||
| 293 | 289 | /** Select a tag filter: reset pagination and reload items. */ | |
| 294 | 290 | function selectTag(tag) { | |
| 295 | 291 | BB.state.set('currentTag', tag); | |
| 296 | - | BB.state.set('currentPage', 0); | |
| 297 | - | BB.state.set('selectedItemId', null); | |
| 292 | + | BB.state.resetPagination(true); | |
| 298 | 293 | renderTagBar(); | |
| 299 | 294 | BB.items.load(); | |
| 300 | 295 | } |
| @@ -62,12 +62,22 @@ | |||
| 62 | 62 | return data[key]; | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | /** | |
| 66 | + | * Reset pagination to the first page, optionally clearing selection. | |
| 67 | + | * @param {boolean} [clearSelection=false] - Also clear selectedItemId. | |
| 68 | + | */ | |
| 69 | + | function resetPagination(clearSelection) { | |
| 70 | + | set('currentPage', 0); | |
| 71 | + | if (clearSelection) set('selectedItemId', null); | |
| 72 | + | } | |
| 73 | + | ||
| 65 | 74 | // Proxy for direct access: BB.state.sources etc. | |
| 66 | 75 | BB.state = new Proxy(data, { | |
| 67 | 76 | get(target, prop) { | |
| 68 | 77 | if (prop === 'subscribe') return subscribe; | |
| 69 | 78 | if (prop === 'set') return set; | |
| 70 | 79 | if (prop === 'get') return get; | |
| 80 | + | if (prop === 'resetPagination') return resetPagination; | |
| 71 | 81 | return target[prop]; | |
| 72 | 82 | }, | |
| 73 | 83 | set(target, prop, value) { |
| @@ -125,9 +125,12 @@ | |||
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | 127 | /** | |
| 128 | - | * Build theme selector UI in the header | |
| 128 | + | * Build theme selector UI into a given container element. | |
| 129 | + | * @param {HTMLElement} container - Element to append the selector into. | |
| 129 | 130 | */ | |
| 130 | - | async function buildSelector() { | |
| 131 | + | async function buildSelector(container) { | |
| 132 | + | if (!container) return; | |
| 133 | + | ||
| 131 | 134 | let themes; | |
| 132 | 135 | try { | |
| 133 | 136 | themes = await invoke('list_themes'); | |
| @@ -142,7 +145,7 @@ | |||
| 142 | 145 | ||
| 143 | 146 | const select = document.createElement('select'); | |
| 144 | 147 | select.id = 'theme-selector'; | |
| 145 | - | select.className = 'sort-select'; | |
| 148 | + | select.className = 'form-input'; | |
| 146 | 149 | ||
| 147 | 150 | const addGroup = (label, items) => { | |
| 148 | 151 | const group = document.createElement('optgroup'); | |
| @@ -162,12 +165,7 @@ | |||
| 162 | 165 | if (highContrast.length > 0) addGroup('High Contrast', highContrast); | |
| 163 | 166 | ||
| 164 | 167 | select.addEventListener('change', () => loadTheme(select.value)); | |
| 165 | - | ||
| 166 | - | // Insert before the refresh button | |
| 167 | - | const actions = document.querySelector('.header-actions'); | |
| 168 | - | if (actions) { | |
| 169 | - | actions.insertBefore(select, actions.firstChild); | |
| 170 | - | } | |
| 168 | + | container.appendChild(select); | |
| 171 | 169 | } | |
| 172 | 170 | ||
| 173 | 171 | // Listen for system theme changes |
| @@ -0,0 +1,137 @@ | |||
| 1 | + | /** | |
| 2 | + | * Balanced Breakfast - OTA Update Notification | |
| 3 | + | * Listens for update-available events from the Rust backend and shows | |
| 4 | + | * a notification banner with Install / Dismiss buttons. The user must | |
| 5 | + | * explicitly consent before any download or installation happens. | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | (function() { | |
| 9 | + | 'use strict'; | |
| 10 | + | ||
| 11 | + | let updateBannerShown = false; | |
| 12 | + | let pendingUpdate = null; | |
| 13 | + | ||
| 14 | + | const BTN_STYLE = [ | |
| 15 | + | 'padding: 0.25rem 0.5rem', | |
| 16 | + | 'border-radius: 4px', | |
| 17 | + | 'cursor: pointer', | |
| 18 | + | 'font-size: 0.8rem', | |
| 19 | + | ].join(';'); | |
| 20 | + | ||
| 21 | + | function showUpdateBanner(version, body, update) { | |
| 22 | + | if (updateBannerShown) return; | |
| 23 | + | updateBannerShown = true; | |
| 24 | + | pendingUpdate = update || null; | |
| 25 | + | ||
| 26 | + | const banner = document.createElement('div'); | |
| 27 | + | banner.id = 'update-banner'; | |
| 28 | + | banner.style.cssText = [ | |
| 29 | + | 'position: fixed', | |
| 30 | + | 'bottom: 1rem', | |
| 31 | + | 'right: 1rem', | |
| 32 | + | 'background: var(--bg-secondary)', | |
| 33 | + | 'border: 1px solid var(--border)', | |
| 34 | + | 'border-radius: 8px', | |
| 35 | + | 'padding: 0.75rem 1rem', | |
| 36 | + | 'z-index: 9999', | |
| 37 | + | 'max-width: 320px', | |
| 38 | + | 'box-shadow: 0 4px 12px var(--shadow)', | |
| 39 | + | 'font-family: var(--font-body, sans-serif)', | |
| 40 | + | 'font-size: 0.875rem', | |
| 41 | + | ].join(';'); | |
| 42 | + | ||
| 43 | + | const title = document.createElement('div'); | |
| 44 | + | title.style.cssText = 'font-weight: 600; margin-bottom: 0.25rem;'; | |
| 45 | + | title.textContent = 'Update Available: v' + BB.utils.escapeHtml(version); | |
| 46 | + | banner.appendChild(title); | |
| 47 | + | ||
| 48 | + | if (body) { | |
| 49 | + | const notes = document.createElement('div'); | |
| 50 | + | notes.style.cssText = 'color: var(--text-secondary); margin-bottom: 0.5rem;'; | |
| 51 | + | notes.textContent = body.length > 120 ? body.substring(0, 120) + '...' : body; | |
| 52 | + | banner.appendChild(notes); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | const buttons = document.createElement('div'); | |
| 56 | + | buttons.style.cssText = 'display: flex; gap: 0.5rem; margin-top: 0.5rem;'; | |
| 57 | + | ||
| 58 | + | const install = document.createElement('button'); | |
| 59 | + | install.textContent = 'Install & Restart'; | |
| 60 | + | install.style.cssText = BTN_STYLE + ';background: var(--accent); border: none; color: var(--bg-primary); font-weight: 600;'; | |
| 61 | + | install.onclick = () => installUpdate(banner); | |
| 62 | + | buttons.appendChild(install); | |
| 63 | + | ||
| 64 | + | const dismiss = document.createElement('button'); | |
| 65 | + | dismiss.textContent = 'Not Now'; | |
| 66 | + | dismiss.style.cssText = BTN_STYLE + ';background: none; border: 1px solid var(--border); color: var(--text-secondary);'; | |
| 67 | + | dismiss.onclick = () => { | |
| 68 | + | banner.remove(); | |
| 69 | + | updateBannerShown = false; | |
| 70 | + | pendingUpdate = null; | |
| 71 | + | }; | |
| 72 | + | buttons.appendChild(dismiss); | |
| 73 | + | ||
| 74 | + | banner.appendChild(buttons); | |
| 75 | + | document.body.appendChild(banner); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | async function installUpdate(banner) { | |
| 79 | + | if (!pendingUpdate) { | |
| 80 | + | try { | |
| 81 | + | const { check } = window.__TAURI_PLUGIN_UPDATER__; | |
| 82 | + | pendingUpdate = await check(); | |
| 83 | + | } catch (err) { | |
| 84 | + | BB.ui.showToast('Update check failed: ' + err, 'error'); | |
| 85 | + | return; | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | if (!pendingUpdate) { | |
| 90 | + | BB.ui.showToast('No update available.', 'error'); | |
| 91 | + | return; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Replace banner content with progress | |
| 95 | + | banner.innerHTML = ''; | |
| 96 | + | const status = document.createElement('div'); | |
| 97 | + | status.textContent = 'Downloading update...'; | |
| 98 | + | banner.appendChild(status); | |
| 99 | + | ||
| 100 | + | try { | |
| 101 | + | await pendingUpdate.downloadAndInstall(); | |
| 102 | + | status.textContent = 'Update installed. Restarting...'; | |
| 103 | + | } catch (err) { | |
| 104 | + | status.textContent = 'Update failed: ' + err; | |
| 105 | + | BB.ui.showToast('Update failed: ' + err, 'error'); | |
| 106 | + | updateBannerShown = false; | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Listen for automatic update check results from Rust backend | |
| 111 | + | if (window.__TAURI__) { | |
| 112 | + | const { listen } = window.__TAURI__.event; | |
| 113 | + | ||
| 114 | + | listen('update-available', (event) => { | |
| 115 | + | const { version, body } = event.payload; | |
| 116 | + | showUpdateBanner(version, body, null); | |
| 117 | + | }); | |
| 118 | + | ||
| 119 | + | // Listen for manual "Check for Updates" menu action | |
| 120 | + | listen('menu:check_updates', async () => { | |
| 121 | + | BB.ui.showToast('Checking for updates...'); | |
| 122 | + | try { | |
| 123 | + | const { check } = window.__TAURI_PLUGIN_UPDATER__; | |
| 124 | + | const update = await check(); | |
| 125 | + | if (update) { | |
| 126 | + | showUpdateBanner(update.version, update.body || '', update); | |
| 127 | + | } else { | |
| 128 | + | BB.ui.showToast('You are running the latest version.'); | |
| 129 | + | } | |
| 130 | + | } catch (err) { | |
| 131 | + | BB.ui.showToast('Update check failed: ' + err, 'error'); | |
| 132 | + | } | |
| 133 | + | }); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | BB.updater = { showUpdateBanner }; | |
| 137 | + | })(); |
| @@ -14,7 +14,7 @@ fn mobile_main() { | |||
| 14 | 14 | ||
| 15 | 15 | use state::AppState; | |
| 16 | 16 | use std::sync::Arc; | |
| 17 | - | use tauri::Manager; | |
| 17 | + | use tauri::{Emitter, Manager}; | |
| 18 | 18 | ||
| 19 | 19 | /// Build the Tauri app with all commands registered. | |
| 20 | 20 | pub fn build_app() -> tauri::Builder<tauri::Wry> { | |
| @@ -25,7 +25,8 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> { | |||
| 25 | 25 | { | |
| 26 | 26 | builder = builder | |
| 27 | 27 | .plugin(tauri_plugin_shell::init()) | |
| 28 | - | .plugin(tauri_plugin_window_state::Builder::new().build()); | |
| 28 | + | .plugin(tauri_plugin_window_state::Builder::new().build()) | |
| 29 | + | .plugin(tauri_plugin_updater::Builder::new().build()); | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | builder | |
| @@ -49,6 +50,16 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> { | |||
| 49 | 50 | sync_scheduler::start_sync_scheduler(app_handle); | |
| 50 | 51 | }); | |
| 51 | 52 | ||
| 53 | + | // Check for OTA updates after a short delay (desktop only) | |
| 54 | + | #[cfg(not(any(target_os = "ios", target_os = "android")))] | |
| 55 | + | { | |
| 56 | + | let update_handle = app.handle().clone(); | |
| 57 | + | tauri::async_runtime::spawn(async move { | |
| 58 | + | tokio::time::sleep(std::time::Duration::from_secs(10)).await; | |
| 59 | + | check_for_updates(update_handle).await; | |
| 60 | + | }); | |
| 61 | + | } | |
| 62 | + | ||
| 52 | 63 | Ok(()) | |
| 53 | 64 | }) | |
| 54 | 65 | .invoke_handler(tauri::generate_handler![ | |
| @@ -93,3 +104,35 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> { | |||
| 93 | 104 | commands::extract_reader_view, | |
| 94 | 105 | ]) | |
| 95 | 106 | } | |
| 107 | + | ||
| 108 | + | /// Check for OTA updates and emit an event to the frontend if one is available. | |
| 109 | + | #[cfg(not(any(target_os = "ios", target_os = "android")))] | |
| 110 | + | async fn check_for_updates(app: tauri::AppHandle) { | |
| 111 | + | use tauri_plugin_updater::UpdaterExt; | |
| 112 | + | ||
| 113 | + | let updater = match app.updater() { | |
| 114 | + | Ok(u) => u, | |
| 115 | + | Err(e) => { | |
| 116 | + | tracing::warn!("Failed to initialize updater: {e}"); | |
| 117 | + | return; | |
| 118 | + | } | |
| 119 | + | }; | |
| 120 | + | match updater.check().await { | |
| 121 | + | Ok(Some(update)) => { | |
| 122 | + | tracing::info!("Update available: v{}", update.version); | |
| 123 | + | let _ = app.emit( | |
| 124 | + | "update-available", | |
| 125 | + | serde_json::json!({ | |
| 126 | + | "version": update.version, | |
| 127 | + | "body": update.body.unwrap_or_default(), | |
| 128 | + | }), | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | Ok(None) => { | |
| 132 | + | tracing::info!("App is up to date"); | |
| 133 | + | } | |
| 134 | + | Err(e) => { | |
| 135 | + | tracing::warn!("Update check failed: {e}"); | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } |
| @@ -84,6 +84,8 @@ fn main() { | |||
| 84 | 84 | "Help", | |
| 85 | 85 | true, | |
| 86 | 86 | &[ | |
| 87 | + | &MenuItem::with_id(app, "check_updates", "Check for Updates...", true, None::<&str>)?, | |
| 88 | + | &PredefinedMenuItem::separator(app)?, | |
| 87 | 89 | &MenuItem::with_id(app, "about", "About Balanced Breakfast", true, None::<&str>)?, | |
| 88 | 90 | ], | |
| 89 | 91 | )?; |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "$schema": "https://schema.tauri.app/config/2", | |
| 3 | 3 | "productName": "BalancedBreakfast", | |
| 4 | - | "version": "0.1.0", | |
| 4 | + | "version": "0.3.0", | |
| 5 | 5 | "identifier": "com.balancedbreakfast.app", | |
| 6 | 6 | "build": { | |
| 7 | 7 | "frontendDist": "../src-tauri/frontend" | |
| @@ -40,5 +40,13 @@ | |||
| 40 | 40 | "iOS": { | |
| 41 | 41 | "developmentTeam": "93C54W92UP" | |
| 42 | 42 | } | |
| 43 | + | }, | |
| 44 | + | "plugins": { | |
| 45 | + | "updater": { | |
| 46 | + | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJBOTU1QzIxQzdFRDI1MUMKUldRY0plM0hJVnlWdWhmekNSOThnb0FxejRNZ3NsS1BBWlE0aWNZbW92dXltOEExbVhnRmI2LzAK", | |
| 47 | + | "endpoints": [ | |
| 48 | + | "https://makenot.work/api/sync/ota/balanced-breakfast/{{target}}/{{arch}}/{{current_version}}" | |
| 49 | + | ] | |
| 50 | + | } | |
| 43 | 51 | } | |
| 44 | 52 | } |