diff --git a/4_rust_tauri/package-lock.json b/4_rust_tauri/package-lock.json index c616c3b..2c69425 100644 --- a/4_rust_tauri/package-lock.json +++ b/4_rust_tauri/package-lock.json @@ -100,9 +100,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -120,9 +117,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -140,9 +134,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -160,9 +151,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -180,9 +168,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ diff --git a/4_rust_tauri/src-tauri/Cargo.toml b/4_rust_tauri/src-tauri/Cargo.toml index 430c850..6847f13 100644 --- a/4_rust_tauri/src-tauri/Cargo.toml +++ b/4_rust_tauri/src-tauri/Cargo.toml @@ -1,36 +1,39 @@ -[package] -name = "codeassist-tauri" -version = "0.1.0" -description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱" -authors = ["justdodev"] -edition = "2021" - -[lib] -# `_lib` 접미사는 Windows 에서 bin 이름과 충돌 안 나게 하려는 것 (rust-lang/cargo#8519) -name = "codeassist_tauri_lib" -crate-type = ["staticlib", "cdylib", "rlib"] - -[build-dependencies] -tauri-build = { version = "2", features = [] } - -[dependencies] -# tray-icon: 트레이 상주 / image-png: 트레이 아이콘을 png 로 얹기 -tauri = { version = "2", features = ["tray-icon", "image-png"] } -tauri-plugin-global-shortcut = "2" # 전역 핫키 (.NET HotKeyService 대체) -tauri-plugin-single-instance = "2" # 단일 인스턴스 (.NET SingleInstanceGuard 대체) -tauri-plugin-window-state = "2" # 창 위치·크기 저장 (.NET JsonWindowPlacementStore 대체) -serde = { version = "1", features = ["derive"] } -serde_json = "1" -rusqlite = { version = "0.32.1", features = ["bundled"] } -base64 = "0.22" -png = "0.17" -windows = { version = "0.61.3", features = [ - "Win32_Foundation", - "Win32_Graphics_Gdi", - "Win32_System_LibraryLoader", - "Win32_System_DataExchange", - "Win32_System_Memory", - "Win32_System_Threading", - "Win32_UI_Input_KeyboardAndMouse", - "Win32_UI_WindowsAndMessaging", -] } +[package] +name = "codeassist-tauri" +version = "0.1.0" +description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱" +authors = ["justdodev"] +edition = "2021" + +[lib] +# `_lib` 접미사는 Windows 에서 bin 이름과 충돌 안 나게 하려는 것 (rust-lang/cargo#8519) +name = "codeassist_tauri_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +# tray-icon: 트레이 상주 / image-png: 트레이 아이콘을 png 로 얹기 +tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri-plugin-global-shortcut = "2" # 전역 핫키 (.NET HotKeyService 대체) +tauri-plugin-single-instance = "2" # 단일 인스턴스 (.NET SingleInstanceGuard 대체) +tauri-plugin-window-state = "2" # 창 위치·크기 저장 (.NET JsonWindowPlacementStore 대체) +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.32.1", features = ["bundled"] } +base64 = "0.22" +png = "0.17" + +# Win32 (붙여넣기·영역 캡처) — Windows 빌드에서만. 맥/리눅스는 shell/*_stub.rs 껍데기. +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61.3", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_System_LibraryLoader", + "Win32_System_DataExchange", + "Win32_System_Memory", + "Win32_System_Threading", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/4_rust_tauri/src-tauri/src/bridge/commands.rs b/4_rust_tauri/src-tauri/src/bridge/commands.rs index f0e1118..90795c8 100644 --- a/4_rust_tauri/src-tauri/src/bridge/commands.rs +++ b/4_rust_tauri/src-tauri/src/bridge/commands.rs @@ -1,210 +1,207 @@ -//! JS→Rust 방향 계약 = `#[tauri::command]` 모음. -//! -//! .NET 판의 `OnWebMessageReceived` 안 `type` 스위치가 여기 함수 하나하나로 펴진 것. -//! reqId 로 요청·응답 짝 맞추던 인프라(`snippetBridge.ts` 46줄 + `SnippetBridge.cs`)는 -//! invoke 가 Promise 를 돌려주므로 통째로 없어진다. -//! -//! 창 조작·route 보고·직전 앱 붙여넣기·스니펫 저장을 제공함. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Mutex; - -use tauri::{AppHandle, State}; - -use crate::shell::{paste, paste::PasteState, window}; - -/// JS 가 보고하는 현재 route + 마지막 챗봇(`/snap`) route. -/// (.NET `App.xaml.cs` 의 `_currentRoute` / `_lastSnapRoute` 대응) -pub struct Routes { - pub current: String, - pub last_snap: String, -} - -impl Default for Routes { - fn default() -> Self { - // 부팅 시 프론트가 뜨는 자리와 맞춰둠(.NET StartPath 와 동일). - Self { - current: "/snap".into(), - last_snap: "/snap".into(), - } - } -} - -impl Routes { - pub fn report(&mut self, path: &str) { - if path == "/snap" || path.starts_with("/snap/") { - path.clone_into(&mut self.last_snap); - } - path.clone_into(&mut self.current); - } -} - -pub type RouteState = Mutex; - -// ─── 창 조작 (동작함) ─────────────────────────────────────────── - -#[tauri::command] -pub fn window_hide(app: AppHandle) { - let _ = window::hide(&app); -} - -/// 프레임리스 창이라 제목표시줄이 없음 — React 헤더 mousedown 이 이걸 불러 창을 끈다. -#[tauri::command] -pub fn window_drag(app: AppHandle) { - let _ = window::start_dragging(&app); -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SnippetStage { - Search, - Results, - Preview, - Editor, -} - -/// 프론트는 단계만 전달하고 실제 크기와 화면 경계는 호스트가 정함. -#[tauri::command] -pub fn window_snippet_layout(app: AppHandle, stage: SnippetStage) -> Result<(), String> { - let (width, height) = match stage { - SnippetStage::Search => (640.0, 84.0), - SnippetStage::Results => (640.0, 440.0), - SnippetStage::Preview => (840.0, 520.0), - SnippetStage::Editor => (960.0, 600.0), - }; - window::set_temporary_size(&app, width, height) -} - -// ─── route 보고 (동작함) ──────────────────────────────────────── - -#[tauri::command] -pub fn report_route( - app: AppHandle, - path: String, - state: State<'_, RouteState>, -) -> Result<(), String> { - if !path.starts_with("/snippet") { - window::restore_size(&app)?; - } - let mut routes = state.lock().map_err(|_| "현재 화면 상태를 읽지 못했어")?; - routes.report(&path); - Ok(()) -} - -// ─── 직전 앱 붙여넣기 (동작함) ─────────────────────────────────── - -#[tauri::command] -pub async fn paste_code( - text: String, - app: AppHandle, - state: State<'_, PasteState>, -) -> Result<(), String> { - static PASTE_IN_PROGRESS: AtomicBool = AtomicBool::new(false); - struct PasteGuard; - impl Drop for PasteGuard { - fn drop(&mut self) { - PASTE_IN_PROGRESS.store(false, Ordering::Release); - } - } - // 작업 큐에 넣기 전에 막아서 중복 요청이 나중에 붙여넣지 않게 함. - if PASTE_IN_PROGRESS.swap(true, Ordering::Acquire) { - return Err("이미 붙여넣는 중이야".to_string()); - } - let paste_guard = PasteGuard; - // 대상 스냅샷만 짧게 잠금. 키를 기다리는 동안 핫키·창 열기는 막지 않음. - let target = state - .lock() - .map(|target| target.clone()) - .map_err(|_| "붙여넣기 대상 상태를 읽지 못함".to_string()); - let palette_hwnd = window::main_window(&app) - .and_then(|window| window.hwnd().ok()) - .map(|hwnd| hwnd.0 as isize) - .unwrap_or_default(); - let worker_app = app.clone(); - let result = tauri::async_runtime::spawn_blocking(move || { - let _paste_guard = paste_guard; - let result = target - .and_then(|target| { - paste::paste(&text, &target, palette_hwnd, paste::DEFAULT_PASTE_DELAY) - }) - .and_then(|()| { - window::hide(&worker_app).map_err(|error| { - format!("붙여넣기 키 입력은 보냈지만 창을 숨기지 못함: {error}") - }) - }); - match result { - Ok(()) => Ok(()), - Err(error) => { - window::show(&worker_app).map_err(|restore| { - format!("{error} / 오류를 보여줄 창을 열지 못함: {restore}") - })?; - Err(error) - } - } - }) - .await; - match result { - Ok(result) => result, - Err(error) => { - window::show(&app).map_err(|restore| { - format!("붙여넣기 작업 실패: {error} / 창 열기 실패: {restore}") - })?; - Err(format!("붙여넣기 작업 실패: {error}")) - } - } -} - -// ─── 스니펫 저장 ──────────────────────────────────────────────── - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SnippetInput { - name: String, - desc: String, - body: String, - category: String, -} - -fn snippets_path() -> Result { - let base = std::env::var_os("LOCALAPPDATA").ok_or("LOCALAPPDATA 경로를 찾을 수 없음")?; - Ok(std::path::PathBuf::from(base) - .join("CodeAssist") - .join("snippets.db")) -} - -#[tauri::command] -pub fn snippets_list() -> Result { - super::snippets::list(&snippets_path()?) -} - -#[tauri::command] -pub fn snippets_create(snippet: SnippetInput) -> Result { - super::snippets::create_at( - &snippets_path()?, - &snippet.name, - &snippet.desc, - &snippet.body, - &snippet.category, - ) -} - -#[tauri::command] -pub fn snippets_update(snippet: SnippetInput) -> Result { - super::snippets::update_at( - &snippets_path()?, - &snippet.name, - &snippet.desc, - &snippet.body, - &snippet.category, - ) -} - -#[tauri::command] -pub fn snippets_delete(name: String) -> Result { - super::snippets::remove_at(&snippets_path()?, &name) -} - -#[tauri::command] -pub fn snippets_record_use(name: String) -> Result { - super::snippets::record_use_at(&snippets_path()?, &name) -} +//! JS→Rust 방향 계약 = `#[tauri::command]` 모음. +//! +//! .NET 판의 `OnWebMessageReceived` 안 `type` 스위치가 여기 함수 하나하나로 펴진 것. +//! reqId 로 요청·응답 짝 맞추던 인프라(`snippetBridge.ts` 46줄 + `SnippetBridge.cs`)는 +//! invoke 가 Promise 를 돌려주므로 통째로 없어진다. +//! +//! 창 조작·route 보고·직전 앱 붙여넣기·스니펫 저장을 제공함. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use tauri::{AppHandle, State}; + +use crate::shell::{paste, paste::PasteState, window}; + +/// JS 가 보고하는 현재 route + 마지막 챗봇(`/snap`) route. +/// (.NET `App.xaml.cs` 의 `_currentRoute` / `_lastSnapRoute` 대응) +pub struct Routes { + pub current: String, + pub last_snap: String, +} + +impl Default for Routes { + fn default() -> Self { + // 부팅 시 프론트가 뜨는 자리와 맞춰둠(.NET StartPath 와 동일). + Self { + current: "/snap".into(), + last_snap: "/snap".into(), + } + } +} + +impl Routes { + pub fn report(&mut self, path: &str) { + if path == "/snap" || path.starts_with("/snap/") { + path.clone_into(&mut self.last_snap); + } + path.clone_into(&mut self.current); + } +} + +pub type RouteState = Mutex; + +// ─── 창 조작 (동작함) ─────────────────────────────────────────── + +#[tauri::command] +pub fn window_hide(app: AppHandle) { + let _ = window::hide(&app); +} + +/// 프레임리스 창이라 제목표시줄이 없음 — React 헤더 mousedown 이 이걸 불러 창을 끈다. +#[tauri::command] +pub fn window_drag(app: AppHandle) { + let _ = window::start_dragging(&app); +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SnippetStage { + Search, + Results, + Preview, + Editor, +} + +/// 프론트는 단계만 전달하고 실제 크기와 화면 경계는 호스트가 정함. +#[tauri::command] +pub fn window_snippet_layout(app: AppHandle, stage: SnippetStage) -> Result<(), String> { + let (width, height) = match stage { + SnippetStage::Search => (640.0, 84.0), + SnippetStage::Results => (640.0, 440.0), + SnippetStage::Preview => (840.0, 520.0), + SnippetStage::Editor => (960.0, 600.0), + }; + window::set_temporary_size(&app, width, height) +} + +// ─── route 보고 (동작함) ──────────────────────────────────────── + +#[tauri::command] +pub fn report_route( + app: AppHandle, + path: String, + state: State<'_, RouteState>, +) -> Result<(), String> { + if !path.starts_with("/snippet") { + window::restore_size(&app)?; + } + let mut routes = state.lock().map_err(|_| "현재 화면 상태를 읽지 못했어")?; + routes.report(&path); + Ok(()) +} + +// ─── 직전 앱 붙여넣기 (동작함) ─────────────────────────────────── + +#[tauri::command] +pub async fn paste_code( + text: String, + app: AppHandle, + state: State<'_, PasteState>, +) -> Result<(), String> { + static PASTE_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + struct PasteGuard; + impl Drop for PasteGuard { + fn drop(&mut self) { + PASTE_IN_PROGRESS.store(false, Ordering::Release); + } + } + // 작업 큐에 넣기 전에 막아서 중복 요청이 나중에 붙여넣지 않게 함. + if PASTE_IN_PROGRESS.swap(true, Ordering::Acquire) { + return Err("이미 붙여넣는 중이야".to_string()); + } + let paste_guard = PasteGuard; + // 대상 스냅샷만 짧게 잠금. 키를 기다리는 동안 핫키·창 열기는 막지 않음. + let target = state + .lock() + .map(|target| target.clone()) + .map_err(|_| "붙여넣기 대상 상태를 읽지 못함".to_string()); + let palette_hwnd = window::native_handle(&app).unwrap_or_default(); + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let _paste_guard = paste_guard; + let result = target + .and_then(|target| { + paste::paste(&text, &target, palette_hwnd, paste::DEFAULT_PASTE_DELAY) + }) + .and_then(|()| { + window::hide(&worker_app).map_err(|error| { + format!("붙여넣기 키 입력은 보냈지만 창을 숨기지 못함: {error}") + }) + }); + match result { + Ok(()) => Ok(()), + Err(error) => { + window::show(&worker_app).map_err(|restore| { + format!("{error} / 오류를 보여줄 창을 열지 못함: {restore}") + })?; + Err(error) + } + } + }) + .await; + match result { + Ok(result) => result, + Err(error) => { + window::show(&app).map_err(|restore| { + format!("붙여넣기 작업 실패: {error} / 창 열기 실패: {restore}") + })?; + Err(format!("붙여넣기 작업 실패: {error}")) + } + } +} + +// ─── 스니펫 저장 ──────────────────────────────────────────────── + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetInput { + name: String, + desc: String, + body: String, + category: String, +} + +fn snippets_path() -> Result { + let base = std::env::var_os("LOCALAPPDATA").ok_or("LOCALAPPDATA 경로를 찾을 수 없음")?; + Ok(std::path::PathBuf::from(base) + .join("CodeAssist") + .join("snippets.db")) +} + +#[tauri::command] +pub fn snippets_list() -> Result { + super::snippets::list(&snippets_path()?) +} + +#[tauri::command] +pub fn snippets_create(snippet: SnippetInput) -> Result { + super::snippets::create_at( + &snippets_path()?, + &snippet.name, + &snippet.desc, + &snippet.body, + &snippet.category, + ) +} + +#[tauri::command] +pub fn snippets_update(snippet: SnippetInput) -> Result { + super::snippets::update_at( + &snippets_path()?, + &snippet.name, + &snippet.desc, + &snippet.body, + &snippet.category, + ) +} + +#[tauri::command] +pub fn snippets_delete(name: String) -> Result { + super::snippets::remove_at(&snippets_path()?, &name) +} + +#[tauri::command] +pub fn snippets_record_use(name: String) -> Result { + super::snippets::record_use_at(&snippets_path()?, &name) +} diff --git a/4_rust_tauri/src-tauri/src/lib.rs b/4_rust_tauri/src-tauri/src/lib.rs index 7ce3cc3..17a796e 100644 --- a/4_rust_tauri/src-tauri/src/lib.rs +++ b/4_rust_tauri/src-tauri/src/lib.rs @@ -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 { - 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::(); - 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::(); - 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 { + 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::(); + 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::(); + 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"); + } +} diff --git a/4_rust_tauri/src-tauri/src/shell/capture_stub.rs b/4_rust_tauri/src-tauri/src/shell/capture_stub.rs new file mode 100644 index 0000000..c5edeec --- /dev/null +++ b/4_rust_tauri/src-tauri/src/shell/capture_stub.rs @@ -0,0 +1,10 @@ +//! `capture.rs` 의 Windows 외 껍데기 — 영역 캡처 오버레이는 GDI 전용이라 맥/리눅스에선 아무것도 안 함. + +use tauri::AppHandle; + +pub fn start(_app: AppHandle, _on_captured: F) +where + F: FnOnce(AppHandle, String) + Send + 'static, +{ + eprintln!("[capture] 영역 캡처는 Windows 에서만 됨"); +} diff --git a/4_rust_tauri/src-tauri/src/shell/mod.rs b/4_rust_tauri/src-tauri/src/shell/mod.rs index d4d482b..013e085 100644 --- a/4_rust_tauri/src-tauri/src/shell/mod.rs +++ b/4_rust_tauri/src-tauri/src/shell/mod.rs @@ -1,14 +1,23 @@ -//! 프로젝트 무관 재사용 층 — .NET 판의 `CodeAssist.Shell` 과 **같은 경계**로 잘랐다. -//! -//! 여기 있는 것들은 "CodeAssist" 를 하나도 모른다. route 이름도, 스니펫도, 브릿지 계약도 모름. -//! 아는 건 "창 하나 띄우고 숨기는 앱", "핫키 누르면 콜백 부르는 앱" 뿐이라, 다른 프로젝트에 -//! 이 폴더째 복사해도 돌아간다. 프로젝트 고유 배선은 전부 `lib.rs`(조립부)에 있음. -//! -//! 일부러 `init(ShellConfig)` 같은 통합 진입점을 안 만들었다 — 세 모듈이 서로 독립이고 -//! 콜백도 각자 달라서, 감싸봐야 그냥 넘겨주기만 하는 껍데기가 하나 더 생길 뿐임. - -pub mod capture; -pub mod hotkey; -pub mod paste; -pub mod tray; -pub mod window; +//! 프로젝트 무관 재사용 층 — .NET 판의 `CodeAssist.Shell` 과 **같은 경계**로 잘랐다. +//! +//! 여기 있는 것들은 "CodeAssist" 를 하나도 모른다. route 이름도, 스니펫도, 브릿지 계약도 모름. +//! 아는 건 "창 하나 띄우고 숨기는 앱", "핫키 누르면 콜백 부르는 앱" 뿐이라, 다른 프로젝트에 +//! 이 폴더째 복사해도 돌아간다. 프로젝트 고유 배선은 전부 `lib.rs`(조립부)에 있음. +//! +//! 일부러 `init(ShellConfig)` 같은 통합 진입점을 안 만들었다 — 세 모듈이 서로 독립이고 +//! 콜백도 각자 달라서, 감싸봐야 그냥 넘겨주기만 하는 껍데기가 하나 더 생길 뿐임. + +// capture·paste 는 Win32 전용. 다른 OS 는 같은 API 의 껍데기(_stub)로 컴파일만 되게 함 — 맥에서 개발용. +#[cfg(windows)] +pub mod capture; +#[cfg(not(windows))] +#[path = "capture_stub.rs"] +pub mod capture; +pub mod hotkey; +#[cfg(windows)] +pub mod paste; +#[cfg(not(windows))] +#[path = "paste_stub.rs"] +pub mod paste; +pub mod tray; +pub mod window; diff --git a/4_rust_tauri/src-tauri/src/shell/paste_stub.rs b/4_rust_tauri/src-tauri/src/shell/paste_stub.rs new file mode 100644 index 0000000..b11bf94 --- /dev/null +++ b/4_rust_tauri/src-tauri/src/shell/paste_stub.rs @@ -0,0 +1,31 @@ +//! `paste.rs` 의 Windows 외 껍데기 — 직전 앱 기억·키 입력 붙여넣기는 Win32 전용이라 +//! 맥/리눅스에선 "대상 없음"으로만 동작함. 공개 API(타입·함수 시그니처)는 paste.rs 와 같아야 함. + +use std::sync::Mutex; +use std::time::Duration; + +pub const DEFAULT_PASTE_DELAY: Duration = Duration::from_millis(80); + +#[derive(Clone, Debug, Default)] +pub struct PasteTarget { + pub name: String, + pub app: String, +} + +pub type PasteState = Mutex; + +pub fn capture_foreground(_excluded_hwnd: Option) -> Option { + None +} + +pub fn paste( + text: &str, + _target: &PasteTarget, + _palette_hwnd: isize, + _delay: Duration, +) -> Result<(), String> { + if text.is_empty() { + return Err("붙여넣을 코드가 비었음".to_string()); + } + Err("직전 앱 붙여넣기는 Windows 에서만 됨".to_string()) +} diff --git a/4_rust_tauri/src-tauri/src/shell/window.rs b/4_rust_tauri/src-tauri/src/shell/window.rs index e3c0da0..a3cdec2 100644 --- a/4_rust_tauri/src-tauri/src/shell/window.rs +++ b/4_rust_tauri/src-tauri/src/shell/window.rs @@ -1,230 +1,246 @@ -//! 메인 창 조작 + 핀(항상 위) 상태 보관. -//! -//! 위치·크기 복원은 `tauri-plugin-window-state` 가 다 해줌 (.NET 의 `JsonWindowPlacementStore` -//! 46줄이 통째로 사라진 자리). 다만 그 플러그인이 always-on-top 은 저장 안 해서, 핀만 -//! 여기서 따로 텍스트 파일 하나에 남긴다. - -use std::fs; -use std::path::PathBuf; -use std::sync::Mutex; - -use tauri::{ - AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, WebviewWindow, -}; - -/// tauri.conf.json 의 창 label 과 같아야 함. -const MAIN: &str = "main"; - -pub fn main_window(app: &AppHandle) -> Option { - app.get_webview_window(MAIN) -} - -/// 창 표시 + 포커스. (핫키/트레이 소환용) -pub fn show(app: &AppHandle) -> tauri::Result<()> { - if let Some(w) = main_window(app) { - w.show()?; - w.set_focus()?; - } - Ok(()) -} - -pub fn hide(app: &AppHandle) -> tauri::Result<()> { - if let Some(w) = main_window(app) { - w.hide()?; - } - Ok(()) -} - -pub fn start_dragging(app: &AppHandle) -> tauri::Result<()> { - if let Some(w) = main_window(app) { - w.start_dragging()?; - } - Ok(()) -} - -pub fn is_visible(app: &AppHandle) -> bool { - main_window(app) - .and_then(|w| w.is_visible().ok()) - .unwrap_or(false) -} - -/// 핀 적용 + 저장. 저장 실패는 무시 — 다음 실행 때 기본값으로 뜰 뿐 지금 동작은 멀쩡함. -pub fn set_pinned(app: &AppHandle, pinned: bool) -> tauri::Result<()> { - if let Some(w) = main_window(app) { - w.set_always_on_top(pinned)?; - } - save_pinned(app, pinned); - Ok(()) -} - -pub fn load_pinned(app: &AppHandle) -> bool { - pinned_path(app) - .and_then(|p| fs::read_to_string(p).ok()) - .map(|s| s.trim() == "true") - .unwrap_or(false) -} - -// `%LocalAppData%\\com.codeassist.app\\pinned.txt`에 Tauri 핀 상태만 따로 둠. -fn pinned_path(app: &AppHandle) -> Option { - app.path() - .app_local_data_dir() - .ok() - .map(|d| d.join("pinned.txt")) -} - -fn save_pinned(app: &AppHandle, pinned: bool) { - let Some(path) = pinned_path(app) else { return }; - if let Some(dir) = path.parent() { - let _ = fs::create_dir_all(dir); - } - let _ = fs::write(path, if pinned { "true" } else { "false" }); -} - -/// 일시적으로 펼친 창 크기는 일반 창의 저장값을 덮어쓰지 않음. -#[derive(Default)] -pub struct TemporaryWindowState(Mutex>); - -struct WindowPlacement { - size: PhysicalSize, - position: PhysicalPosition, - maximized: bool, -} - -fn fit_temporary_window( - requested: LogicalSize, - scale: f64, - origin: PhysicalPosition, - area: &PhysicalRect, -) -> (PhysicalSize, PhysicalPosition) { - let requested = requested.to_physical::(scale); - let size = PhysicalSize::new( - requested.width.min(area.size.width), - requested.height.min(area.size.height), - ); - let position = PhysicalPosition::new( - origin.x.clamp( - area.position.x, - area.position.x + (area.size.width - size.width) as i32, - ), - origin.y.clamp( - area.position.y, - area.position.y + (area.size.height - size.height) as i32, - ), - ); - (size, position) -} - -pub fn set_temporary_size(app: &AppHandle, width: f64, height: f64) -> Result<(), String> { - let window = main_window(app).ok_or("메인 창을 찾지 못했어")?; - let state = app.state::(); - let mut saved = state.0.lock().map_err(|_| "창 크기 상태를 읽지 못했어")?; - let apply = || -> tauri::Result { - // 최대화된 크기가 아니라 최대화 전의 일반 창 배치를 보관함. - let maximized = window.is_maximized()?; - if maximized { - window.unmaximize()?; - } - Ok(WindowPlacement { - size: window.inner_size()?, - position: window.outer_position()?, - maximized, - }) - }; - if saved.is_none() { - *saved = Some(apply().map_err(|error| error.to_string())?); - } - let resize = || -> tauri::Result<()> { - if window.is_maximized()? { - window.unmaximize()?; - } - let requested = LogicalSize::new(width, height); - if let Some(monitor) = window.current_monitor()? { - // Windows의 프레임리스 창에도 바깥 크기에는 리사이즈 테두리가 포함됨. - let inner = window.inner_size()?; - let outer = window.outer_size()?; - let work_area = monitor.work_area(); - let content_area = PhysicalRect { - position: work_area.position, - size: PhysicalSize::new( - work_area - .size - .width - .saturating_sub(outer.width.saturating_sub(inner.width)), - work_area - .size - .height - .saturating_sub(outer.height.saturating_sub(inner.height)), - ), - }; - let (size, position) = fit_temporary_window( - requested, - window.scale_factor()?, - window.outer_position()?, - &content_area, - ); - window.set_size(size)?; - window.set_position(position)?; - } else { - window.set_size(requested)?; - } - Ok(()) - }; - resize().map_err(|error| error.to_string()) -} - -pub fn restore_size(app: &AppHandle) -> Result<(), String> { - let state = app.state::(); - let mut saved = state.0.lock().map_err(|_| "창 크기 상태를 읽지 못했어")?; - let Some(placement) = saved.as_ref() else { - return Ok(()); - }; - let window = main_window(app).ok_or("메인 창을 찾지 못했어")?; - let restore = || -> tauri::Result<()> { - window.set_size(placement.size)?; - window.set_position(placement.position)?; - if placement.maximized { - window.maximize()?; - } - Ok(()) - }; - restore().map_err(|error| error.to_string())?; - *saved = None; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn expanded_window_stays_inside_scaled_negative_monitor_work_area() { - let area = tauri::PhysicalRect { - position: tauri::PhysicalPosition::new(-1920, 0), - size: tauri::PhysicalSize::new(1920, 1040), - }; - let (size, position) = fit_temporary_window( - tauri::LogicalSize::new(960.0, 600.0), - 1.5, - tauri::PhysicalPosition::new(-700, 900), - &area, - ); - assert_eq!(size, tauri::PhysicalSize::new(1440, 900)); - assert_eq!(position, tauri::PhysicalPosition::new(-1440, 140)); - } - - #[test] - fn small_work_area_caps_size_without_moving_an_in_bounds_origin() { - let area = tauri::PhysicalRect { - position: tauri::PhysicalPosition::new(0, 0), - size: tauri::PhysicalSize::new(800, 500), - }; - let (size, position) = fit_temporary_window( - tauri::LogicalSize::new(960.0, 600.0), - 1.0, - tauri::PhysicalPosition::new(0, 0), - &area, - ); - assert_eq!(size, area.size); - assert_eq!(position, area.position); - } -} +//! 메인 창 조작 + 핀(항상 위) 상태 보관. +//! +//! 위치·크기 복원은 `tauri-plugin-window-state` 가 다 해줌 (.NET 의 `JsonWindowPlacementStore` +//! 46줄이 통째로 사라진 자리). 다만 그 플러그인이 always-on-top 은 저장 안 해서, 핀만 +//! 여기서 따로 텍스트 파일 하나에 남긴다. + +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +use tauri::{ + AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, WebviewWindow, +}; + +/// tauri.conf.json 의 창 label 과 같아야 함. +const MAIN: &str = "main"; + +pub fn main_window(app: &AppHandle) -> Option { + app.get_webview_window(MAIN) +} + +/// 창 표시 + 포커스. (핫키/트레이 소환용) +/// 붙여넣기 대상에서 우리 창을 빼거나 포커스 비교할 때 쓰는 네이티브 창 핸들(HWND). +/// Windows 에서만 값이 있고 다른 OS 는 None — 호출부가 알아서 "대상 없음"으로 처리함. +pub fn native_handle(app: &AppHandle) -> Option { + #[cfg(windows)] + { + main_window(app) + .and_then(|window| window.hwnd().ok()) + .map(|hwnd| hwnd.0 as isize) + } + #[cfg(not(windows))] + { + let _ = app; + None + } +} + +pub fn show(app: &AppHandle) -> tauri::Result<()> { + if let Some(w) = main_window(app) { + w.show()?; + w.set_focus()?; + } + Ok(()) +} + +pub fn hide(app: &AppHandle) -> tauri::Result<()> { + if let Some(w) = main_window(app) { + w.hide()?; + } + Ok(()) +} + +pub fn start_dragging(app: &AppHandle) -> tauri::Result<()> { + if let Some(w) = main_window(app) { + w.start_dragging()?; + } + Ok(()) +} + +pub fn is_visible(app: &AppHandle) -> bool { + main_window(app) + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false) +} + +/// 핀 적용 + 저장. 저장 실패는 무시 — 다음 실행 때 기본값으로 뜰 뿐 지금 동작은 멀쩡함. +pub fn set_pinned(app: &AppHandle, pinned: bool) -> tauri::Result<()> { + if let Some(w) = main_window(app) { + w.set_always_on_top(pinned)?; + } + save_pinned(app, pinned); + Ok(()) +} + +pub fn load_pinned(app: &AppHandle) -> bool { + pinned_path(app) + .and_then(|p| fs::read_to_string(p).ok()) + .map(|s| s.trim() == "true") + .unwrap_or(false) +} + +// `%LocalAppData%\\com.codeassist.app\\pinned.txt`에 Tauri 핀 상태만 따로 둠. +fn pinned_path(app: &AppHandle) -> Option { + app.path() + .app_local_data_dir() + .ok() + .map(|d| d.join("pinned.txt")) +} + +fn save_pinned(app: &AppHandle, pinned: bool) { + let Some(path) = pinned_path(app) else { return }; + if let Some(dir) = path.parent() { + let _ = fs::create_dir_all(dir); + } + let _ = fs::write(path, if pinned { "true" } else { "false" }); +} + +/// 일시적으로 펼친 창 크기는 일반 창의 저장값을 덮어쓰지 않음. +#[derive(Default)] +pub struct TemporaryWindowState(Mutex>); + +struct WindowPlacement { + size: PhysicalSize, + position: PhysicalPosition, + maximized: bool, +} + +fn fit_temporary_window( + requested: LogicalSize, + scale: f64, + origin: PhysicalPosition, + area: &PhysicalRect, +) -> (PhysicalSize, PhysicalPosition) { + let requested = requested.to_physical::(scale); + let size = PhysicalSize::new( + requested.width.min(area.size.width), + requested.height.min(area.size.height), + ); + let position = PhysicalPosition::new( + origin.x.clamp( + area.position.x, + area.position.x + (area.size.width - size.width) as i32, + ), + origin.y.clamp( + area.position.y, + area.position.y + (area.size.height - size.height) as i32, + ), + ); + (size, position) +} + +pub fn set_temporary_size(app: &AppHandle, width: f64, height: f64) -> Result<(), String> { + let window = main_window(app).ok_or("메인 창을 찾지 못했어")?; + let state = app.state::(); + let mut saved = state.0.lock().map_err(|_| "창 크기 상태를 읽지 못했어")?; + let apply = || -> tauri::Result { + // 최대화된 크기가 아니라 최대화 전의 일반 창 배치를 보관함. + let maximized = window.is_maximized()?; + if maximized { + window.unmaximize()?; + } + Ok(WindowPlacement { + size: window.inner_size()?, + position: window.outer_position()?, + maximized, + }) + }; + if saved.is_none() { + *saved = Some(apply().map_err(|error| error.to_string())?); + } + let resize = || -> tauri::Result<()> { + if window.is_maximized()? { + window.unmaximize()?; + } + let requested = LogicalSize::new(width, height); + if let Some(monitor) = window.current_monitor()? { + // Windows의 프레임리스 창에도 바깥 크기에는 리사이즈 테두리가 포함됨. + let inner = window.inner_size()?; + let outer = window.outer_size()?; + let work_area = monitor.work_area(); + let content_area = PhysicalRect { + position: work_area.position, + size: PhysicalSize::new( + work_area + .size + .width + .saturating_sub(outer.width.saturating_sub(inner.width)), + work_area + .size + .height + .saturating_sub(outer.height.saturating_sub(inner.height)), + ), + }; + let (size, position) = fit_temporary_window( + requested, + window.scale_factor()?, + window.outer_position()?, + &content_area, + ); + window.set_size(size)?; + window.set_position(position)?; + } else { + window.set_size(requested)?; + } + Ok(()) + }; + resize().map_err(|error| error.to_string()) +} + +pub fn restore_size(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut saved = state.0.lock().map_err(|_| "창 크기 상태를 읽지 못했어")?; + let Some(placement) = saved.as_ref() else { + return Ok(()); + }; + let window = main_window(app).ok_or("메인 창을 찾지 못했어")?; + let restore = || -> tauri::Result<()> { + window.set_size(placement.size)?; + window.set_position(placement.position)?; + if placement.maximized { + window.maximize()?; + } + Ok(()) + }; + restore().map_err(|error| error.to_string())?; + *saved = None; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expanded_window_stays_inside_scaled_negative_monitor_work_area() { + let area = tauri::PhysicalRect { + position: tauri::PhysicalPosition::new(-1920, 0), + size: tauri::PhysicalSize::new(1920, 1040), + }; + let (size, position) = fit_temporary_window( + tauri::LogicalSize::new(960.0, 600.0), + 1.5, + tauri::PhysicalPosition::new(-700, 900), + &area, + ); + assert_eq!(size, tauri::PhysicalSize::new(1440, 900)); + assert_eq!(position, tauri::PhysicalPosition::new(-1440, 140)); + } + + #[test] + fn small_work_area_caps_size_without_moving_an_in_bounds_origin() { + let area = tauri::PhysicalRect { + position: tauri::PhysicalPosition::new(0, 0), + size: tauri::PhysicalSize::new(800, 500), + }; + let (size, position) = fit_temporary_window( + tauri::LogicalSize::new(960.0, 600.0), + 1.0, + tauri::PhysicalPosition::new(0, 0), + &area, + ); + assert_eq!(size, area.size); + assert_eq!(position, area.position); + } +}