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