Files
CODE_ASSISTANT/4_rust_tauri/src-tauri/src/lib.rs
T
lee-hyeon-cheolandClaude Fable 5.1 89fc8fab45 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>
2026-09-21 14:13:40 +09:00

299 lines
10 KiB
Rust

//! 조립부 — .NET 판의 `App.xaml.cs` 에 대응한다.
//!
//! CodeAssist 고유 결정(어느 핫키가 뭘 하는지, route 이름, 토글 규칙)은 **전부 여기에만** 있다.
//! `shell/` 은 이걸 하나도 모르는 재사용 층이고, `bridge/` 는 프론트 계약만 안다.
mod bridge;
mod shell;
use tauri::{AppHandle, Manager};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use bridge::{commands, Push};
use shell::hotkey::HotkeySpec;
use shell::paste;
use shell::tray::TrayAction;
use shell::window;
const HOTKEYS: &[HotkeySpec] = &[
HotkeySpec {
id: "snap",
accelerator: "Ctrl+Alt+W",
},
HotkeySpec {
id: "snippet",
accelerator: "Ctrl+Alt+Q",
},
// 캡쳐 4종: 윗줄(A/S) = 새 대화, 아랫줄(Z/X) = 마지막 기존 대화(없으면 새 대화).
// 왼쪽(A/Z) = 맨 앞 창 통째, 오른쪽(S/X) = 드래그 영역.
HotkeySpec {
id: "capture",
accelerator: "Ctrl+Alt+A",
},
HotkeySpec {
id: "capture_area",
accelerator: "Ctrl+Alt+S",
},
HotkeySpec {
id: "capture_last",
accelerator: "Ctrl+Alt+Z",
},
HotkeySpec {
id: "capture_area_last",
accelerator: "Ctrl+Alt+X",
},
];
const TOOLTIP: &str = "CodeAssist";
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// 두 번째 실행은 새 창을 만들지 않고 기존 창을 소환함.
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
let _ = show_palette(app);
}))
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(StateFlags::SIZE | StateFlags::POSITION)
.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())
.invoke_handler(tauri::generate_handler![
commands::window_hide,
commands::window_drag,
commands::window_snippet_layout,
commands::report_route,
commands::paste_code,
commands::snippets_list,
commands::snippets_create,
commands::snippets_update,
commands::snippets_delete,
commands::snippets_record_use,
commands::chat_done,
])
.setup(|app| {
let handle = app.handle().clone();
let pinned = window::load_pinned(&handle);
let _ = window::set_pinned(&handle, pinned);
shell::tray::init(&handle, TOOLTIP, pinned, |app, action| match action {
TrayAction::Open => {
let _ = show_palette(app);
}
TrayAction::TogglePin(on) => {
let _ = window::set_pinned(app, on);
}
TrayAction::Quit => {
if let Err(error) = window::restore_size(app) {
eprintln!("[window] {error}");
}
app.exit(0);
}
})?;
for accelerator in shell::hotkey::init(&handle, HOTKEYS, on_hotkey) {
// 죽이지 않는다 — 트레이 '열기'로 여전히 쓸 수 있음.
eprintln!("[hotkey] {accelerator} 등록 실패 — 다른 앱이 선점함");
}
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}");
}
// CloseRequested 리스너 순서는 보장되지 않음. 창이 사라지기 전에
// 복원된 실측값을 캐시에도 반영해야 축소 크기가 저장되지 않음.
if let Err(error) = window
.app_handle()
.save_window_state(StateFlags::SIZE | StateFlags::POSITION)
{
eprintln!("[window-state] {error}");
}
}
})
.build(tauri::generate_context!())
.expect("Tauri 앱 실행 실패")
.run(|app, event| {
// window-state 플러그인이 Exit에서 저장하기 전에 원래 크기로 돌림.
if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
if let Err(error) = window::restore_size(app) {
eprintln!("[window] {error}");
}
}
});
}
fn chat_toggle_target(visible: bool, current: &str, last_snap: &str) -> Option<String> {
if visible && (current == "/snap" || current.starts_with("/snap/")) {
None
} else {
Some(last_snap.to_string())
}
}
/// 팔레트를 띄우기 직전에 대상 창 값을 복사해 둔다. 대상이 닫혀도 배지 문자열은 유지됨.
fn show_palette(app: &AppHandle) -> tauri::Result<()> {
let own_hwnd = window::native_handle(app);
let captured = paste::capture_foreground(own_hwnd);
let state = app.state::<paste::PasteState>();
let target = match state.lock() {
Ok(mut stored) => {
if let Some(captured) = captured {
*stored = captured;
}
stored.clone()
}
Err(_) => paste::PasteTarget::default(),
};
window::show(app)?;
bridge::push(
app,
Push::PasteTarget {
name: target.name,
app: target.app,
},
);
Ok(())
}
/// 캡쳐 핫키 id 로 목적지 route 결정. `*_last` 는 마지막 기존 대화, 없으면 새 대화.
fn capture_target(id: &str, last_session: Option<&str>) -> String {
if id.ends_with("_last") {
last_session.unwrap_or("/snap/new").to_string()
} else {
"/snap/new".to_string()
}
}
/// 캡쳐 결과 공통 처리 — 팔레트 소환 → 목적지 대화 화면 → 그 화면 Composer 에 이미지 첨부.
fn on_captured(app: AppHandle, target: String, data_url: String) {
if let Err(error) = show_palette(&app) {
eprintln!("[capture] 창 소환 실패: {error}");
}
bridge::push(
&app,
Push::Navigate {
path: target.clone(),
},
);
bridge::push(
&app,
Push::CaptureImage {
path: target,
data_url,
},
);
}
/// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함.
fn on_hotkey(app: &AppHandle, id: &str) {
match id {
"snippet" => {
let _ = show_palette(app);
bridge::push(
app,
Push::Navigate {
path: "/snippet".into(),
},
);
}
"capture" | "capture_area" | "capture_last" | "capture_area_last" => {
let target = app
.state::<commands::RouteState>()
.lock()
.map(|routes| capture_target(id, routes.last_session.as_deref()))
.unwrap_or_else(|_| "/snap/new".to_string());
let deliver =
move |app: AppHandle, data_url: String| on_captured(app, target, data_url);
if id.starts_with("capture_area") {
shell::capture::start(app.clone(), deliver);
} else {
let own_hwnd = window::native_handle(app);
shell::capture::start_foreground_window(app.clone(), own_hwnd, deliver);
}
}
// 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다.
_ => {
let visible = window::is_visible(app);
let state = app.state::<commands::RouteState>();
let target = state
.lock()
.map(|routes| chat_toggle_target(visible, &routes.current, &routes.last_snap))
.unwrap_or_else(|_| Some("/snap".to_string()));
match target {
None => {
let _ = window::hide(app);
}
Some(path) => {
let _ = show_palette(app);
bridge::push(app, Push::Navigate { path });
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::{capture_target, chat_toggle_target, commands::Routes};
#[test]
fn 챗봇_토글은_현재_화면과_창_표시에_따라_결정한다() {
assert_eq!(chat_toggle_target(true, "/snap", "/snap/last"), None);
assert_eq!(
chat_toggle_target(true, "/files", "/snap/last"),
Some("/snap/last".to_string())
);
assert_eq!(
chat_toggle_target(true, "/snippet", "/snap/last"),
Some("/snap/last".to_string())
);
assert_eq!(
chat_toggle_target(false, "/snap", "/snap/last"),
Some("/snap/last".to_string())
);
}
#[test]
fn 마지막_기존_대화는_새_대화나_다른_화면으로_가도_유지된다() {
let mut routes = Routes::default();
routes.report("/snap/s/session-1");
routes.report("/snap/new");
routes.report("/files");
assert_eq!(routes.current, "/files");
assert_eq!(routes.last_snap, "/snap/new");
assert_eq!(routes.last_session.as_deref(), Some("/snap/s/session-1"));
}
#[test]
fn 캡쳐_핫키에_따라_새_대화나_마지막_기존_대화를_고른다() {
assert_eq!(capture_target("capture", Some("/snap/s/last")), "/snap/new");
assert_eq!(
capture_target("capture_area", Some("/snap/s/last")),
"/snap/new"
);
assert_eq!(
capture_target("capture_last", Some("/snap/s/last")),
"/snap/s/last"
);
assert_eq!(capture_target("capture_area_last", None), "/snap/new");
}
}