Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
+7
View File
@@ -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
+5264
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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",
] }
+3
View File
@@ -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"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

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)
}
+47
View File
@@ -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());
}
}
+233
View File
@@ -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");
}
}
+6
View File
@@ -0,0 +1,6 @@
// Windows release 빌드에서 콘솔 창 안 뜨게 — 지우지 말 것
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
codeassist_tauri_lib::run()
}
+416
View File
@@ -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
}
+14
View File
@@ -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;
+327
View File
@@ -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"
);
}
}
+50
View File
@@ -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(())
}
+230
View File
@@ -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);
}
}
+42
View File
@@ -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"
]
}
}