feat(tauri): 맥에서도 빌드되게 Win32 모듈을 cfg(windows) 로 분리

windows crate 를 Windows 타깃 전용 의존성으로 옮기고, paste/capture 는 다른 OS 에서
같은 API 의 껍데기(_stub)로 컴파일. hwnd 접근은 window::native_handle 하나로.
Windows 동작은 그대로. 맥에선 붙여넣기·영역 캡처가 'Windows 에서만 됨'으로 응답.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 21:24:17 +09:00
co-authored by Claude Fable 5.1
parent 54f75aa089
commit 506fbf601d
8 changed files with 787 additions and 738 deletions
+231 -233
View File
@@ -1,233 +1,231 @@
//! 조립부 — .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+Shift+8",
},
HotkeySpec {
id: "snippet",
accelerator: "Ctrl+Shift+7",
},
HotkeySpec {
id: "capture",
accelerator: "Ctrl+Shift+9",
},
];
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())
.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,
])
.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::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::main_window(app)
.and_then(|window| window.hwnd().ok())
.map(|hwnd| hwnd.0 as isize);
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(())
}
/// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함.
fn on_hotkey(app: &AppHandle, id: &str) {
match id {
"snippet" => {
let _ = show_palette(app);
bridge::push(
app,
Push::Navigate {
path: "/snippet".into(),
},
);
}
"capture" => {
shell::capture::start(app.clone(), |app, data_url| {
let _ = show_palette(&app);
bridge::push(
&app,
Push::Navigate {
path: "/snap/new".into(),
},
);
bridge::push(&app, Push::CaptureImage { data_url });
});
}
// 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다.
_ => {
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::{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/session-1");
routes.report("/snippet");
routes.report("/files");
assert_eq!(routes.current, "/files");
assert_eq!(routes.last_snap, "/snap/session-1");
}
}
//! 조립부 — .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+Shift+8",
},
HotkeySpec {
id: "snippet",
accelerator: "Ctrl+Shift+7",
},
HotkeySpec {
id: "capture",
accelerator: "Ctrl+Shift+9",
},
];
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())
.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,
])
.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::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(())
}
/// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함.
fn on_hotkey(app: &AppHandle, id: &str) {
match id {
"snippet" => {
let _ = show_palette(app);
bridge::push(
app,
Push::Navigate {
path: "/snippet".into(),
},
);
}
"capture" => {
shell::capture::start(app.clone(), |app, data_url| {
let _ = show_palette(&app);
bridge::push(
&app,
Push::Navigate {
path: "/snap/new".into(),
},
);
bridge::push(&app, Push::CaptureImage { data_url });
});
}
// 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다.
_ => {
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::{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/session-1");
routes.report("/snippet");
routes.report("/files");
assert_eq!(routes.current, "/files");
assert_eq!(routes.last_snap, "/snap/session-1");
}
}