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