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