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:
Generated
-15
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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
@@ -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 에서만 됨");
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user