Initial Commit
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,36 @@
|
||||
[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",
|
||||
] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "메인 창 권한. 프론트는 core API 를 직접 안 부르고 우리 #[tauri::command] 만 invoke 하므로 core:default 로 충분 (listen/emit 포함).",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,210 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! 프론트와의 계약 층. **진실원천은 여기가 아니라** 아래 두 문서다:
|
||||
//! - `specs/001-snippet-palette/contracts/bridge-messages.md`
|
||||
//! - `specs/002-capture-to-chat/contracts/bridge-capture.md`
|
||||
//!
|
||||
//! .NET 판은 `chrome.webview.postMessage`(JS→C#) + `PostWebMessageAsJson`(C#→JS) 한 쌍으로
|
||||
//! 계약을 구현한다. 여기선 방향별로 수단이 갈린다:
|
||||
//! - JS→Rust : `#[tauri::command]` (commands.rs) — reqId 짝맞추기가 invoke 의 Promise 로 대체됨
|
||||
//! - Rust→JS : 이벤트 **한 채널**(`CHANNEL`)로 `{type, ...}` 을 그대로 emit
|
||||
//!
|
||||
//! Rust→JS 를 굳이 한 채널에 몰아넣는 이유: 프론트 `bridgeNavigate.ts` 의 `type` 분기와
|
||||
//! CustomEvent dispatch 로직을 **한 줄도 안 고치고** 재사용하려고. 채널 이름만 다르고
|
||||
//! 페이로드 모양은 .NET 이 보내던 것과 완전히 같다.
|
||||
|
||||
pub mod commands;
|
||||
mod snippets;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// Rust→JS 푸시 채널. 프론트는 `listen(CHANNEL, ...)` 하나만 걸고 `type` 으로 갈라 본다.
|
||||
pub const CHANNEL: &str = "bridge";
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Push {
|
||||
#[serde(rename = "navigate")]
|
||||
Navigate { path: String },
|
||||
|
||||
/// 소환 직전 foreground 창 스냅샷 — 붙여넣기 대상 배지에 뜸.
|
||||
#[serde(rename = "paste.target")]
|
||||
PasteTarget { name: String, app: String },
|
||||
|
||||
/// 영역 캡쳐 PNG를 프론트의 새 대화 Composer에 붙임.
|
||||
#[serde(rename = "capture.image")]
|
||||
CaptureImage {
|
||||
#[serde(rename = "dataUrl")]
|
||||
data_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 푸시 실패는 로그만 남기고 삼킨다 — 창이 아직 안 떴거나 막 닫힌 정상 상황에서도 나는데,
|
||||
/// 그걸로 앱을 죽일 이유가 없음.
|
||||
pub fn push(app: &AppHandle, msg: Push) {
|
||||
if let Err(e) = app.emit(CHANNEL, msg) {
|
||||
eprintln!("[bridge] 푸시 실패: {e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! 닷넷과 같은 SQLite 스키마를 그대로 쓰는 스니펫 저장소.
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection, ErrorCode, OptionalExtension};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn list(path: &Path) -> Result<Value, String> {
|
||||
let conn = open(path)?;
|
||||
read(&conn).map_err(|e| format!("스니펫 목록 조회 실패: {e}"))
|
||||
}
|
||||
|
||||
pub fn create_at(
|
||||
path: &Path,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
body: &str,
|
||||
category: &str,
|
||||
) -> Result<Value, String> {
|
||||
let mut conn = open(path)?;
|
||||
create(&mut conn, name, desc, body, category)
|
||||
}
|
||||
|
||||
pub fn update_at(
|
||||
path: &Path,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
body: &str,
|
||||
category: &str,
|
||||
) -> Result<Value, String> {
|
||||
let mut conn = open(path)?;
|
||||
update(&mut conn, name, desc, body, category)
|
||||
}
|
||||
|
||||
pub fn remove_at(path: &Path, name: &str) -> Result<Value, String> {
|
||||
let mut conn = open(path)?;
|
||||
remove(&mut conn, name)
|
||||
}
|
||||
|
||||
pub fn record_use_at(path: &Path, name: &str) -> Result<Value, String> {
|
||||
let mut conn = open(path)?;
|
||||
record_use(&mut conn, name)
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Result<Connection, String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("스니펫 DB 폴더 생성 실패 ({}): {e}", parent.display()))?;
|
||||
}
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| format!("스니펫 DB 열기 실패 ({}): {e}", path.display()))?;
|
||||
ensure_schema(&conn).map_err(|e| format!("스니펫 DB 준비 실패: {e}"))?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn ensure_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS snippets (
|
||||
name TEXT PRIMARY KEY,
|
||||
desc TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '코드'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS usage (
|
||||
name TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
}
|
||||
|
||||
fn read(conn: &Connection) -> rusqlite::Result<Value> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT s.name, s.desc, s.body, s.category, COALESCE(u.count, 0), COALESCE(u.last_used, 0)
|
||||
FROM snippets s LEFT JOIN usage u ON u.name = s.name",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(json!({
|
||||
"name": row.get::<_, String>(0)?,
|
||||
"desc": row.get::<_, String>(1)?,
|
||||
"body": row.get::<_, String>(2)?,
|
||||
"category": row.get::<_, String>(3)?,
|
||||
"usageCount": row.get::<_, i64>(4)?,
|
||||
"lastUsed": row.get::<_, i64>(5)?,
|
||||
}))
|
||||
})?;
|
||||
Ok(Value::Array(rows.collect::<rusqlite::Result<Vec<_>>>()?))
|
||||
}
|
||||
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.trim().to_uppercase().replace(' ', "_")
|
||||
}
|
||||
|
||||
fn snippet_value(
|
||||
name: &str,
|
||||
desc: &str,
|
||||
body: &str,
|
||||
category: &str,
|
||||
usage_count: i64,
|
||||
last_used: i64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"desc": desc,
|
||||
"body": body,
|
||||
"category": category,
|
||||
"usageCount": usage_count,
|
||||
"lastUsed": last_used,
|
||||
})
|
||||
}
|
||||
|
||||
fn create(
|
||||
conn: &mut Connection,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
body: &str,
|
||||
category: &str,
|
||||
) -> Result<Value, String> {
|
||||
let name = normalize_name(name);
|
||||
if name.is_empty() {
|
||||
return Err("이름을 입력해야 함".into());
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
return Err("내용을 입력해야 함".into());
|
||||
}
|
||||
let category = if category.trim().is_empty() {
|
||||
"코드"
|
||||
} else {
|
||||
category
|
||||
};
|
||||
match conn.execute(
|
||||
"INSERT INTO snippets (name, desc, body, category) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![name, desc, body, category],
|
||||
) {
|
||||
Ok(_) => Ok(snippet_value(&name, desc, body, category, 0, 0)),
|
||||
Err(error) if error.sqlite_error_code() == Some(ErrorCode::ConstraintViolation) => {
|
||||
Err(format!("이미 있는 이름임: {name}"))
|
||||
}
|
||||
Err(error) => Err(format!("스니펫 생성 실패: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
conn: &mut Connection,
|
||||
name: &str,
|
||||
desc: &str,
|
||||
body: &str,
|
||||
category: &str,
|
||||
) -> Result<Value, String> {
|
||||
let name = normalize_name(name);
|
||||
if body.trim().is_empty() {
|
||||
return Err("내용을 입력해야 함".into());
|
||||
}
|
||||
let category = if category.trim().is_empty() {
|
||||
"코드"
|
||||
} else {
|
||||
category
|
||||
};
|
||||
let changed = conn
|
||||
.execute(
|
||||
"UPDATE snippets SET desc = ?1, body = ?2, category = ?3 WHERE name = ?4",
|
||||
params![desc, body, category, name],
|
||||
)
|
||||
.map_err(|e| format!("스니펫 수정 실패: {e}"))?;
|
||||
if changed == 0 {
|
||||
return Err(format!("존재하지 않는 스니펫임: {name}"));
|
||||
}
|
||||
let usage = conn
|
||||
.query_row(
|
||||
"SELECT count, last_used FROM usage WHERE name = ?1",
|
||||
[&name],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("스니펫 사용기록 조회 실패: {e}"))?
|
||||
.unwrap_or((0, 0));
|
||||
Ok(snippet_value(&name, desc, body, category, usage.0, usage.1))
|
||||
}
|
||||
|
||||
fn remove(conn: &mut Connection, name: &str) -> Result<Value, String> {
|
||||
let name = normalize_name(name);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|e| format!("스니펫 삭제 시작 실패: {e}"))?;
|
||||
tx.execute("DELETE FROM snippets WHERE name = ?1", [&name])
|
||||
.map_err(|e| format!("스니펫 삭제 실패: {e}"))?;
|
||||
tx.execute("DELETE FROM usage WHERE name = ?1", [&name])
|
||||
.map_err(|e| format!("스니펫 사용기록 삭제 실패: {e}"))?;
|
||||
tx.commit()
|
||||
.map_err(|e| format!("스니펫 삭제 저장 실패: {e}"))?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn record_use(conn: &mut Connection, name: &str) -> Result<Value, String> {
|
||||
let name = normalize_name(name);
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| format!("현재 시각 확인 실패: {e}"))?
|
||||
.as_secs() as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO usage (name, count, last_used) VALUES (?1, 1, ?2)
|
||||
ON CONFLICT(name) DO UPDATE SET count = count + 1, last_used = ?2",
|
||||
params![name, now],
|
||||
)
|
||||
.map_err(|e| format!("스니펫 사용기록 저장 실패: {e}"))?;
|
||||
let (count, last_used) = conn
|
||||
.query_row(
|
||||
"SELECT count, last_used FROM usage WHERE name = ?1",
|
||||
[&name],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||
)
|
||||
.map_err(|e| format!("스니펫 사용기록 조회 실패: {e}"))?;
|
||||
Ok(json!({ "name": name, "usageCount": count, "lastUsed": last_used }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
ensure_schema(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_normalizes_name_and_rejects_duplicate_and_empty_fields() {
|
||||
let mut conn = empty_db();
|
||||
|
||||
let created = create(&mut conn, " hello world ", "설명", "body", "").unwrap();
|
||||
assert_eq!(created["name"], "HELLO_WORLD");
|
||||
assert_eq!(created["category"], "코드");
|
||||
assert_eq!(
|
||||
create(&mut conn, "hello world", "", "body", "코드").unwrap_err(),
|
||||
"이미 있는 이름임: HELLO_WORLD"
|
||||
);
|
||||
assert_eq!(
|
||||
create(&mut conn, " ", "", "body", "코드").unwrap_err(),
|
||||
"이름을 입력해야 함"
|
||||
);
|
||||
assert_eq!(
|
||||
create(&mut conn, "EMPTY", "", " ", "코드").unwrap_err(),
|
||||
"내용을 입력해야 함"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_delete_and_usage_keep_dotnet_behavior() {
|
||||
let mut conn = empty_db();
|
||||
create(&mut conn, "foo", "old", "body", "코드").unwrap();
|
||||
|
||||
let first = record_use(&mut conn, "FOO").unwrap();
|
||||
let second = record_use(&mut conn, "FOO").unwrap();
|
||||
assert_eq!(first["usageCount"], 1);
|
||||
assert_eq!(second["usageCount"], 2);
|
||||
|
||||
let updated = update(&mut conn, "foo", "new", "changed", "기타").unwrap();
|
||||
assert_eq!(updated["desc"], "new");
|
||||
assert_eq!(updated["usageCount"], 2);
|
||||
assert_eq!(
|
||||
update(&mut conn, "missing", "", "body", "코드").unwrap_err(),
|
||||
"존재하지 않는 스니펫임: MISSING"
|
||||
);
|
||||
assert_eq!(
|
||||
update(&mut conn, "foo", "", "", "코드").unwrap_err(),
|
||||
"내용을 입력해야 함"
|
||||
);
|
||||
|
||||
remove(&mut conn, "foo").unwrap();
|
||||
assert!(read(&conn).unwrap().as_array().unwrap().is_empty());
|
||||
let usage_rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM usage", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(usage_rows, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_empty_database_and_persists_after_reopen() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codeassist-snippets-{}-{}.db",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
|
||||
create_at(&path, "persist me", "설명", "본문", "코드").unwrap();
|
||||
let rows = list(&path).unwrap();
|
||||
assert_eq!(rows.as_array().unwrap()[0]["name"], "PERSIST_ME");
|
||||
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_dotnet_schema_with_and_without_usage() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE snippets (name TEXT PRIMARY KEY, desc TEXT, body TEXT, category TEXT);
|
||||
CREATE TABLE usage (name TEXT PRIMARY KEY, count INTEGER, last_used INTEGER);
|
||||
INSERT INTO snippets VALUES ('A', '한글 설명', 'SELECT *', '코드'), ('B', '', '본문', '메모');
|
||||
INSERT INTO usage VALUES ('A', 3, 123);",
|
||||
).unwrap();
|
||||
let rows = read(&conn).unwrap();
|
||||
let rows = rows.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
let a = rows.iter().find(|r| r["name"] == "A").unwrap();
|
||||
assert_eq!(a["desc"], "한글 설명");
|
||||
assert_eq!(a["body"], "SELECT *");
|
||||
assert_eq!(a["usageCount"], 3);
|
||||
assert_eq!(a["lastUsed"], 123);
|
||||
let b = rows.iter().find(|r| r["name"] == "B").unwrap();
|
||||
assert_eq!(b["usageCount"], 0);
|
||||
assert_eq!(b["lastUsed"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "로컬 닷넷 DB를 읽는 수동 통합 검사"]
|
||||
fn reads_existing_dotnet_database() {
|
||||
let path = std::path::PathBuf::from(std::env::var_os("LOCALAPPDATA").unwrap())
|
||||
.join("CodeAssist")
|
||||
.join("snippets.db");
|
||||
let rows = list(&path).unwrap();
|
||||
let rows = rows.as_array().unwrap();
|
||||
assert!(!rows.is_empty());
|
||||
println!("기존 닷넷 DB: {}개 조회 성공", rows.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! 조립부 — .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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Windows release 빌드에서 콘솔 창 안 뜨게 — 지우지 말 것
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
codeassist_tauri_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
//! 화면 영역 선택 + PNG 캡쳐. legacy `CaptureOverlayWindow`/`ScreenCapture`의 Tauri 이식판.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
|
||||
use base64::Engine;
|
||||
use tauri::AppHandle;
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BeginPaint, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, CreateSolidBrush, DeleteDC,
|
||||
DeleteObject, EndPaint, FillRect, FrameRect, GetDC, GetDIBits, InvalidateRect, ReleaseDC,
|
||||
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HGDIOBJ, PAINTSTRUCT,
|
||||
SRCCOPY,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture, SetFocus};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect, GetMessageW,
|
||||
GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW,
|
||||
SetForegroundWindow, SetLayeredWindowAttributes, SetWindowLongPtrW, ShowWindow,
|
||||
TranslateMessage, CREATESTRUCTW, CS_HREDRAW, CS_VREDRAW, GWLP_USERDATA, IDC_CROSS, LWA_ALPHA,
|
||||
MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOW,
|
||||
WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY, WM_ERASEBKGND, WM_KEYDOWN,
|
||||
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE, WM_NCDESTROY, WM_PAINT, WNDCLASSW,
|
||||
WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
|
||||
};
|
||||
|
||||
const MIN_SELECTION: i32 = 4;
|
||||
const OVERLAY_ALPHA: u8 = 115;
|
||||
// Samsung Blue #1428A0. COLORREF는 0x00BBGGRR 순서임.
|
||||
const CAPTURE_BORDER_COLOR: COLORREF = COLORREF(0x00a02814);
|
||||
static CAPTURING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct CaptureRect {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
}
|
||||
|
||||
fn selection_rect(start: (i32, i32), end: (i32, i32), origin: (i32, i32)) -> Option<CaptureRect> {
|
||||
let left = start.0.min(end.0);
|
||||
let top = start.1.min(end.1);
|
||||
let width = (end.0 - start.0).abs();
|
||||
let height = (end.1 - start.1).abs();
|
||||
(width >= MIN_SELECTION && height >= MIN_SELECTION).then_some(CaptureRect {
|
||||
x: origin.0 + left,
|
||||
y: origin.1 + top,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
/// 이미 캡쳐 중이면 두 번째 핫키는 무시함.
|
||||
pub fn start<F>(app: AppHandle, on_captured: F)
|
||||
where
|
||||
F: FnOnce(AppHandle, String) + Send + 'static,
|
||||
{
|
||||
if CAPTURING.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = run_overlay().and_then(|rect| match rect {
|
||||
Some(rect) => capture_png_data_url(rect),
|
||||
None => Ok(None),
|
||||
});
|
||||
CAPTURING.store(false, Ordering::Release);
|
||||
match result {
|
||||
Ok(Some(data_url)) => on_captured(app, data_url),
|
||||
Ok(None) => {}
|
||||
Err(error) => eprintln!("[capture] {error}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
struct OverlayState {
|
||||
origin: (i32, i32),
|
||||
start: Option<(i32, i32)>,
|
||||
current: Option<(i32, i32)>,
|
||||
sender: mpsc::Sender<Option<CaptureRect>>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl OverlayState {
|
||||
fn finish(&mut self, hwnd: HWND, rect: Option<CaptureRect>) {
|
||||
if self.finished {
|
||||
return;
|
||||
}
|
||||
self.finished = true;
|
||||
// DestroyWindow는 WM_NCDESTROY를 동기 처리하며 OverlayState를 해제함.
|
||||
// 따라서 self를 쓰는 마지막 작업인 결과 전달을 창 파괴보다 먼저 해야 함.
|
||||
let _ = self.sender.send(rect);
|
||||
unsafe {
|
||||
let _ = ReleaseCapture();
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_point(lparam: LPARAM) -> (i32, i32) {
|
||||
let value = lparam.0 as u32;
|
||||
((value & 0xffff) as i16 as i32, (value >> 16) as i16 as i32)
|
||||
}
|
||||
|
||||
fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> {
|
||||
let inset = RECT {
|
||||
left: rect.left + amount,
|
||||
top: rect.top + amount,
|
||||
right: rect.right - amount,
|
||||
bottom: rect.bottom - amount,
|
||||
};
|
||||
(inset.left < inset.right && inset.top < inset.bottom).then_some(inset)
|
||||
}
|
||||
|
||||
unsafe extern "system" fn overlay_proc(
|
||||
hwnd: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
if message == WM_NCCREATE {
|
||||
let create = &*(lparam.0 as *const CREATESTRUCTW);
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, create.lpCreateParams as isize);
|
||||
}
|
||||
|
||||
let state_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut OverlayState;
|
||||
let state = state_ptr.as_mut();
|
||||
match message {
|
||||
WM_LBUTTONDOWN => {
|
||||
if let Some(state) = state {
|
||||
let point = mouse_point(lparam);
|
||||
state.start = Some(point);
|
||||
state.current = Some(point);
|
||||
SetCapture(hwnd);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_MOUSEMOVE => {
|
||||
if let Some(state) = state {
|
||||
if state.start.is_some() {
|
||||
state.current = Some(mouse_point(lparam));
|
||||
let _ = InvalidateRect(Some(hwnd), None, false);
|
||||
}
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_LBUTTONUP => {
|
||||
if let Some(state) = state {
|
||||
let rect = state
|
||||
.start
|
||||
.and_then(|start| selection_rect(start, mouse_point(lparam), state.origin));
|
||||
state.finish(hwnd, rect);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_KEYDOWN if wparam.0 == 0x1b => {
|
||||
if let Some(state) = state {
|
||||
state.finish(hwnd, None);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_ACTIVATE if (wparam.0 & 0xffff) == WA_INACTIVE as usize => {
|
||||
if let Some(state) = state {
|
||||
state.finish(hwnd, None);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_PAINT => {
|
||||
let mut paint = PAINTSTRUCT::default();
|
||||
let dc = BeginPaint(hwnd, &mut paint);
|
||||
let mut client = RECT::default();
|
||||
let _ = GetClientRect(hwnd, &mut client);
|
||||
let shade = CreateSolidBrush(COLORREF(0x000000));
|
||||
FillRect(dc, &client, shade);
|
||||
if let Some(state) = state {
|
||||
if let (Some(start), Some(end)) = (state.start, state.current) {
|
||||
let selected = RECT {
|
||||
left: start.0.min(end.0),
|
||||
top: start.1.min(end.1),
|
||||
right: start.0.max(end.0),
|
||||
bottom: start.1.max(end.1),
|
||||
};
|
||||
// 반투명 오버레이에서도 경계가 묻히지 않게 흰색 외곽선과
|
||||
// 포인트색 안쪽선을 겹쳐 4px 테두리로 그림.
|
||||
let white = CreateSolidBrush(COLORREF(0x00ffffff));
|
||||
let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR);
|
||||
FrameRect(dc, &selected, white);
|
||||
for amount in 1..4 {
|
||||
if let Some(border) = inset_rect(selected, amount) {
|
||||
FrameRect(dc, &border, accent);
|
||||
}
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(white.0));
|
||||
let _ = DeleteObject(HGDIOBJ(accent.0));
|
||||
}
|
||||
}
|
||||
let _ = DeleteObject(HGDIOBJ(shade.0));
|
||||
let _ = EndPaint(hwnd, &paint);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_ERASEBKGND => LRESULT(1),
|
||||
WM_DESTROY => {
|
||||
PostQuitMessage(0);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_NCDESTROY => {
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0);
|
||||
if !state_ptr.is_null() {
|
||||
drop(Box::from_raw(state_ptr));
|
||||
}
|
||||
DefWindowProcW(hwnd, message, wparam, lparam)
|
||||
}
|
||||
_ => DefWindowProcW(hwnd, message, wparam, lparam),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_overlay() -> Result<Option<CaptureRect>, String> {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
unsafe {
|
||||
let module = GetModuleHandleW(None).map_err(|error| error.to_string())?;
|
||||
let instance = HINSTANCE(module.0);
|
||||
let class_name = w!("CodeAssistCaptureOverlay");
|
||||
let class = WNDCLASSW {
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(overlay_proc),
|
||||
hInstance: instance,
|
||||
hCursor: LoadCursorW(None, IDC_CROSS).unwrap_or_default(),
|
||||
lpszClassName: class_name,
|
||||
..Default::default()
|
||||
};
|
||||
RegisterClassW(&class);
|
||||
|
||||
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
let width = GetSystemMetrics(SM_CXVIRTUALSCREEN);
|
||||
let height = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||
if width <= 0 || height <= 0 {
|
||||
return Err("가상 화면 크기를 읽지 못했어".into());
|
||||
}
|
||||
|
||||
let state = Box::new(OverlayState {
|
||||
origin: (x, y),
|
||||
start: None,
|
||||
current: None,
|
||||
sender,
|
||||
finished: false,
|
||||
});
|
||||
let state_ptr = Box::into_raw(state);
|
||||
let hwnd = match CreateWindowExW(
|
||||
WINDOW_EX_STYLE(WS_EX_LAYERED.0 | WS_EX_TOPMOST.0 | WS_EX_TOOLWINDOW.0),
|
||||
class_name,
|
||||
PCWSTR::null(),
|
||||
WS_POPUP,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
None,
|
||||
None,
|
||||
Some(instance),
|
||||
Some(state_ptr.cast()),
|
||||
) {
|
||||
Ok(hwnd) => hwnd,
|
||||
Err(error) => {
|
||||
drop(Box::from_raw(state_ptr));
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if let Err(error) = SetLayeredWindowAttributes(hwnd, COLORREF(0), OVERLAY_ALPHA, LWA_ALPHA)
|
||||
{
|
||||
let _ = DestroyWindow(hwnd);
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
let _ = SetFocus(Some(hwnd));
|
||||
|
||||
let mut message = MSG::default();
|
||||
while GetMessageW(&mut message, None, 0, 0).as_bool() {
|
||||
let _ = TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
}
|
||||
Ok(receiver.recv().ok().flatten())
|
||||
}
|
||||
|
||||
fn capture_png_data_url(rect: CaptureRect) -> Result<Option<String>, String> {
|
||||
if rect.width <= 0 || rect.height <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
unsafe {
|
||||
let screen = GetDC(None);
|
||||
if screen.is_invalid() {
|
||||
return Err("화면 DC를 열지 못했어".into());
|
||||
}
|
||||
let memory = CreateCompatibleDC(Some(screen));
|
||||
let bitmap = CreateCompatibleBitmap(screen, rect.width, rect.height);
|
||||
if memory.is_invalid() || bitmap.is_invalid() {
|
||||
let _ = ReleaseDC(None, screen);
|
||||
if !memory.is_invalid() {
|
||||
let _ = DeleteDC(memory);
|
||||
}
|
||||
return Err("캡쳐 버퍼를 만들지 못했어".into());
|
||||
}
|
||||
let previous = SelectObject(memory, HGDIOBJ(bitmap.0));
|
||||
let copied = BitBlt(
|
||||
memory,
|
||||
0,
|
||||
0,
|
||||
rect.width,
|
||||
rect.height,
|
||||
Some(screen),
|
||||
rect.x,
|
||||
rect.y,
|
||||
SRCCOPY,
|
||||
);
|
||||
|
||||
let mut info = BITMAPINFO::default();
|
||||
info.bmiHeader = BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: rect.width,
|
||||
biHeight: -rect.height,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pixels = vec![0u8; rect.width as usize * rect.height as usize * 4];
|
||||
let lines = if copied.is_ok() {
|
||||
GetDIBits(
|
||||
memory,
|
||||
bitmap,
|
||||
0,
|
||||
rect.height as u32,
|
||||
Some(pixels.as_mut_ptr().cast()),
|
||||
&mut info,
|
||||
DIB_RGB_COLORS,
|
||||
)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
SelectObject(memory, previous);
|
||||
let _ = DeleteObject(HGDIOBJ(bitmap.0));
|
||||
let _ = DeleteDC(memory);
|
||||
let _ = ReleaseDC(None, screen);
|
||||
if lines == 0 {
|
||||
return Err("화면 픽셀을 읽지 못했어".into());
|
||||
}
|
||||
|
||||
for pixel in pixels.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
pixel[3] = 255;
|
||||
}
|
||||
let mut png_bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(
|
||||
Cursor::new(&mut png_bytes),
|
||||
rect.width as u32,
|
||||
rect.height as u32,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().map_err(|error| error.to_string())?;
|
||||
writer
|
||||
.write_image_data(&pixels)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(Some(format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(png_bytes)
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn 역방향_드래그와_음수_모니터_원점을_물리_좌표로_합친다() {
|
||||
assert_eq!(
|
||||
selection_rect((300, 200), (100, 50), (-1920, 0)),
|
||||
Some(CaptureRect {
|
||||
x: -1820,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 클릭에_가까운_선택은_취소한다() {
|
||||
assert_eq!(selection_rect((10, 10), (13, 50), (0, 0)), None);
|
||||
assert_eq!(selection_rect((10, 10), (50, 13), (0, 0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 선택_테두리를_안쪽으로_줄이고_공간이_없으면_그리지_않는다() {
|
||||
let rect = RECT {
|
||||
left: 10,
|
||||
top: 20,
|
||||
right: 30,
|
||||
bottom: 50,
|
||||
};
|
||||
let inset = inset_rect(rect, 3).unwrap();
|
||||
assert_eq!((inset.left, inset.top), (13, 23));
|
||||
assert_eq!((inset.right, inset.bottom), (27, 47));
|
||||
assert!(inset_rect(rect, 10).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 전역 핫키 등록 (.NET `HotKeyService` + `RegisterHotKey` P/Invoke 대체).
|
||||
//!
|
||||
//! 조합은 문자열 accelerator("Ctrl+Shift+8")로 받는다 — 어느 키인지 한눈에 보이고,
|
||||
//! 나중에 설정 파일로 빼기도 쉬움. 어떤 id 가 뭘 하는지는 여기서 모르고 콜백에 넘긴다.
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
||||
|
||||
/// 핫키 하나. `id` 는 콜백에서 분기용으로 되돌려받는 이름.
|
||||
pub struct HotkeySpec {
|
||||
pub id: &'static str,
|
||||
pub accelerator: &'static str,
|
||||
}
|
||||
|
||||
/// 전부 등록하고, **실패한 accelerator 목록**을 돌려준다.
|
||||
///
|
||||
/// 실패해도 앱은 안 죽인다 — 다른 앱이 그 조합을 선점했을 뿐이고, 트레이 '열기'로
|
||||
/// 여전히 쓸 수 있으니까 (.NET 판도 같은 정책: 등록 실패 시 트레이 알림만 띄움).
|
||||
pub fn init<F>(app: &AppHandle, specs: &'static [HotkeySpec], on_press: F) -> Vec<&'static str>
|
||||
where
|
||||
F: Fn(&AppHandle, &'static str) + Send + Sync + Clone + 'static,
|
||||
{
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for spec in specs {
|
||||
let id = spec.id;
|
||||
let handler = on_press.clone();
|
||||
|
||||
let registered = app
|
||||
.global_shortcut()
|
||||
.on_shortcut(spec.accelerator, move |app, _shortcut, event| {
|
||||
// 누를 때만 — 뗄 때도 이벤트가 와서 안 거르면 두 번 발동함.
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
handler(app, id);
|
||||
}
|
||||
})
|
||||
.is_ok();
|
||||
|
||||
if !registered {
|
||||
failed.push(spec.accelerator);
|
||||
}
|
||||
}
|
||||
|
||||
failed
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! 프로젝트 무관 재사용 층 — .NET 판의 `CodeAssist.Shell` 과 **같은 경계**로 잘랐다.
|
||||
//!
|
||||
//! 여기 있는 것들은 "CodeAssist" 를 하나도 모른다. route 이름도, 스니펫도, 브릿지 계약도 모름.
|
||||
//! 아는 건 "창 하나 띄우고 숨기는 앱", "핫키 누르면 콜백 부르는 앱" 뿐이라, 다른 프로젝트에
|
||||
//! 이 폴더째 복사해도 돌아간다. 프로젝트 고유 배선은 전부 `lib.rs`(조립부)에 있음.
|
||||
//!
|
||||
//! 일부러 `init(ShellConfig)` 같은 통합 진입점을 안 만들었다 — 세 모듈이 서로 독립이고
|
||||
//! 콜백도 각자 달라서, 감싸봐야 그냥 넘겨주기만 하는 껍데기가 하나 더 생길 뿐임.
|
||||
|
||||
pub mod capture;
|
||||
pub mod hotkey;
|
||||
pub mod paste;
|
||||
pub mod tray;
|
||||
pub mod window;
|
||||
@@ -0,0 +1,327 @@
|
||||
//! 직전 foreground 창 스냅샷과 Windows 붙여넣기.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Foundation::{CloseHandle, GlobalFree, HANDLE, HGLOBAL, HWND};
|
||||
use windows::Win32::System::DataExchange::{
|
||||
CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData,
|
||||
};
|
||||
use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
GetAsyncKeyState, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP,
|
||||
VIRTUAL_KEY, VK_CONTROL, VK_LBUTTON, VK_LWIN, VK_MBUTTON, VK_MENU, VK_RBUTTON, VK_RETURN,
|
||||
VK_RWIN, VK_SHIFT, VK_V,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetForegroundWindow, GetWindowTextW, GetWindowThreadProcessId, IsWindow, SetForegroundWindow,
|
||||
};
|
||||
|
||||
pub const DEFAULT_PASTE_DELAY: Duration = Duration::from_millis(80);
|
||||
const KEY_RELEASE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
const CF_UNICODETEXT: u32 = 13;
|
||||
const MAX_PROCESS_PATH: usize = 32_768;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PasteTarget {
|
||||
hwnd: isize,
|
||||
process_id: u32,
|
||||
thread_id: u32,
|
||||
pub name: String,
|
||||
pub app: String,
|
||||
}
|
||||
|
||||
pub type PasteState = Mutex<PasteTarget>;
|
||||
|
||||
impl PasteTarget {
|
||||
fn window(&self) -> HWND {
|
||||
HWND(self.hwnd as *mut c_void)
|
||||
}
|
||||
|
||||
fn is_valid(&self) -> bool {
|
||||
let window = self.window();
|
||||
if window.0.is_null() || !unsafe { IsWindow(Some(window)) }.as_bool() {
|
||||
return false;
|
||||
}
|
||||
let mut process_id = 0;
|
||||
let thread_id = unsafe { GetWindowThreadProcessId(window, Some(&mut process_id)) };
|
||||
thread_id != 0 && thread_id == self.thread_id && process_id == self.process_id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capture_foreground(excluded_hwnd: Option<isize>) -> Option<PasteTarget> {
|
||||
let window = unsafe { GetForegroundWindow() };
|
||||
let hwnd = window.0 as isize;
|
||||
if window.0.is_null() || excluded_hwnd == Some(hwnd) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut process_id = 0;
|
||||
let thread_id = unsafe { GetWindowThreadProcessId(window, Some(&mut process_id)) };
|
||||
Some(PasteTarget {
|
||||
hwnd,
|
||||
process_id,
|
||||
thread_id,
|
||||
name: window_title(window),
|
||||
app: process_name(window),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn paste(
|
||||
text: &str,
|
||||
target: &PasteTarget,
|
||||
palette_hwnd: isize,
|
||||
delay: Duration,
|
||||
) -> Result<(), String> {
|
||||
if text.is_empty() {
|
||||
return Err("붙여넣을 코드가 비었음".to_string());
|
||||
}
|
||||
|
||||
set_clipboard_text(text)?;
|
||||
// 클립보드 적재가 끝난 뒤의 실패만 원문이 남았다고 알려줌.
|
||||
paste_from_clipboard(target, palette_hwnd, delay)
|
||||
.map_err(|error| format!("{error} — 코드는 클립보드에 남겨둠"))
|
||||
}
|
||||
|
||||
fn paste_from_clipboard(
|
||||
target: &PasteTarget,
|
||||
palette_hwnd: isize,
|
||||
delay: Duration,
|
||||
) -> Result<(), String> {
|
||||
if !target.is_valid() || target.hwnd == palette_hwnd {
|
||||
return Err("직전 앱을 찾지 못함".to_string());
|
||||
}
|
||||
|
||||
let palette = HWND(palette_hwnd as *mut c_void);
|
||||
let deadline = Instant::now() + KEY_RELEASE_TIMEOUT;
|
||||
loop {
|
||||
if palette.0.is_null() || unsafe { GetForegroundWindow() } != palette {
|
||||
return Err("다른 창으로 이동해서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("키나 마우스 버튼이 계속 눌려 있어서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
if !paste_keys_down() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(INPUT_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// 키를 뗄 때까지 대상 상태의 잠금을 잡거나 대상 창으로 이동하지 않음.
|
||||
if !target.is_valid() {
|
||||
return Err("직전 앱이 닫혀서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
if unsafe { GetForegroundWindow() } != palette || paste_keys_down() {
|
||||
return Err("입력 상태가 바뀌어서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
let window = target.window();
|
||||
if !unsafe { SetForegroundWindow(window) }.as_bool() {
|
||||
return Err("직전 앱으로 돌아가지 못함".to_string());
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + delay;
|
||||
let mut activated = false;
|
||||
loop {
|
||||
if !target.is_valid() {
|
||||
return Err("직전 앱이 닫혀서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
// 다른 입력 큐의 활성화는 SetForegroundWindow가 반환한 뒤 끝날 수 있음.
|
||||
// 처음 전환 중인 팔레트·빈 포커스만 기다리고, 다른 앱으로 이탈하면 취소함.
|
||||
let foreground = unsafe { GetForegroundWindow() };
|
||||
if foreground == window {
|
||||
activated = true;
|
||||
} else if activated || (!foreground.0.is_null() && foreground != palette) {
|
||||
return Err("대상 창의 포커스가 바뀌어서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
if paste_keys_down() {
|
||||
return Err("키나 마우스 버튼이 다시 눌려서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
if !activated {
|
||||
return Err("직전 앱이 활성화되지 않아서 붙여넣기를 취소함".to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
thread::sleep(INPUT_POLL_INTERVAL.min(remaining));
|
||||
}
|
||||
|
||||
let inputs = [
|
||||
keyboard_input(VK_CONTROL, false),
|
||||
keyboard_input(VK_V, false),
|
||||
keyboard_input(VK_V, true),
|
||||
keyboard_input(VK_CONTROL, true),
|
||||
];
|
||||
let sent = unsafe { SendInput(&inputs, size_of::<INPUT>() as i32) } as usize;
|
||||
if sent != inputs.len() {
|
||||
// 부분 주입 때 우리가 누른 키만 뗌. Ctrl+V 자체는 재시도하지 않음.
|
||||
let cleanup = match sent {
|
||||
1 | 3 => &inputs[3..],
|
||||
2 => &inputs[2..],
|
||||
_ => &inputs[4..],
|
||||
};
|
||||
if !cleanup.is_empty()
|
||||
&& unsafe { SendInput(cleanup, size_of::<INPUT>() as i32) } as usize != cleanup.len()
|
||||
{
|
||||
return Err(
|
||||
"붙여넣기 입력과 키 해제에 실패함. Ctrl과 V를 직접 눌렀다 떼줘".to_string(),
|
||||
);
|
||||
}
|
||||
return Err("붙여넣기 키 입력을 보내지 못함. 대상 앱의 관리자 권한을 확인해봐".to_string());
|
||||
}
|
||||
// 입력 큐에 4개가 들어간 것만 확인함. 대상 앱의 붙여넣기 처리까지 보장하진 않음.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn paste_keys_down() -> bool {
|
||||
[
|
||||
VK_CONTROL, VK_SHIFT, VK_MENU, VK_LWIN, VK_RWIN, VK_RETURN, VK_V, VK_LBUTTON, VK_RBUTTON,
|
||||
VK_MBUTTON,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|key| unsafe { GetAsyncKeyState(i32::from(key.0)) } < 0)
|
||||
}
|
||||
|
||||
fn keyboard_input(key: VIRTUAL_KEY, up: bool) -> INPUT {
|
||||
INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: key,
|
||||
dwFlags: if up {
|
||||
KEYEVENTF_KEYUP
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn window_title(window: HWND) -> String {
|
||||
let mut buffer = [0_u16; 512];
|
||||
let length = unsafe { GetWindowTextW(window, &mut buffer) };
|
||||
if length <= 0 {
|
||||
return String::new();
|
||||
}
|
||||
String::from_utf16_lossy(&buffer[..length as usize])
|
||||
}
|
||||
|
||||
fn process_name(window: HWND) -> String {
|
||||
let mut process_id = 0;
|
||||
unsafe { GetWindowThreadProcessId(window, Some(&mut process_id)) };
|
||||
if process_id == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let Ok(process) =
|
||||
(unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) })
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
let _guard = ProcessGuard(process);
|
||||
|
||||
let mut buffer = vec![0_u16; MAX_PROCESS_PATH];
|
||||
let mut length = buffer.len() as u32;
|
||||
if unsafe {
|
||||
QueryFullProcessImageNameW(
|
||||
process,
|
||||
PROCESS_NAME_WIN32,
|
||||
PWSTR(buffer.as_mut_ptr()),
|
||||
&mut length,
|
||||
)
|
||||
}
|
||||
.is_err()
|
||||
{
|
||||
return String::new();
|
||||
}
|
||||
|
||||
process_name_from_path(&String::from_utf16_lossy(&buffer[..length as usize]))
|
||||
}
|
||||
|
||||
fn process_name_from_path(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn set_clipboard_text(text: &str) -> Result<(), String> {
|
||||
let utf16: Vec<u16> = text.encode_utf16().chain([0]).collect();
|
||||
let byte_len = utf16.len() * size_of::<u16>();
|
||||
let memory = unsafe { GlobalAlloc(GMEM_MOVEABLE, byte_len) }
|
||||
.map_err(|error| format!("클립보드 메모리 준비 실패: {error}"))?;
|
||||
let mut memory_guard = GlobalMemoryGuard(Some(memory));
|
||||
|
||||
let destination = unsafe { GlobalLock(memory) } as *mut u16;
|
||||
if destination.is_null() {
|
||||
return Err("클립보드 메모리 잠금 실패".to_string());
|
||||
}
|
||||
unsafe {
|
||||
ptr::copy_nonoverlapping(utf16.as_ptr(), destination, utf16.len());
|
||||
let _ = GlobalUnlock(memory);
|
||||
}
|
||||
|
||||
unsafe { OpenClipboard(None) }.map_err(|error| format!("클립보드를 열지 못함: {error}"))?;
|
||||
let _clipboard_guard = ClipboardGuard;
|
||||
unsafe { EmptyClipboard() }.map_err(|error| format!("클립보드를 비우지 못함: {error}"))?;
|
||||
|
||||
unsafe { SetClipboardData(CF_UNICODETEXT, Some(HANDLE(memory.0))) }
|
||||
.map_err(|error| format!("클립보드에 코드를 넣지 못함: {error}"))?;
|
||||
memory_guard.0 = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ClipboardGuard;
|
||||
|
||||
impl Drop for ClipboardGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = CloseClipboard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalMemoryGuard(Option<HGLOBAL>);
|
||||
|
||||
impl Drop for GlobalMemoryGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(memory) = self.0 {
|
||||
unsafe {
|
||||
let _ = GlobalFree(Some(memory));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessGuard(HANDLE);
|
||||
|
||||
impl Drop for ProcessGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn 실행_파일_경로에서_앱_이름만_남긴다() {
|
||||
assert_eq!(
|
||||
super::process_name_from_path(r"C:\Program Files\Notepad\notepad.exe"),
|
||||
"notepad"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! 트레이 상주 아이콘 + 메뉴 (.NET `TrayIconHost`/H.NotifyIcon 대체 — Tauri 내장이라 의존성 0).
|
||||
//!
|
||||
//! 메뉴 구성은 .NET 판과 똑같이 열기 / 항상 위에 고정(체크) / 종료 3개.
|
||||
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// 트레이에서 사용자가 고른 것. 실제로 뭘 할지는 조립부(lib.rs)가 정한다.
|
||||
pub enum TrayAction {
|
||||
Open,
|
||||
/// 토글 **후**의 새 체크 상태
|
||||
TogglePin(bool),
|
||||
Quit,
|
||||
}
|
||||
|
||||
pub fn init<F>(app: &AppHandle, tooltip: &str, pinned: bool, on_action: F) -> tauri::Result<()>
|
||||
where
|
||||
F: Fn(&AppHandle, TrayAction) + Send + Sync + 'static,
|
||||
{
|
||||
let open_i = MenuItem::with_id(app, "open", "열기", true, None::<&str>)?;
|
||||
let pin_i = CheckMenuItem::with_id(app, "pin", "항상 위에 고정", true, pinned, None::<&str>)?;
|
||||
let quit_i = MenuItem::with_id(app, "quit", "종료", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open_i, &pin_i, &quit_i])?;
|
||||
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.expect("번들 아이콘 없음 — tauri.conf.json 의 bundle.icon 확인")
|
||||
.clone();
|
||||
|
||||
TrayIconBuilder::with_id("main")
|
||||
.icon(icon)
|
||||
.tooltip(tooltip)
|
||||
.menu(&menu)
|
||||
// 좌클릭은 메뉴 안 열고 창 소환에 쓴다(윈도우 트레이 관습).
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(move |app, event| match event.id.as_ref() {
|
||||
"open" => on_action(app, TrayAction::Open),
|
||||
// CheckMenuItem 은 클릭 시 자기가 먼저 토글되므로, 지금 읽은 값이 곧 새 상태.
|
||||
"pin" => on_action(
|
||||
app,
|
||||
TrayAction::TogglePin(pin_i.is_checked().unwrap_or(false)),
|
||||
),
|
||||
"quit" => on_action(app, TrayAction::Quit),
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 메인 창 조작 + 핀(항상 위) 상태 보관.
|
||||
//!
|
||||
//! 위치·크기 복원은 `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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeAssist",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.codeassist.app",
|
||||
"build": {
|
||||
"beforeDevCommand": { "cwd": "../../2_frontend", "script": "npm run dev" },
|
||||
"devUrl": "http://localhost:15173",
|
||||
"beforeBuildCommand": { "cwd": "../../2_frontend", "script": "npm run build" },
|
||||
"frontendDist": "../../2_frontend/dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"url": "/snap",
|
||||
"title": "CodeAssist",
|
||||
"width": 960,
|
||||
"height": 680,
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||