fix(theme): bound auto-theme image decode, spinner via event loop

Review follow-ups: extract_palette decoded the full image before the
100x100 downsample with no limits — a decompression-bomb PNG expands
to tens of GB, and this box hard-freezes on OOM; decode is now bounded
at 12k x 12k / 512 MB (any real wallpaper fits). The generating
indicator is now set through upgrade_in_event_loop so the select_image
path (which runs on a tokio thread, where a plain upgrade() returns
None) shows it too. Startup generation stays synchronous by design:
with the decode bounded it costs milliseconds and the first paint
comes up in the generated palette instead of flashing the default.

cc
This commit is contained in:
vicrodh committed 2026-07-05 22:44:50 -06:00
1 parent 1bbc98cc8e
commit e70d434773
2 files changed
+20 -5

No files matched your search

+13 -1
View File
@@ -13,7 +13,19 @@ pub fn extract_palette(image_path: &str) -> Result<ThemePalette, String> {
return Err(format!("Image not found: {}", image_path)); return Err(format!("Image not found: {}", image_path));
} }
let img = image::open(path).map_err(|e| format!("Failed to open image: {}", e))?; // Bounded decode: a decompression-bomb PNG (65k x 65k) would otherwise
// expand to tens of GB before the 100x100 downsample. 12k x 12k / 512 MB
// covers any real wallpaper while keeping the worst case survivable.
let mut reader = image::ImageReader::open(path)
.map_err(|e| format!("Failed to open image: {}", e))?
.with_guessed_format()
.map_err(|e| format!("Failed to read image: {}", e))?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(12_000);
limits.max_image_height = Some(12_000);
limits.max_alloc = Some(512 * 1024 * 1024);
reader.limits(limits);
let img = reader.decode().map_err(|e| format!("Failed to decode image: {}", e))?;
// Downsample to 100x100 for fast processing. // Downsample to 100x100 for fast processing.
let thumb = img.resize_exact(100, 100, image::imageops::FilterType::Lanczos3); let thumb = img.resize_exact(100, 100, image::imageops::FilterType::Lanczos3);
+7 -4
View File
@@ -75,10 +75,13 @@ pub fn apply_startup(window: &AppWindow) {
/// Regenerate the auto theme off-thread and push the result on the event loop. /// Regenerate the auto theme off-thread and push the result on the event loop.
/// Toggles `auto-theme-generating` around the work and toasts on failure. /// Toggles `auto-theme-generating` around the work and toasts on failure.
pub fn regenerate(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) { pub fn regenerate(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
if let Some(w) = weak.upgrade() { // Through the event loop, NOT a direct upgrade: regenerate() is also
w.global::<AppearanceState>() // called from tokio contexts (select_image), where a plain upgrade()
.set_auto_theme_generating(true); // returns None and the generating indicator would silently be skipped.
} let weak_flag = weak.clone();
let _ = weak_flag.upgrade_in_event_loop(move |w| {
w.global::<AppearanceState>().set_auto_theme_generating(true);
});
handle.spawn(async move { handle.spawn(async move {
let prefs = crate::ui_prefs::load(); let prefs = crate::ui_prefs::load();
let source = source_from_prefs(&prefs); let source = source_from_prefs(&prefs);