1use std::collections::HashMap;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::Mutex;
6use std::thread;
7use std::time::Duration;
8use tauri::image::Image;
9use tauri::menu::MenuBuilder;
10use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
11use tauri::{
12 App, AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, Position, Rect,
13 Size, State, Wry,
14};
15
16use crate::history;
17
18const TRAY_MENU_NOW_PLAYING_MAX: usize = 96;
20
21const TRAY_POPOVER_W: u32 = 340;
22const TRAY_POPOVER_H: u32 = 480;
25
26fn emit_tray_popover_state(app: &AppHandle<Wry>, emit: &TrayPopoverEmit) {
29 let appearance_n = emit
30 .appearance
31 .as_ref()
32 .map(|m| m.len())
33 .unwrap_or(0);
34 match app.emit_to("tray-popover", "tray-popover-state", emit) {
35 Ok(()) => {
36 if std::env::var_os("AUDIO_HAXOR_TRAY_DEBUG").is_some() {
37 eprintln!(
38 "[tray-popover-host] emit tray-popover-state ok idle={} ui_theme={} appearance_vars={} title_ch={} subtitle_ch={} playing={} elapsed={:.2} total_sec={:?}",
39 emit.idle,
40 emit.ui_theme,
41 appearance_n,
42 emit.title.chars().count(),
43 emit.subtitle.chars().count(),
44 emit.playing,
45 emit.elapsed_sec,
46 emit.total_sec
47 );
48 }
49 }
50 Err(e) => {
51 eprintln!("[tray-popover-host] emit tray-popover-state FAILED: {e}");
52 }
53 }
54}
55
56pub fn emit_tray_popover_ui_theme(app: &AppHandle<Wry>, ui_theme: &str) {
58 let payload = serde_json::json!({ "ui_theme": ui_theme });
59 match app.emit_to("tray-popover", "tray-popover-ui-theme", payload) {
60 Ok(()) => {
61 if std::env::var_os("AUDIO_HAXOR_TRAY_DEBUG").is_some() {
62 eprintln!(
63 "[tray-popover-host] emit tray-popover-ui-theme ok ui_theme={ui_theme}"
64 );
65 }
66 }
67 Err(e) => {
68 eprintln!("[tray-popover-host] emit tray-popover-ui-theme FAILED: {e}");
69 }
70 }
71}
72
73fn tray_menu_bar_icon(app: &App) -> tauri::Result<Image<'static>> {
75 if let Some(icon) = app.default_window_icon() {
76 return Ok(icon.clone().to_owned());
77 }
78 const TRAY_PNG: &[u8] = include_bytes!("../icons/32x32.png");
79 Image::from_bytes(TRAY_PNG)
80}
81
82fn t(strings: &HashMap<String, String>, key: &str, fallback: &str) -> String {
83 strings
84 .get(key)
85 .map(|s| s.as_str())
86 .filter(|s| !s.is_empty())
87 .unwrap_or(fallback)
88 .to_string()
89}
90
91fn tray_popover_ui_theme_from_prefs() -> String {
93 match history::get_preference("theme") {
94 Some(serde_json::Value::String(s)) if s == "light" => "light".to_string(),
95 _ => "dark".to_string(),
96 }
97}
98
99fn truncate_tray_menu_line(s: &str) -> String {
100 let t = s.trim();
101 if t.chars().count() <= TRAY_MENU_NOW_PLAYING_MAX {
102 return t.to_string();
103 }
104 let mut out = String::new();
105 for (i, ch) in t.chars().enumerate() {
106 if i >= TRAY_MENU_NOW_PLAYING_MAX.saturating_sub(1) {
107 break;
108 }
109 out.push(ch);
110 }
111 out.push('…');
112 out
113}
114
115#[derive(Clone, serde::Serialize)]
117pub struct TrayPopoverEmit {
118 pub idle: bool,
119 pub title: String,
120 pub subtitle: String,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub reveal_path: Option<String>,
124 pub elapsed_sec: f64,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub total_sec: Option<f64>,
127 pub playing: bool,
128 pub playback_speed: f64,
130 pub volume_pct: u8,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub idle_hint: Option<String>,
134 pub ui_theme: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub appearance: Option<HashMap<String, String>>,
139}
140
141pub struct TrayState {
143 pub inner: Mutex<TrayStateInner>,
144}
145
146pub struct TrayStateInner {
147 pub tray: Option<TrayIcon<Wry>>,
148 pub menu_strings: HashMap<String, String>,
149 pub now_playing_menu_line: Option<String>,
150 pub last_popover_emit: Option<TrayPopoverEmit>,
151 pub last_tray_appearance: Option<HashMap<String, String>>,
152}
153
154impl Default for TrayStateInner {
155 fn default() -> Self {
156 Self {
157 tray: None,
158 menu_strings: HashMap::new(),
159 now_playing_menu_line: None,
160 last_popover_emit: None,
161 last_tray_appearance: None,
162 }
163 }
164}
165
166impl Default for TrayState {
167 fn default() -> Self {
168 Self {
169 inner: Mutex::new(TrayStateInner::default()),
170 }
171 }
172}
173
174pub fn refresh_tray_popup_menu(
176 app: &AppHandle<Wry>,
177 state: &TrayState,
178 strings: &HashMap<String, String>,
179) -> Result<(), String> {
180 let mut guard = state
181 .inner
182 .lock()
183 .map_err(|_| "tray state mutex poisoned".to_string())?;
184 let Some(tray) = guard.tray.clone() else {
185 return Ok(());
186 };
187 guard.menu_strings.clone_from(strings);
188 let menu = build_tray_popup_menu(
189 app,
190 &guard.menu_strings,
191 guard.now_playing_menu_line.as_deref(),
192 )?;
193 drop(guard);
194 tray.set_menu(Some(menu)).map_err(|e| e.to_string())
195}
196
197fn build_tray_popup_menu(
198 app: &AppHandle<Wry>,
199 strings: &HashMap<String, String>,
200 now_playing_line: Option<&str>,
201) -> Result<tauri::menu::Menu<Wry>, String> {
202 let mut b = MenuBuilder::new(app);
203 if let Some(raw) = now_playing_line {
204 let line = truncate_tray_menu_line(raw);
205 if !line.is_empty() {
206 b = b.text("tray_now_playing", line);
207 b = b.separator();
208 }
209 }
210 b.text("tray_show", t(strings, "tray.show", "Show AUDIO_HAXOR"))
211 .separator()
212 .text("tray_scan_all", t(strings, "tray.scan_all", "Scan All"))
213 .text("tray_stop_all", t(strings, "tray.stop_all", "Stop All"))
214 .separator()
215 .text("tray_prev", t(strings, "tray.previous_track", "Previous Track"))
216 .text("tray_play_pause", t(strings, "tray.play_pause", "Play / Pause"))
217 .text("tray_next", t(strings, "tray.next_track", "Next Track"))
218 .separator()
219 .text("tray_quit", t(strings, "tray.quit", "Quit"))
220 .build()
221 .map_err(|e| e.to_string())
222}
223
224fn popover_xy_below_tray(rect: &Rect, scale_factor: f64) -> (i32, i32) {
227 let physical_coords = matches!(rect.position, Position::Physical(..));
228 let pop_w_half = if physical_coords {
229 f64::from(TRAY_POPOVER_W) * scale_factor / 2.0
230 } else {
231 f64::from(TRAY_POPOVER_W) / 2.0
232 };
233 let gap = if physical_coords {
234 4.0_f64 * scale_factor
235 } else {
236 4.0_f64
237 };
238 let (px, py) = match rect.position {
239 Position::Physical(p) => (p.x as f64, p.y as f64),
240 Position::Logical(p) => (p.x, p.y),
241 };
242 let (w, h) = match rect.size {
243 Size::Physical(s) => (s.width as f64, s.height as f64),
244 Size::Logical(s) => (s.width, s.height),
245 };
246 let x = px + w / 2.0 - pop_w_half;
247 let y = py + h + gap;
248 (x.floor() as i32, y.floor() as i32)
249}
250
251fn toggle_tray_popover(app: &AppHandle<Wry>, rect: &Rect) -> Result<(), String> {
252 let tray_state = app.state::<TrayState>();
253 let last = tray_state
254 .inner
255 .lock()
256 .map_err(|_| "tray state mutex poisoned".to_string())?
257 .last_popover_emit
258 .clone();
259 let Some(win) = app.get_webview_window("tray-popover") else {
260 return Ok(());
261 };
262 if win.is_visible().unwrap_or(false) {
263 let _ = win.hide();
264 return Ok(());
265 }
266 let mut emit = last.unwrap_or(TrayPopoverEmit {
267 idle: true,
268 title: String::new(),
269 subtitle: String::new(),
270 reveal_path: None,
271 elapsed_sec: 0.0,
272 total_sec: None,
273 playing: false,
274 playback_speed: 1.0,
275 volume_pct: 100,
276 idle_hint: None,
277 ui_theme: tray_popover_ui_theme_from_prefs(),
278 appearance: None,
279 });
280 emit.ui_theme = tray_popover_ui_theme_from_prefs();
281 emit_tray_popover_state(app, &emit);
282 let scale = win.scale_factor().unwrap_or(1.0);
283 let (mut x, y) = popover_xy_below_tray(rect, scale);
284 x = x.max(8);
285 let _ = win.set_size(tauri::Size::Logical(LogicalSize::new(
286 f64::from(TRAY_POPOVER_W),
287 f64::from(TRAY_POPOVER_H),
288 )));
289 let _ = win.set_position(tauri::Position::Physical(PhysicalPosition::new(x, y)));
290 let _ = win.show();
291 let _ = win.set_always_on_top(true);
293 let _ = win.set_focus();
303 Ok(())
304}
305
306pub fn create_tray(app: &App, strings: &HashMap<String, String>) -> Result<TrayIcon<Wry>, String> {
307 let handle = app.handle().clone();
308 let tray_menu = build_tray_popup_menu(&handle, strings, None)?;
309 let icon = tray_menu_bar_icon(app).map_err(|e| e.to_string())?;
310 let mut builder = TrayIconBuilder::new()
311 .menu(&tray_menu)
312 .icon(icon)
313 .tooltip(t(strings, "tray.tooltip", "AUDIO_HAXOR"))
314 .show_menu_on_left_click(cfg!(target_os = "linux"));
315 #[cfg(target_os = "macos")]
316 {
317 builder = builder.icon_as_template(false);
319 }
320 let tray = builder.build(app).map_err(|e| e.to_string())?;
321 #[cfg(not(target_os = "linux"))]
322 {
323 tray.on_tray_icon_event(move |tray, event| {
324 if let TrayIconEvent::Click {
325 button: MouseButton::Left,
326 button_state: MouseButtonState::Up,
327 rect,
328 ..
329 } = event
330 {
331 let app = tray.app_handle().clone();
332 let _ = toggle_tray_popover(&app, &rect);
333 }
334 });
335 }
336 Ok(tray)
337}
338
339#[derive(serde::Deserialize)]
340pub struct TrayNowPlayingPayload {
341 #[serde(default)]
342 pub title_bar: Option<String>,
343 pub tooltip: String,
344 #[serde(default)]
345 pub idle: bool,
346 #[serde(default)]
347 pub popover_title: Option<String>,
348 #[serde(default)]
349 pub popover_subtitle: Option<String>,
350 #[serde(default)]
351 pub elapsed_sec: Option<f64>,
352 #[serde(default)]
353 pub total_sec: Option<f64>,
354 #[serde(default)]
355 pub popover_playing: Option<bool>,
356 #[serde(default)]
357 pub popover_idle_label: Option<String>,
358 #[serde(default)]
360 pub playback_speed: Option<f64>,
361 #[serde(default)]
363 pub volume_pct: Option<f64>,
364 #[serde(default)]
366 pub ui_theme: Option<String>,
367 #[serde(default)]
369 pub appearance: Option<HashMap<String, String>>,
370 #[serde(default)]
372 pub popover_reveal_path: Option<String>,
373}
374
375fn normalized_popover_reveal_path(payload: &TrayNowPlayingPayload) -> Option<String> {
376 payload
377 .popover_reveal_path
378 .as_ref()
379 .map(|s| s.trim().to_string())
380 .filter(|s| !s.is_empty())
381}
382
383fn tray_emit_ui_theme(payload: &TrayNowPlayingPayload) -> String {
384 match payload.ui_theme.as_deref() {
385 Some("light") => "light".to_string(),
386 Some(_) => "dark".to_string(),
387 None => tray_popover_ui_theme_from_prefs(),
388 }
389}
390
391fn tray_playback_speed_merge(payload: &TrayNowPlayingPayload, last: Option<&TrayPopoverEmit>) -> f64 {
392 let fallback = || last.map(|e| e.playback_speed).unwrap_or(1.0);
393 match payload.playback_speed {
394 Some(s) if s.is_finite() => s.clamp(0.25, 2.0),
395 _ => fallback(),
396 }
397}
398
399fn tray_volume_pct_merge(payload: &TrayNowPlayingPayload, last: Option<&TrayPopoverEmit>) -> u8 {
400 let fallback = || last.map(|e| e.volume_pct).unwrap_or(100);
401 match payload.volume_pct {
402 Some(v) if v.is_finite() => v.clamp(0.0, 100.0).round() as u8,
403 _ => fallback(),
404 }
405}
406
407#[tauri::command]
408pub fn tray_popover_action(app: AppHandle<Wry>, action: String) -> Result<(), String> {
409 if let Some(rest) = action.strip_prefix("volume:") {
418 if let Ok(n) = rest.parse::<f64>() {
419 if let Some(tray_state) = app.try_state::<TrayState>() {
420 if let Ok(mut guard) = tray_state.inner.lock() {
421 if let Some(emit) = guard.last_popover_emit.as_mut() {
422 emit.volume_pct = n.clamp(0.0, 100.0).round() as u8;
423 }
424 }
425 }
426 }
427 } else if let Some(rest) = action.strip_prefix("speed:") {
428 if let Ok(s) = rest.parse::<f64>() {
429 if s.is_finite() {
430 if let Some(tray_state) = app.try_state::<TrayState>() {
431 if let Ok(mut guard) = tray_state.inner.lock() {
432 if let Some(emit) = guard.last_popover_emit.as_mut() {
433 emit.playback_speed = s.clamp(0.25, 2.0);
434 }
435 }
436 }
437 }
438 }
439 } else if let Some(rest) = action.strip_prefix("seek:") {
440 if let Ok(frac) = rest.parse::<f64>() {
450 if frac.is_finite() {
451 let frac = frac.clamp(0.0, 1.0);
452 let total_sec = app
453 .try_state::<TrayState>()
454 .and_then(|s| s.inner.lock().ok().and_then(|g| {
455 g.last_popover_emit.as_ref().and_then(|e| e.total_sec)
456 }));
457 if let Some(dur) = total_sec {
458 if dur > 0.0 {
459 let position_sec = frac * dur;
460 std::thread::spawn(move || {
461 let _ = crate::audio_engine::spawn_audio_engine_request(
462 &serde_json::json!({
463 "cmd": "playback_seek",
464 "position_sec": position_sec,
465 }),
466 );
467 });
468 }
469 }
470 }
471 }
472 }
473 let _ = app.emit_to("main", "menu-action", action);
476 Ok(())
477}
478
479#[tauri::command]
483pub fn tray_popover_resize(app: AppHandle<Wry>, width: f64, height: f64) -> Result<(), String> {
484 let Some(win) = app.get_webview_window("tray-popover") else {
485 return Ok(());
486 };
487 let w = width.clamp(240.0, 620.0);
488 let h = height.clamp(60.0, 1200.0);
492 let _ = win.set_size(tauri::Size::Logical(LogicalSize::new(w, h)));
493 Ok(())
494}
495
496#[tauri::command]
499pub fn update_tray_now_playing(
500 app: AppHandle<Wry>,
501 tray_state: State<'_, TrayState>,
502 payload: TrayNowPlayingPayload,
503) -> Result<(), String> {
504 let mut guard = tray_state
505 .inner
506 .lock()
507 .map_err(|_| "tray state mutex poisoned".to_string())?;
508 let Some(tray) = guard.tray.clone() else {
509 return Ok(());
510 };
511
512 let np_line = if payload.idle {
513 None
514 } else {
515 payload
516 .title_bar
517 .as_ref()
518 .map(|s| s.trim())
519 .filter(|s| !s.is_empty())
520 .map(|s| s.to_string())
521 };
522 guard.now_playing_menu_line.clone_from(&np_line);
523
524 if let Some(ref map) = payload.appearance {
525 if !map.is_empty() {
526 guard.last_tray_appearance = Some(map.clone());
527 }
528 }
529
530 let theme = tray_emit_ui_theme(&payload);
531 let appearance = guard.last_tray_appearance.clone();
532 let last_emit = guard.last_popover_emit.as_ref();
533 let prev_reveal_path = last_emit.and_then(|e| e.reveal_path.clone());
534 let playback_speed = tray_playback_speed_merge(&payload, last_emit);
535 let volume_pct = tray_volume_pct_merge(&payload, last_emit);
536 let emit = if payload.idle {
537 TrayPopoverEmit {
538 idle: true,
539 title: String::new(),
540 subtitle: String::new(),
541 reveal_path: None,
542 elapsed_sec: 0.0,
543 total_sec: None,
544 playing: false,
545 playback_speed,
546 volume_pct,
547 idle_hint: payload
548 .popover_idle_label
549 .clone()
550 .filter(|s| !s.trim().is_empty()),
551 ui_theme: theme,
552 appearance: appearance.clone(),
553 }
554 } else {
555 TrayPopoverEmit {
556 idle: false,
557 title: payload.popover_title.clone().unwrap_or_default(),
558 subtitle: payload.popover_subtitle.clone().unwrap_or_default(),
559 reveal_path: normalized_popover_reveal_path(&payload),
560 elapsed_sec: payload.elapsed_sec.unwrap_or(0.0),
561 total_sec: payload.total_sec,
562 playing: payload.popover_playing.unwrap_or(false),
563 playback_speed,
564 volume_pct,
565 idle_hint: None,
566 ui_theme: theme,
567 appearance: appearance.clone(),
568 }
569 };
570 guard.last_popover_emit = Some(emit.clone());
571
572 let menu = build_tray_popup_menu(
573 &app,
574 &guard.menu_strings,
575 guard.now_playing_menu_line.as_deref(),
576 )?;
577 drop(guard);
578 let _ = tray.set_menu(Some(menu));
579 let _ = tray.set_tooltip(Some(payload.tooltip.as_str()));
580 #[cfg(target_os = "macos")]
583 {
584 let _ = tray.set_title(None::<&str>);
585 }
586 emit_tray_popover_state(&app, &emit);
587
588 let new_reveal_path = emit.reveal_path.clone();
600 if new_reveal_path != prev_reveal_path {
601 if let Some(rp) = new_reveal_path {
602 std::thread::spawn(move || {
603 let p = std::path::Path::new(&rp);
604 if let Some(parent) = p.parent() {
605 if !parent.as_os_str().is_empty() {
606 if let Ok(entries) = std::fs::read_dir(parent) {
607 for entry in entries.flatten() {
608 let _ = entry.metadata();
609 }
610 }
611 }
612 }
613 });
614 }
615 }
616 Ok(())
617}
618
619#[tauri::command]
620pub fn tray_popover_get_state(tray_state: State<'_, TrayState>) -> Result<Option<TrayPopoverEmit>, String> {
621 let guard = tray_state
622 .inner
623 .lock()
624 .map_err(|_| "tray state mutex poisoned".to_string())?;
625 Ok(guard.last_popover_emit.clone())
626}
627
628#[tauri::command]
629pub fn tray_popover_get_ui_theme() -> String {
630 tray_popover_ui_theme_from_prefs()
631}
632
633#[tauri::command]
635pub fn show_main_window(app: AppHandle<Wry>) -> Result<(), String> {
636 let Some(w) = app.get_webview_window("main") else {
637 return Ok(());
638 };
639 w.show().map_err(|e| e.to_string())?;
640 w.unminimize().map_err(|e| e.to_string())?;
641 w.set_focus().map_err(|e| e.to_string())?;
642 Ok(())
643}
644
645#[tauri::command]
650pub fn tray_popover_hide(app: AppHandle<Wry>) -> Result<(), String> {
651 if let Some(win) = app.get_webview_window("tray-popover") {
652 let _ = win.hide();
653 }
654 Ok(())
655}
656
657static TRAY_POLL_ACTIVE: AtomicBool = AtomicBool::new(false);
658const TRAY_POLL_MS: u64 = 500;
661
662fn fmt_tray_time(sec: f64) -> String {
663 let s = sec.max(0.0);
664 let m = (s / 60.0) as u64;
665 let r = (s as u64) % 60;
666 format!("{}:{:02}", m, r)
667}
668
669fn truncate_tray_title(s: &str) -> String {
670 const MAX: usize = 44;
671 let t = s.trim();
672 if t.chars().count() <= MAX {
673 return t.to_string();
674 }
675 let mut out: String = t.chars().take(MAX.saturating_sub(1)).collect();
676 out.push('…');
677 out
678}
679
680pub fn start_tray_host_poll(app: AppHandle<Wry>) {
695 if TRAY_POLL_ACTIVE.swap(true, Ordering::SeqCst) {
696 return;
697 }
698 thread::spawn(move || {
699 while TRAY_POLL_ACTIVE.load(Ordering::SeqCst) {
700 thread::sleep(Duration::from_millis(TRAY_POLL_MS));
701 if !TRAY_POLL_ACTIVE.load(Ordering::SeqCst) {
702 break;
703 }
704 let Some(tray_state) = app.try_state::<TrayState>() else {
707 continue;
708 };
709 {
710 let guard = match tray_state.inner.lock() {
711 Ok(g) => g,
712 Err(_) => continue,
713 };
714 match guard.last_popover_emit.as_ref() {
715 Some(e) if !e.idle => {}
716 _ => continue,
717 }
718 }
719 let v = match crate::audio_engine::spawn_audio_engine_request(
720 &serde_json::json!({ "cmd": "playback_status" }),
721 ) {
722 Ok(v) => v,
723 Err(_) => continue,
724 };
725 let loaded = v.get("loaded").and_then(|x| x.as_bool()).unwrap_or(false);
726 if !loaded {
727 continue;
728 }
729 let pos = v
730 .get("position_sec")
731 .and_then(|x| x.as_f64())
732 .unwrap_or(0.0);
733 let dur = v
734 .get("duration_sec")
735 .and_then(|x| x.as_f64())
736 .unwrap_or(0.0);
737 let paused = v.get("paused").and_then(|x| x.as_bool()).unwrap_or(false);
738 let (tray, new_emit, title_bar, tooltip) = {
739 let mut guard = match tray_state.inner.lock() {
740 Ok(g) => g,
741 Err(_) => continue,
742 };
743 let Some(tray) = guard.tray.clone() else {
744 continue;
745 };
746 let Some(last) = guard.last_popover_emit.clone() else {
747 continue;
748 };
749 if last.idle {
752 continue;
753 }
754 let total_sec = if dur > 0.0 { Some(dur) } else { last.total_sec };
757 let new_emit = TrayPopoverEmit {
758 idle: false,
759 title: last.title.clone(),
760 subtitle: last.subtitle.clone(),
761 reveal_path: last.reveal_path.clone(),
762 elapsed_sec: pos,
763 total_sec,
764 playing: !paused,
765 playback_speed: last.playback_speed,
766 volume_pct: last.volume_pct,
767 idle_hint: None,
768 ui_theme: last.ui_theme.clone(),
769 appearance: last.appearance.clone(),
770 };
771 guard.last_popover_emit = Some(new_emit.clone());
772
773 let total_str = match total_sec {
774 Some(t) if t > 0.0 => fmt_tray_time(t),
775 _ => "—".to_string(),
776 };
777 let elapsed_str = fmt_tray_time(pos);
778 let title_bar = truncate_tray_title(&new_emit.title);
780 let status = if new_emit.playing {
781 "Playing"
782 } else {
783 "Paused"
784 };
785 let tooltip = if new_emit.title.is_empty() {
786 format!("{} / {} • {}", elapsed_str, total_str, status)
787 } else {
788 format!(
789 "{} — {} / {} • {}",
790 new_emit.title, elapsed_str, total_str, status
791 )
792 };
793 (tray, new_emit, title_bar, tooltip)
794 };
795
796 let _ = title_bar;
798 let _ = tray.set_tooltip(Some(tooltip.as_str()));
799 emit_tray_popover_state(&app, &new_emit);
800 }
801 TRAY_POLL_ACTIVE.store(false, Ordering::SeqCst);
802 });
803}