feat(app): 답변 완료 알림 — 창 안 보고 있으면 토스트 + 트레이 점

- 프론트: 스트림 onDone 에서 chat.done(title) 한 번. 브라우저에선 no-op
- Rust shell/notify: 창 보이고 포커스면 아무것도 안 함. 아니면 Windows 토스트(tauri-plugin-notification)
  + 트레이 아이콘을 점 찍힌 tray-alert.png 로. 창 Focused(true) 에서 원복
- 토스트는 설치본(msi)에서만 알림센터에 등록돼 보임. dev 에선 트레이 점으로 확인

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-21 14:13:40 +09:00
co-authored by Claude Fable 5.1
parent eb74de2634
commit 89fc8fab45
12 changed files with 5522 additions and 5320 deletions
@@ -11,7 +11,7 @@ use std::sync::Mutex;
use tauri::{AppHandle, State};
use crate::shell::{paste, paste::PasteState, window};
use crate::shell::{notify, paste, paste::PasteState, window};
/// JS 가 보고하는 현재 route + 마지막 챗봇(`/snap`) route.
/// (.NET `App.xaml.cs` 의 `_currentRoute` / `_lastSnapRoute` 대응)
@@ -54,6 +54,13 @@ pub fn window_hide(app: AppHandle) {
let _ = window::hide(&app);
}
/// 답변 스트림이 끝났음 — 사용자가 창을 안 보고 있으면 토스트 + 트레이 점.
/// 프론트 `send({type:"chat.done", title})`.
#[tauri::command]
pub fn chat_done(app: AppHandle, title: Option<String>) {
notify::answer_done(&app, title.as_deref().unwrap_or(""));
}
/// 프레임리스 창이라 제목표시줄이 없음 — React 헤더 mousedown 이 이걸 불러 창을 끈다.
#[tauri::command]
pub fn window_drag(app: AppHandle) {
+6
View File
@@ -59,6 +59,7 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.manage(commands::RouteState::default())
.manage(paste::PasteState::default())
.manage(window::TemporaryWindowState::default())
@@ -73,6 +74,7 @@ pub fn run() {
commands::snippets_update,
commands::snippets_delete,
commands::snippets_record_use,
commands::chat_done,
])
.setup(|app| {
let handle = app.handle().clone();
@@ -103,6 +105,10 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| {
// 창을 다시 보면 트레이 "새 답변" 점 원복.
if matches!(event, tauri::WindowEvent::Focused(true)) {
shell::notify::seen(window.app_handle());
}
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
if let Err(error) = window::restore_size(window.app_handle()) {
eprintln!("[window] {error}");
+1
View File
@@ -14,6 +14,7 @@ pub mod capture;
#[path = "capture_stub.rs"]
pub mod capture;
pub mod hotkey;
pub mod notify;
#[cfg(windows)]
pub mod paste;
#[cfg(not(windows))]
@@ -0,0 +1,51 @@
//! 답변 완료 알림 — 사용자가 창을 안 보고 있을 때만.
//!
//! 토스트(Windows 알림센터)는 몇 초 뒤 사라지고, 트레이 점은 창을 다시 볼 때까지 남는다. 둘 다 씀.
//! 창이 앞에 있고 포커스면 아무것도 안 함 — 보고 있는데 알림 뜨면 짜증.
//! 토스트는 설치본(msi)에서만 알림센터에 등록돼 보이고 `tauri dev` 에선 안 뜰 수 있음 — 그땐 트레이 점으로 확인.
use tauri::{AppHandle, Manager};
use tauri_plugin_notification::NotificationExt;
use super::{tray, window};
pub const TOOLTIP: &str = "CodeAssist";
const TOOLTIP_ALERT: &str = "CodeAssist — 새 답변 도착";
/// 창이 보이고 포커스까지 있으면 "보고 있음".
fn user_is_watching(app: &AppHandle) -> bool {
window::is_visible(app)
&& window::main_window(app)
.and_then(|w| w.is_focused().ok())
.unwrap_or(false)
}
pub fn answer_done(app: &AppHandle, title: &str) {
if user_is_watching(app) {
return;
}
tray::mark_alert(app, TOOLTIP_ALERT);
let body = if title.trim().is_empty() {
"새 답변이 도착했어".to_string()
} else {
title.trim().to_string()
};
if let Err(error) = app
.notification()
.builder()
.title("답변 완료")
.body(body)
.show()
{
eprintln!("[notify] 토스트 실패(설치본 아니면 정상): {error}");
}
}
/// 창이 포커스를 받으면 트레이 점 원복. `on_window_event(Focused(true))` 에서 부른다.
pub fn seen(app: &AppHandle) {
tray::clear_alert(app, TOOLTIP);
}
// Manager 는 tray_by_id/default_window_icon 용으로 tray.rs 가 쓰고, 여기선 is_focused 경로에서 씀.
#[allow(unused_imports)]
use Manager as _;
+75 -50
View File
@@ -1,50 +1,75 @@
//! 트레이 상주 아이콘 + 메뉴 (.NET `TrayIconHost`/H.NotifyIcon 대체 — Tauri 내장이라 의존성 0).
//!
//! 메뉴 구성은 .NET 판과 똑같이 열기 / 항상 위에 고정(체크) / 종료 3개.
use tauri::menu::{CheckMenuItem, Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::AppHandle;
/// 트레이에서 사용자가 고른 것. 실제로 뭘 할지는 조립부(lib.rs)가 정한다.
pub enum TrayAction {
Open,
/// 토글 **후**의 새 체크 상태
TogglePin(bool),
Quit,
}
pub fn init<F>(app: &AppHandle, tooltip: &str, pinned: bool, on_action: F) -> tauri::Result<()>
where
F: Fn(&AppHandle, TrayAction) + Send + Sync + 'static,
{
let open_i = MenuItem::with_id(app, "open", "열기", true, None::<&str>)?;
let pin_i = CheckMenuItem::with_id(app, "pin", "항상 위에 고정", true, pinned, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "종료", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open_i, &pin_i, &quit_i])?;
let icon = app
.default_window_icon()
.expect("번들 아이콘 없음 — tauri.conf.json 의 bundle.icon 확인")
.clone();
TrayIconBuilder::with_id("main")
.icon(icon)
.tooltip(tooltip)
.menu(&menu)
// 좌클릭은 메뉴 안 열고 창 소환에 쓴다(윈도우 트레이 관습).
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"open" => on_action(app, TrayAction::Open),
// CheckMenuItem 은 클릭 시 자기가 먼저 토글되므로, 지금 읽은 값이 곧 새 상태.
"pin" => on_action(
app,
TrayAction::TogglePin(pin_i.is_checked().unwrap_or(false)),
),
"quit" => on_action(app, TrayAction::Quit),
_ => {}
})
.build(app)?;
Ok(())
}
//! 트레이 상주 아이콘 + 메뉴 (.NET `TrayIconHost`/H.NotifyIcon 대체 — Tauri 내장이라 의존성 0).
//!
//! 메뉴 구성은 .NET 판과 똑같이 열기 / 항상 위에 고정(체크) / 종료 3개.
use tauri::image::Image;
use tauri::menu::{CheckMenuItem, Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::AppHandle;
const TRAY_ID: &str = "main";
/// 트레이에서 사용자가 고른 것. 실제로 뭘 할지는 조립부(lib.rs)가 정한다.
pub enum TrayAction {
Open,
/// 토글 **후**의 새 체크 상태
TogglePin(bool),
Quit,
}
pub fn init<F>(app: &AppHandle, tooltip: &str, pinned: bool, on_action: F) -> tauri::Result<()>
where
F: Fn(&AppHandle, TrayAction) + Send + Sync + 'static,
{
let open_i = MenuItem::with_id(app, "open", "열기", true, None::<&str>)?;
let pin_i = CheckMenuItem::with_id(app, "pin", "항상 위에 고정", true, pinned, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "종료", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open_i, &pin_i, &quit_i])?;
let icon = app
.default_window_icon()
.expect("번들 아이콘 없음 — tauri.conf.json 의 bundle.icon 확인")
.clone();
TrayIconBuilder::with_id(TRAY_ID)
.icon(icon)
.tooltip(tooltip)
.menu(&menu)
// 좌클릭은 메뉴 안 열고 창 소환에 쓴다(윈도우 트레이 관습).
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id.as_ref() {
"open" => on_action(app, TrayAction::Open),
// CheckMenuItem 은 클릭 시 자기가 먼저 토글되므로, 지금 읽은 값이 곧 새 상태.
"pin" => on_action(
app,
TrayAction::TogglePin(pin_i.is_checked().unwrap_or(false)),
),
"quit" => on_action(app, TrayAction::Quit),
_ => {}
})
.build(app)?;
Ok(())
}
/// 답변 도착 표시 — 아이콘에 파란 점 + 툴팁. 사용자가 창을 볼 때까지 남는다(토스트는 사라지니까).
pub fn mark_alert(app: &AppHandle, tooltip: &str) {
let Some(tray) = app.tray_by_id(TRAY_ID) else {
return;
};
if let Ok(icon) = Image::from_bytes(include_bytes!("../../icons/tray-alert.png")) {
let _ = tray.set_icon(Some(icon));
}
let _ = tray.set_tooltip(Some(tooltip));
}
/// 알림 표시 원복 — 창이 포커스를 받으면 부른다.
pub fn clear_alert(app: &AppHandle, tooltip: &str) {
let Some(tray) = app.tray_by_id(TRAY_ID) else {
return;
};
if let Some(icon) = app.default_window_icon() {
let _ = tray.set_icon(Some(icon.clone()));
}
let _ = tray.set_tooltip(Some(tooltip));
}