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
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
# Tauri + Vanilla TS
This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+37
View File
@@ -0,0 +1,37 @@
# docs-lib
이 모듈(`4_rust_tauri`)이 쓰는 라이브러리 참조 문서 모음. **구현 전에 여기 먼저 보고 감 — 기억으로 API 쓰지 말 것** (CLAUDE.md §3, spec-kit `specs/004-tauri-shell/research.md` R3).
파일이 크면 통째로 읽지 말고 `Grep` 으로 필요한 부분만 꺼내 쓰기.
## 카탈로그
| 파일 | 라이브러리 | 출처 | 갱신법 |
|---|---|---|---|
| `tauri-v2.md` | tauri 2.11.5 + 플러그인 3종 | **crate 소스 직접 확인** (`~/.cargo/registry/src/.../tauri-2.11.5/` 등) | 아래 "갱신법" 참고 |
| `tauri-v2-llms-full.md` | Tauri v2 공식 가이드 전문 (2.4MB) | https://tauri.app/llms-full.txt | `curl -sL https://tauri.app/llms-full.txt -o tauri-v2-llms-full.md` |
| `windows-rs.md` | windows 0.61.3 Win32 바인딩 | **crate 소스 직접 확인** (`~/.cargo/registry/src/.../windows-0.61.3/`) | Cargo 버전 변경 시 실제 시그니처와 feature 재확인 |
## 왜 두 개인가
`tauri-v2-llms-full.md`**공식 가이드 전문**이라 "어떻게 쓰는지"는 잘 나오는데 **시그니처가 못 믿을 데가 있다.** 실제로 이 파일 안에서 `global_shortcut` 핸들러가 2인자(1948줄)와 3인자(23080줄)로 **서로 다르게** 나온다 — 옛 버전 예제가 안 지워지고 남아있음. research R3 이 경고한 함정이 문서 안에 그대로 있는 셈.
그래서 `tauri-v2.md`**`cargo fetch` 로 받은 실제 crate 소스에서 뽑은 것**만 적는다. 우리가 컴파일할 바로 그 코드라 틀릴 수가 없다.
- **"이거 어떻게 쓰지"** → `tauri-v2-llms-full.md` 를 Grep
- **"시그니처가 정확히 뭐지"** → `tauri-v2.md`. 여기 없으면 소스에서 직접 확인하고 여기에 추가
## 갱신법
`tauri-v2-llms-full.md` 는 위 표의 curl 명령으로 덮어쓰기.
`tauri-v2.md` 는 crate 버전이 올라갔을 때 소스에서 다시 확인:
```powershell
cd 4_rust_tauri\src-tauri
cargo update # 버전 올릴 때만
cargo fetch # 소스 내려받기
# 소스 위치: %USERPROFILE%\.cargo\registry\src\index.crates.io-*\<crate>-<version>\
```
버전이 바뀌면 `tauri-v2.md` 맨 위의 **박제 기준 버전** 표부터 고칠 것. 새 라이브러리 문서를 추가하면 위 카탈로그에 한 줄 등록.
+11
View File
@@ -0,0 +1,11 @@
# rusqlite 목록 조회
설치된 `rusqlite-0.32.1` 소스 기준.
- `Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)`: 기존 DB만 읽기 전용으로 열기. 파일이 없으면 에러이며 새 DB를 만들지 않음. `src/lib.rs:472`.
- `Statement::query_map([], callback)`: 매개변수 없는 SELECT 결과를 행별 변환. `src/statement.rs:277`.
- `Connection::execute(sql, params![...])`: 쓰기 SQL 실행 후 바뀐 행 수 반환.
- `Connection::transaction()` + `Transaction::commit()`: 스니펫과 usage를 한 덩어리로 삭제.
- `OptionalExtension::optional()`: usage처럼 행이 없을 수 있는 단건 SELECT를 `Option`으로 변환.
- 닷넷 저장소와 같은 `%LOCALAPPDATA%/CodeAssist/snippets.db` 경로 및 snippets/usage 스키마 사용.
- SQLite는 `bundled` 기능으로 포함해 별도 설치 의존성을 없앰.
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
# Tauri v2 — 우리가 쓸 API 표면 박제
**작성**: 2026-08-15 | **근거**: `specs/004-tauri-shell/research.md` R3
**여기 적힌 건 전부 `cargo fetch` 로 받은 실제 crate 소스에서 뽑은 것이다.** 공식 가이드(`tauri-v2-llms-full.md`)의 예제는 옛 버전이 섞여 있어서 시그니처 근거로 쓰지 않는다. 자세한 건 `README.md` 의 "왜 두 개인가".
## 박제 기준 버전
`4_rust_tauri/src-tauri/Cargo.lock` 이 잠근 값 (2026-08-15):
| crate | 버전 |
|---|---|
| `tauri` | **2.11.5** |
| `tauri-build` | 2.6.3 |
| `tauri-plugin-global-shortcut` | 2.3.2 |
| `tauri-plugin-single-instance` | 2.4.3 |
| `tauri-plugin-window-state` | 2.4.1 |
| `tao` (창·이벤트루프, tauri 가 물고 옴) | 0.35.3 |
| `global-hotkey` (global-shortcut 가 물고 옴) | 0.8.0 |
| `windows` (Win32, tao 가 물고 옴) | 0.61.3 |
소스 위치: `%USERPROFILE%\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\<crate>-<version>\`
---
## 1. 전역 핫키 — `tauri-plugin-global-shortcut` 2.3.2
### 핸들러는 **3인자**, `ShortcutEvent` 는 **값**으로 온다
research R3 이 "2개→3개로 바뀐 자리"라고 찍었던 바로 그것. 소스에서 확인한 정답:
```rust
// src/lib.rs:143
pub fn on_shortcut<S, F>(&self, shortcut: S, handler: F) -> Result<()>
where
S: TryInto<ShortcutWrapper>,
S::Error: std::error::Error,
F: Fn(&AppHandle<R>, &Shortcut, ShortcutEvent) + Send + Sync + 'static,
```
- 인자 **3개**: `(&AppHandle, &Shortcut, ShortcutEvent)`
- 3번째는 **`&ShortcutEvent` 가 아니라 `ShortcutEvent` 값**
- `shortcut``TryInto<ShortcutWrapper>`**`"Ctrl+Shift+8"` 문자열 그대로 넘겨도 된다**
- 반환은 `Result<()>` — 등록 실패는 `Err` 로 오지 패닉이 아님 (FR-010 이 요구하는 "죽지 않기" 가 공짜)
같은 시그니처를 쓰는 다른 진입점:
```rust
pub fn register<S>(&self, shortcut: S) -> Result<()> // 핸들러 없이 등록만 (lib.rs:131)
pub fn on_shortcuts<S, T, F>(&self, shortcuts: S, handler: F) // 여러 개 한 번에 (lib.rs:167)
pub fn with_handler<F>(self, handler: F) -> Self // Builder 에 공용 핸들러 (lib.rs:380)
```
> ⚠️ `tauri-v2-llms-full.md` 1948줄에는 `with_handler(|app, shortcut| ...)` 로 **2인자** 예제가 남아있다. **그건 옛날 것.** 23080줄의 3인자가 맞다.
### `ShortcutState` / `ShortcutEvent` 는 `global-hotkey` 것의 별명
```rust
// src/lib.rs:24
GlobalHotKeyEvent as ShortcutEvent, HotKeyState as ShortcutState,
```
`global-hotkey` 0.8.0 정의:
```rust
pub enum HotKeyState { Pressed, Released } // = ShortcutState
pub struct GlobalHotKeyEvent { // = ShortcutEvent
pub id: u32,
pub state: HotKeyState,
}
impl GlobalHotKeyEvent {
pub fn id(&self) -> u32 { self.id }
pub fn state(&self) -> HotKeyState { self.state }
}
```
**필드와 메서드가 둘 다 공개**라 `event.state``event.state()` 도 컴파일된다. 둘 중 뭘 써도 됨.
**누를 때/뗄 때 둘 다 온다**`ShortcutState::Pressed` 로 안 거르면 한 번 눌러 두 번 발동한다.
### 배선
```rust
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
// 플러그인 등록
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// setup 안에서
app.global_shortcut().on_shortcut("Ctrl+Shift+8", move |app, _shortcut, event| {
if event.state() == ShortcutState::Pressed { /* ... */ }
})
```
---
## 2. 창 — `tauri::WebviewWindow` (tauri 2.11.5)
전부 `src/webview/webview_window.rs`. **전부 `crate::Result<...>` 를 돌려준다**`let _ =` 로 무시하든 `?` 로 올리든 결정할 것.
| 메서드 | 시그니처 | 줄 |
|---|---|---|
| `is_visible` | `pub fn is_visible(&self) -> crate::Result<bool>` | 1800 |
| `set_always_on_top` | `pub fn set_always_on_top(&self, always_on_top: bool) -> crate::Result<()>` | 2049 |
| `start_dragging` | `pub fn start_dragging(&self) -> crate::Result<()>` | 2138 |
| `show` | `pub fn show(&self) -> crate::Result<()>` | 2207 |
| `hide` | `pub fn hide(&self) -> crate::Result<()>` | 2212 |
| `set_focus` | `pub fn set_focus(&self) -> crate::Result<()>` | 2260 |
`is_visible``bool` 을 감싸 돌려주므로 토글 판정에서 `unwrap_or(false)` 같은 처리가 필요하다.
창 꺼내는 법은 `Manager` trait 의 `get_webview_window("main")` — 라벨은 `tauri.conf.json` 의 창 `label` 과 같아야 한다.
---
## 3. 푸시 (Rust → JS) — `Emitter` trait
```rust
// tauri-2.11.5/src/lib.rs:946
fn emit<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>
```
- **`use tauri::Emitter;` 를 안 하면 `app.emit(...)` 이 안 보인다.** trait 메서드라서 import 필수 — research R3 이 "어디서 import 하는지"를 함정으로 찍은 자리가 이것
- payload 는 `Serialize + Clone`
- `Manager` 를 구현한 것(`App`, `AppHandle`, `WebviewWindow`)이면 다 부를 수 있다
우리 계약은 **이벤트 이름 하나(`"bridge"`)에 `{type, ...}` 를 그대로** 싣는 것 (research R5, `contracts/transport-mapping.md` §2):
```rust
use tauri::Emitter;
app.emit("bridge", serde_json::json!({ "type": "navigate", "path": "/snippet" }))
```
관련 trait 정리:
| trait | 뭘 주나 | import |
|---|---|---|
| `Emitter` | `emit`, `emit_to`, `emit_filter` | `use tauri::Emitter;` |
| `Manager` | `state`, `path`, `get_webview_window` 등 | `use tauri::Manager;` |
| `GlobalShortcutExt` | `global_shortcut()` | `use tauri_plugin_global_shortcut::GlobalShortcutExt;` |
---
## 4. 경로 — `app.path().app_local_data_dir()`
```rust
// tauri-2.11.5/src/path/desktop.rs:256
pub fn app_local_data_dir(&self) -> Result<PathBuf> {
dirs::data_local_dir()
.ok_or(Error::UnknownPath)
.map(|dir| dir.join(&self.0.config().identifier))
}
```
**`%LocalAppData%\<identifier>`다.** identifier가 `com.codeassist.app`이면 `%LocalAppData%\com.codeassist.app\`.
창 상태와 핀은 이 경로에 둔다. 스니펫 DB는 기존 사용자 데이터를 이어야 하므로 이 함수를 쓰지 않고 `%LocalAppData%\CodeAssist\snippets.db`를 직접 지정한다(R8).
---
## 5. 트레이 — `tauri::tray::TrayIconBuilder`
`show_menu_on_left_click`**2.11.5 에 존재한다** (`src/tray/mod.rs:319). research R3 의 "이름·존재 여부" 걱정은 해소.
```rust
pub fn show_menu_on_left_click(mut self, enable: bool) -> Self // 빌더, mod.rs:319
pub fn set_show_menu_on_left_click(&self, enable: bool) -> crate::Result<()> // 런타임 변경, mod.rs:607
```
`mod.rs:307` 에 **옛 이름이 `#[deprecated]` 로 남아있다** — 자동완성이 옛 이름을 물어올 수 있으니 위 이름을 쓸 것.
메뉴는 `tauri::menu::{MenuBuilder, MenuItemBuilder, CheckMenuItemBuilder}`:
```rust
let toggle = MenuItemBuilder::with_id("toggle", "열기").build(app)?;
let pin = CheckMenuItemBuilder::new("항상 위에 고정").build(app)?;
let menu = MenuBuilder::new(app).items(&[&toggle, &pin]).build()?;
TrayIconBuilder::new()
.menu(&menu)
.on_menu_event(move |app, event| match event.id().as_ref() {
"toggle" => { /* ... */ }
_ => {}
})
.build(app)?;
```
> v1 → v2 이름 변경: `SystemTray` → `tray::TrayIconBuilder`, `SystemTrayMenu` → `menu::Menu`, `SystemTrayMenuItem` → `menu::PredefinedMenuItem`. `Builder::on_menu_event` 는 **없어졌고** `App`/`AppHandle::on_menu_event` 나 위처럼 트레이 빌더에 붙인다.
---
## 6. 창 상태 저장 — `tauri-plugin-window-state` 2.4.1
### ⚠️ 기본값이 우리한테 함정이다
```rust
// src/lib.rs:52
pub struct StateFlags: u32 {
const SIZE = 1 << 0;
const POSITION = 1 << 1;
const MAXIMIZED = 1 << 2;
const VISIBLE = 1 << 3;
const DECORATIONS = 1 << 4;
const FULLSCREEN = 1 << 5;
}
impl Default for StateFlags { fn default() -> Self { Self::all() } } // ← 전부 켜짐
```
`Builder::default()` 로 쓰면 **`VISIBLE` 과 `DECORATIONS` 까지 저장·복원한다.**
우리 앱은 **트레이 상주 + 제목표시줄 없음(`decorations: false`)** 이라 이게 문제가 된다:
- `VISIBLE` — 끌 때 창이 보이는 상태였으면 다음에 켤 때 **창이 저절로 뜬다.** 트레이 앱은 조용히 시작해야 하는데 어긋남
- `DECORATIONS` — 프레임리스 설정과 겹쳐 싸울 수 있는 자리
**우리가 필요한 건 위치·크기뿐**이다(FR-007, 핀 상태는 R8 대로 직접 저장). 그래서:
```rust
use tauri_plugin_window_state::StateFlags;
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(StateFlags::SIZE | StateFlags::POSITION) // ← 기본값 쓰지 말 것
.build()
)
```
### 저장 파일
```rust
pub const DEFAULT_FILENAME: &str = ".window-state.json"; // src/lib.rs:36
```
→ `%LocalAppData%\com.codeassist.app\.window-state.json`. 핀(항상 위) 상태는 플래그에 없으므로 같은 폴더에 따로 저장한다(R8·R9).
기타 빌더 메서드: `skip_initial_state(label)` (lib.rs:369), `build()` (lib.rs:385).
---
## 7. 단일 인스턴스 — `tauri-plugin-single-instance` 2.4.3
```rust
// src/lib.rs:31
pub fn init<R: Runtime, F: FnMut(&AppHandle<R>, Vec<String>, String) + Send + Sync + 'static>(
f: F,
) -> TauriPlugin<R>
```
- 콜백 인자 3개: `(&AppHandle, argv: Vec<String>, cwd: String)`
- `FnMut` 이다 (`Fn` 아님) — 캡쳐한 걸 안에서 바꿔도 됨
- **두 번째 실행에서 이 콜백이 첫 번째 프로세스 쪽에서 불린다.** 거기서 창을 소환하면 "두 번 실행해도 새 창 안 뜸"(FR-009)이 된다
```rust
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
let _ = window::show(app);
}))
```
> **첫 플러그인으로 등록할 것** — 플러그인 문서의 요구사항.
---
## 8. DPI 인지 (PerMonitorV2) — **할 일 없음** ✅
research R11 이 "Tauri v2 에서 DPI 인지를 어디서 켜는지 미확정, docs-lib 만들면서 확인"으로 남겨둔 것. **확인 결과: 우리가 켤 필요가 없다.**
`tao` 0.35.3 이 이벤트 루프를 만들 때 **무조건** 부른다:
```rust
// tao-0.35.3/src/platform_impl/windows/dpi.rs:20
pub fn become_dpi_aware() {
static ENABLE_DPI_AWARENESS: Once = Once::new();
ENABLE_DPI_AWARENESS.call_once(|| unsafe {
if let Some(SetProcessDpiAwarenessContext) = *SET_PROCESS_DPI_AWARENESS_CONTEXT {
if !SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2).as_bool() {
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE);
}
} else if let Some(SetProcessDpiAwareness) = *SET_PROCESS_DPI_AWARENESS {
let _ = SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE);
} // ... 더 옛날 폴백
});
}
```
호출 지점: `tao-0.35.3/src/platform_impl/windows/event_loop.rs:190` — 이벤트 루프 생성 시.
**즉 Windows 10 1703 이상이면 PerMonitorV2 가 자동으로 켜진다.** .NET 판이 `app.manifest` 를 따로 넣어야 했던 것과 다름.
### 그럼 매니페스트는?
`tauri-build` 2.6.3 이 기본으로 박아넣는 매니페스트(`src/windows-app-manifest.xml`)에는 **`<dpiAware>` 항목이 아예 없다.** Common-Controls v6 의존성 하나뿐. 위 런타임 호출이 그 역할을 대신하므로 그대로 둔다.
> **바꿔야 할 일이 생기면** `tauri_build::WindowsAttributes::new().app_manifest(include_str!("app.manifest"))` 로 통째 교체하고 `Attributes::new().windows_attributes(..)` 에 물려 `try_build` 에 넘긴다 (tauri-build `src/lib.rs:337`). **지금은 필요 없다.**
**US4(캡쳐) 작업 시 함의**: 좌표가 어긋나면 원인이 DPI 인지 설정이 아니다. 논리↔물리 좌표 변환 쪽을 봐야 한다.
---
## 9. 커맨드 (JS → Rust) 와 capabilities
```rust
#[tauri::command]
fn window_hide(app: tauri::AppHandle) -> Result<(), String> { /* ... */ }
// lib.rs 에서
.invoke_handler(tauri::generate_handler![window_hide, /* ... */])
```
- 공유 상태는 `.manage(MyState::default())` 로 넣고 커맨드 인자에서 `state: tauri::State<MyState>` 로 받는다
- **에러 타입은 `Serialize` 여야 한다.** `String` 으로 돌려주면 프론트에서 `Error` 로 reject 됨 → 우리 계약의 한글 오류 메시지가 그대로 토스트로 흐른다 (`contracts/transport-mapping.md` §3)
### capabilities
현재 `capabilities/default.json` 은 `"permissions": ["core:default"]` 하나다. 프론트가 core API 를 직접 안 부르고 **우리 `#[tauri::command]` 만 invoke** 하므로 이걸로 충분하다 — `invoke`/`listen`/`emit` 은 `core:default` 에 들어있다.
플러그인 권한(`global-shortcut`·`window-state` 는 `permissions/default.toml` 을 가지고 있음)은 **프론트에서 그 플러그인의 JS API 를 직접 부를 때만** 필요하다. 우리는 Rust 쪽에서만 쓰므로 추가 안 해도 된다.
> 빌드 후 `src-tauri/gen/schemas/desktop-schema.json` 이 생기면 에디터가 권한 이름을 자동완성해준다. 권한이 모자라면 **런타임에 "not allowed" 로 거부**되지 컴파일 에러가 안 나므로, 프론트에서 플러그인 API 를 직접 부르기 시작하면 그때 여기를 의심할 것.
---
## 10. 한 장 요약 — import 안 하면 안 보이는 것들
컴파일 에러 중 제일 헷갈리는 게 "메서드가 없다"인데, 대부분 trait import 누락이다.
```rust
use tauri::Manager; // state(), path(), get_webview_window()
use tauri::Emitter; // emit()
use tauri_plugin_global_shortcut::GlobalShortcutExt; // global_shortcut()
use tauri_plugin_window_state::StateFlags; // with_state_flags()
```
+60
View File
@@ -0,0 +1,60 @@
# windows 0.61.3 — CodeAssist가 쓰는 Win32 표면
**작성**: 2026-09-09
**근거**: 로컬 crate 소스 `%USERPROFILE%/.cargo/registry/src/index.crates.io-*/windows-0.61.3/`에서 직접 확인함.
## Cargo features
```toml
windows = { version = "0.61.3", features = [
"Win32_Foundation",
"Win32_System_DataExchange",
"Win32_System_Memory",
"Win32_System_Threading",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }
```
## 직전 창 스냅샷
```rust
GetForegroundWindow() -> HWND
GetWindowTextW(HWND, &mut [u16]) -> i32
GetWindowThreadProcessId(HWND, Option<*mut u32>) -> u32
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) -> Result<HANDLE>
QueryFullProcessImageNameW(HANDLE, PROCESS_NAME_FORMAT, PWSTR, *mut u32) -> Result<()>
CloseHandle(HANDLE) -> Result<()>
```
창 핸들은 앱 상태에서 `isize`로 보관한다. `HWND`의 raw pointer를 그대로 앱 상태에 넣으면 Tauri가 요구하는 `Send + Sync` 경계와 부딪힐 수 있다.
## 유니코드 텍스트 클립보드
```rust
OpenClipboard(None) -> Result<()>
EmptyClipboard() -> Result<()>
GlobalAlloc(GMEM_MOVEABLE, bytes) -> Result<HGLOBAL>
GlobalLock(HGLOBAL) -> *mut c_void
GlobalUnlock(HGLOBAL) -> Result<()>
SetClipboardData(13, Some(HANDLE(hglobal.0))) -> Result<HANDLE> // 13 = CF_UNICODETEXT
CloseClipboard() -> Result<()>
```
`SetClipboardData`가 성공하면 메모리 소유권이 Windows로 넘어간다. 성공 뒤 `GlobalFree`를 부르면 안 된다. 실패한 경우에만 직접 해제한다.
## 대상 활성화와 Ctrl+V (2026-09-11 갱신)
```rust
SetForegroundWindow(HWND) -> BOOL
GetAsyncKeyState(i32) -> i16 // 상위 비트가 1이면 현재 눌린 상태
IsWindow(Option<HWND>) -> BOOL
SendInput(&[INPUT], size_of::<INPUT>() as i32) -> u32 // 실제 전송한 이벤트 수
```
순서: 클립보드 적재 → 키·마우스 해제 대기(최대 2초, 10ms 간격) → 대상 HWND·PID·thread 재확인 → `SetForegroundWindow` → 활성화·포커스·키 상태 확인(80ms) → `SendInput`으로 Ctrl/V 누름·해제 4개 전송 → 성공 때만 창 숨김. 클립보드는 원복하지 않음.
- `SetForegroundWindow` 반환 직후에는 대상 활성화가 아직 끝나지 않을 수 있음. 실제 검사에서 약 3ms 뒤 대상이 활성화되는 전환을 관찰함. 첫 활성화 전에는 원래 팔레트나 NULL만 허용하고, 다른 창 또는 활성화 뒤 이탈은 취소함.
- 이벤트 4개가 전부 전송되지 않으면 오류. 이미 누른 키의 해제만 정리하고 붙여넣기를 재시도하지 않음. 큐 전송 성공과 대상 편집기의 실제 내용 반영은 별개임.
- `paste_code``spawn_blocking`에서 대기·Win32 작업을 실행하므로 UI 스레드를 막지 않음. 대상 잠금은 스냅샷 복사 때만 사용하며, 진행 중 중복 요청은 바로 거절함.
- 실패하면 팔레트를 다시 표시하고 오류 안내를 유지. 클립보드 기록 이후 실패에만 원문이 남았다고 안내함.
+247
View File
@@ -0,0 +1,247 @@
{
"name": "codeassist-tauri",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeassist-tauri",
"version": "0.1.0",
"devDependencies": {
"@tauri-apps/cli": "^2"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "codeassist-tauri",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
+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"
]
}
}