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
+207 -210
View File
@@ -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<Routes>;
// ─── 창 조작 (동작함) ───────────────────────────────────────────
#[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<std::path::PathBuf, String> {
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<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(
&snippets_path()?,
&snippet.name,
&snippet.desc,
&snippet.body,
&snippet.category,
)
}
#[tauri::command]
pub fn snippets_update(snippet: SnippetInput) -> Result<serde_json::Value, String> {
super::snippets::update_at(
&snippets_path()?,
&snippet.name,
&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_record_use(name: String) -> Result<serde_json::Value, String> {
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<Routes>;
// ─── 창 조작 (동작함) ───────────────────────────────────────────
#[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<std::path::PathBuf, String> {
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<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(
&snippets_path()?,
&snippet.name,
&snippet.desc,
&snippet.body,
&snippet.category,
)
}
#[tauri::command]
pub fn snippets_update(snippet: SnippetInput) -> Result<serde_json::Value, String> {
super::snippets::update_at(
&snippets_path()?,
&snippet.name,
&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_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` 에 대응한다.
//!
//! 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");
}
}
@@ -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` 과 **같은 경계**로 잘랐다.
//!
//! 여기 있는 것들은 "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;
@@ -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`
//! 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<WebviewWindow> {
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<PathBuf> {
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<Option<WindowPlacement>>);
struct WindowPlacement {
size: PhysicalSize<u32>,
position: PhysicalPosition<i32>,
maximized: bool,
}
fn fit_temporary_window(
requested: LogicalSize<f64>,
scale: f64,
origin: PhysicalPosition<i32>,
area: &PhysicalRect<i32, u32>,
) -> (PhysicalSize<u32>, PhysicalPosition<i32>) {
let requested = requested.to_physical::<u32>(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::<TemporaryWindowState>();
let mut saved = state.0.lock().map_err(|_| "창 크기 상태를 읽지 못했어")?;
let apply = || -> tauri::Result<WindowPlacement> {
// 최대화된 크기가 아니라 최대화 전의 일반 창 배치를 보관함.
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::<TemporaryWindowState>();
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<WebviewWindow> {
app.get_webview_window(MAIN)
}
/// 창 표시 + 포커스. (핫키/트레이 소환용)
/// 붙여넣기 대상에서 우리 창을 빼거나 포커스 비교할 때 쓰는 네이티브 창 핸들(HWND).
/// Windows 에서만 값이 있고 다른 OS 는 None — 호출부가 알아서 "대상 없음"으로 처리함.
pub fn native_handle(app: &AppHandle) -> Option<isize> {
#[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<PathBuf> {
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<Option<WindowPlacement>>);
struct WindowPlacement {
size: PhysicalSize<u32>,
position: PhysicalPosition<i32>,
maximized: bool,
}
fn fit_temporary_window(
requested: LogicalSize<f64>,
scale: f64,
origin: PhysicalPosition<i32>,
area: &PhysicalRect<i32, u32>,
) -> (PhysicalSize<u32>, PhysicalPosition<i32>) {
let requested = requested.to_physical::<u32>(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::<TemporaryWindowState>();
let mut saved = state.0.lock().map_err(|_| "창 크기 상태를 읽지 못했어")?;
let apply = || -> tauri::Result<WindowPlacement> {
// 최대화된 크기가 아니라 최대화 전의 일반 창 배치를 보관함.
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::<TemporaryWindowState>();
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);
}
}