Skip to main content

max / balanced_breakfast

v0.3.0: Beta-ready milestone — OTA updater, plugin authoring docs, theme fixes, UI improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-17 02:58 UTC
Commit: 6c977d7b3521fc6f9bfa29753670ec3759bbeaa7
Parent: d6f00ba
20 files changed, +565 insertions, -57 deletions
M Cargo.lock +185 -2
@@ -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"
M Cargo.toml +2 -1
@@ -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()">&#9881;</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 &gt; Import OPML</strong>.</p>
247 + <p>Subscribe to feeds with the <strong>+ Add Feed</strong> button, or import existing subscriptions via <strong>File &gt; 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 &amp; Settings</h3>
251 + <p>Click the gear icon (<strong>&#9881;</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 }