diff --git a/.github/workflows/ci-windows-asio.yml b/.github/workflows/ci-windows-asio.yml new file mode 100644 index 000000000..fee2ad6b7 --- /dev/null +++ b/.github/workflows/ci-windows-asio.yml @@ -0,0 +1,57 @@ +name: Windows ASIO CI + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-asio-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + asio-check: + name: ASIO 컴파일·정책 테스트 + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable, 2026-08-05 + with: + toolchain: stable + components: clippy,rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + cache-on-failure: true + + - name: Windows 빌드 도구 확인 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $llvmBin = Join-Path $env:ProgramFiles 'LLVM\bin' + $libclang = Join-Path $llvmBin 'libclang.dll' + if (-not (Test-Path -LiteralPath $libclang -PathType Leaf)) { + throw "libclang.dll을 찾을 수 없습니다: $libclang" + } + "LIBCLANG_PATH=$llvmBin" >> $env:GITHUB_ENV + rustc --version + cargo --version + + - name: Rust 포맷 검사 + run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + + - name: ASIO feature 컴파일 검사 + run: cargo check --manifest-path src-tauri/Cargo.toml --features asio-backend + + - name: ASIO feature Clippy 검사 + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --features asio-backend -- -D warnings + + - name: ASIO 오디오 정책 테스트 + run: cargo test --manifest-path src-tauri/Cargo.toml --features asio-backend audio::engine --lib diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index dabef4ebe..798c5b0f6 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -223,6 +223,9 @@ jobs: - run: npm ci + - name: Windows ASIO 출력 정책 테스트 + run: cargo test --manifest-path src-tauri/Cargo.toml --features asio-backend audio::engine --lib + # 자동 업데이트 자산과 포터블 ZIP이 공유할 raw EXE를 한 번만 빌드 - name: Windows x64 빌드 run: npm run tauri:build -- --no-bundle diff --git a/docs/code-quality-refactoring-plan.md b/docs/code-quality-refactoring-plan.md new file mode 100644 index 000000000..f3fa3e38e --- /dev/null +++ b/docs/code-quality-refactoring-plan.md @@ -0,0 +1,185 @@ +# 코드 품질 리팩터링 결과와 후속 계획 + +## 목표와 판정 원칙 + +리팩터링 기준 커밋은 `af1bc19c`이며 작업 브랜치는 `refactor/code-quality-modularization`이다. 최종 검증 기준선은 2.0.2 태그를 포함한 `origin/main`의 `4b4d6c22`다. 이번 작업의 최우선 조건은 **의도한 동작 변경이 없는 책임 분리**와 **작업 중 추가된 선행 변경의 완전한 수용**이다. + +- UI, 상태·비동기 runtime, 순수 변환 모델을 변경 이유와 검증 경계에 맞춰 분리한다. +- 공개 import, Tauri command/event, 저장 schema, editor wire 형식을 유지한다. +- characterization test로 기존 호출 순서, 오류 전파, preview/commit, cleanup 계약을 먼저 고정한다. +- 테스트와 리팩터링을 단계별 한국어 conventional commit으로 남긴다. +- 줄 수만 줄이는 분리나 내부 상태를 넓은 props/API로 노출하는 분리는 하지 않는다. +- 기존 결함은 리팩터링에 섞어 고치지 않고 별도 후속 작업으로 기록한다. +- 완전 동일성은 자동 검증 범위에서 엄격히 확인하되, 실제 Windows ASIO 장치와 Tauri WebView 같은 플랫폼 경계는 수동 smoke test가 별도로 필요하다고 명시한다. + +## 브랜치 결과 요약 + +본 리팩터링 이력은 코드·테스트 242개와 이 결과 문서 1개를 포함해 총 243개 단계별 커밋으로 구성했다. 중간 수정 커밋 11개는 원인을 도입한 커밋에 흡수했고, rewrite 전 트리는 `backup/refactor-code-quality-pre-fixup-20260830`에 보존했다. rewrite 전후 코드 tree hash는 모두 `73d4dfccf034bc9e7290ebf978b956e4082c110a`로 동일하다. 최종 `main` 통합 전 상태는 `backup/refactor-code-quality-pre-main-sync-20260831`에도 별도로 보존했다. + +리팩터링 자체는 공개 API를 바꾸지 않는다. 최신 `main`이 추가한 탭 rename·reorder와 패널 native drag command를 포함해 현재 Tauri command는 `origin/main`과 동일한 150개이며, `src-tauri/permissions`, 생성 schema, `docs/content`에도 `origin/main` 대비 차이가 없다. + +## 최신 main 통합 결과 + +기준 커밋 이후 `origin/main`에 추가된 33개 커밋을 마지막 검증 전에 병합했다. 물리 좌표 기반 패널 drag·overlay 복원, 탭 이름 변경·순서·bar count, pointer 입력 시 초기 focus 정책, gooey·motion 정책, 2.0.2 release 문서와 설정을 모두 유지했다. + +- 충돌 13개 파일은 기존 façade를 유지하면서 최신 구현을 추출 모듈에 이식했다. +- 최신 `main`에서 추가된 Rust 함수와 테스트 이름을 전체 대조했고 누락은 0개였다. +- 새 좌표 알고리즘이 한 파일에 다시 집중되지 않도록 `window_geometry.rs` 505줄과 `window_geometry/overlay_placement.rs` 902줄로 분리했다. +- 새 패널 drag 구현도 `panel_drag.rs` 1006줄, Windows native 어댑터 1075줄, 테스트 407줄로 분리했다. +- 저장된 `tabOrder`·`barCount`·native overlay 좌표를 migration, history, preset, bootstrap에 함께 반영해 원자적 복원 계약을 유지했다. +- pointer 기반 popup/dropdown 진입은 focus 복원을 생략하고 keyboard 진입은 기존 focus 계약을 유지한다. + +아래 줄 수는 기준 커밋과 현재 파일을 `wc -l`로 직접 측정한 값이다. 현재 값은 추출된 하위 모듈을 제외한 façade 또는 상위 조립 파일의 크기다. + +### 프론트엔드 + +| 파일 | 기준 | 현재 | 완료한 책임 경계 | +| --------------------------------- | ---: | ---: | ------------------------------------------------------------------ | +| `PropertiesPanel.tsx` | 3407 | 1199 | 선택 route, commit/runtime, plugin 설정, rename, layer action 분리 | +| `BatchSelectionPanel.tsx` | 2367 | 668 | 타입별 섹션, 공통 graph/knob, key-like commit runtime 분리 | +| `SingleSelectionPanel.tsx` | 2061 | 646 | 타입별 패널과 표시 모델 분리 | +| `Settings.tsx` | 1423 | 793 | 오디오 출력, 비동기 적용 큐, resize anchor controller 분리 | +| `Grid.tsx` | 2303 | 1269 | 네이티브 장면, 선택 overlay, context/ghost 모델 분리 | +| `useGridSelection.ts` | 1552 | 494 | drag, movement, clipboard, paste, guide 모델 분리 | +| `PluginElement.tsx` | 1647 | 686 | DOM adapter, layout, snapshot·persistence runtime 분리 | +| `editorCoordinator.ts` | 3001 | 1784 | queue, retry, semantic projection, violation 처리 분리 | +| `elementOps.ts` | 2611 | 571 | document/property/group/style/geometry 모듈과 façade 분리 | +| `ColorPicker.tsx` | 1513 | 611 | 입력, palette/history, surface runtime 분리 | +| `NumberInput.tsx` | 1440 | 226 | draft/parser, scrub·keyboard session, chrome 분리 | +| `CounterAnimationEditorModal.tsx` | 1417 | 371 | draft/model, curve canvas, preview session 분리 | +| `SoundTrimModal.tsx` | 1275 | 632 | decode, waveform, trim/export 모델 분리 | + +추가로 `scopeUserCss.ts`는 212줄 façade, `smartGuides.ts`는 22줄 façade, `Dropdown.tsx`는 127줄, `ListPopup.tsx`는 168줄, `useLayerActions.ts`는 13줄 façade가 됐다. CSS selector/registry, 팝업 dismissal, 사운드 선택기 cache/runtime, editor structural/violation, plugin snapshot/persistence, window geometry persistence를 각각 독립 경계로 분리했다. + +### 백엔드 + +| 파일 | 기준 | 현재 | 완료한 책임 경계 | +| ------------------------- | ----: | ---: | --------------------------------------------------------------------- | +| `state/store.rs` | 22978 | 829 | persistence, writer, recovery, asset, editor, plugin transaction 분리 | +| `state/editor_ops.rs` | 10154 | 49 | 연산군·검증·structural operation과 테스트 분리 | +| `state/migration.rs` | 6053 | 275 | 복구·migration 도메인과 테스트 분리 | +| `state/editor.rs` | 5777 | 176 | editor 상태·요청·테스트 분리 | +| `state/app_state.rs` | 9314 | 1259 | 창 geometry, keyboard, shutdown, controller 경계 분리 | +| `models/mod.rs` | 4169 | 860 | editor와 도메인 모델 분리 | +| `commands/preset/load.rs` | 3104 | 975 | font/image/sound 복원과 테스트 분리 | +| `commands/keys/sound.rs` | 2189 | 806 | library scan, repair, WAV 교체, 테스트 분리 | +| `audio/engine.rs` | 1788 | 911 | ASIO, decode, output test, command thread 분리 | +| `state/history.rs` | 2101 | 690 | snapshot/admission/transaction 경계 분리 | +| `services/obs_bridge.rs` | 1975 | 837 | 세션·메시지·forwarding 경계 분리 | +| `commands/keys/keys.rs` | 2177 | 1007 | mapping, custom tab, import/export 경계 분리 | + +store 분리에서도 다음 불변식은 그대로 유지했다. + +- orphan 자산은 직접 삭제하지 않고 `trash/<세션>/`에서 30일 격리 +- store 복구가 발생한 세션은 asset sweep 생략 +- 파일 자산 참조 수집과 손상·크래시 복구의 교차 검증 유지 +- `keys[mode][i]`와 `keyPositions[mode][i]`의 인덱스 결합 유지 +- writer와 추출 모듈이 기존 lock·commit 경계를 우회하지 않음 + +## Grid·프로퍼티 패널 재사용성 결과 + +키·통계·그래프·노브를 Grid와 overlay, single/batch panel 사이에서 다시 대조했고, 실제 DOM이나 정책이 같은 부분만 공용화했다. + +- `KeyElementFace`: 키·통계의 placeholder, border, image/error, label, inside counter 표면 +- `CounterPreviewBody` / `CounterPreviewLayer`: 키·통계 외부 카운터의 공통 body와 layer +- `GraphPanel`: Grid/overlay 그래프 표면 +- `BatchGraphSettingsSection`: graph-only와 mixed 배치 설정 +- `KnobFace`: 링, 이미지, indicator 표면 +- `PanelRenameControl`: 단일·배치 이름 변경 header와 focus/select 순서 +- `NativeGridElements`: 네이티브 요소 scene 조립과 adapter +- `nativeElementReferenceRegistry`: mount/unmount 시 ref 등록·삭제 계약 +- `useGridElementInteraction`, stable handler slot, movement/clipboard/paste/smart-guide 모델: 포인터와 선택 정책 + +Graph 데이터 adapter, Knob 회전 상태, Key 활성 상태, Stat 정수 정규화처럼 의미가 다른 부분은 공용 컴포넌트의 조건문으로 합치지 않았다. 현재 `Grid.tsx`와 `PropertiesPanel.tsx`의 남은 대부분은 훅·route·props를 연결하는 상위 orchestration이다. 직접 테스트가 부족한 상태에서 더 분리하면 계약 면적과 회귀 위험이 커져 즉시 작업할 P0–P2 후보로 보지 않는다. + +## ASIO 보강 결과 + +`audio/engine/asio.rs`에서 하드웨어 독립 정책과 장치 I/O 경계를 분리했다. 버퍼 프레임 정규화, 드라이버 목록 정렬·중복 제거, 출력 구성, build availability, 오류 코드와 fallback을 자동 검증한다. + +- Rust ASIO focused suite: 18개 통과 +- 프론트엔드 ASIO 설정 계약: 7개 통과 +- `.github/workflows/ci-windows-asio.yml`: pull request와 수동 실행에서 fmt, feature check, Clippy `-D warnings`, focused test 실행 +- Windows release workflow: production build 전에 ASIO focused test 실행 + +실제 ASIO 드라이버 열기, 다른 앱의 장치 점유, 재시작 후 장치 복원은 Windows 실제 장치 smoke test로 남는다. macOS의 Windows cross target은 로컬 MSVC CRT header가 없어 프로젝트 코드 전에 중단되므로 이를 대체하지 않는다. + +## 검증 결과 + +단계별 focused suite와 독립 서브에이전트 감사를 반복했고, 최종 코드 트리에서 다음 결과를 확인했다. + +- TypeScript type check 통과 +- ESLint 오류·경고 0 +- Prettier check 통과 +- 전체 Vitest: 372개 파일, 3596개 테스트 통과, 18개 skip +- Vite production build 통과; 기존 대형 chunk 경고만 유지 +- Rust 전체: 1044개 통과, 6개 ignored +- Rust fmt, all-target check, Clippy `-D warnings` 통과 +- ASIO feature check·Clippy와 focused 18개 테스트 통과 +- 최신 `main` 추가 Rust 함수·테스트 이름 대조에서 누락 0개 +- 최종 병합 tree `git diff --check` 통과 +- Rust module 175개와 literal `include_str!` / `include_bytes!` 경로 확인 + +동작 동일성 판정에는 AST/토큰 정규화 비교, 공개 반환 객체와 hook/ref 선언 순서 비교, close/await/RAF/focus 순서 테스트, 오류 전파·rollback·preview/commit characterization을 함께 사용했다. 마지막 독립 감사와 최신 `main` 통합 후 그래프·파일 크기 재감사에서는 즉시 안전하고 고가치인 추가 P0–P2 회귀·분리 후보를 발견하지 못했다. 복잡도가 높은 native window·keyboard runtime은 실제 플랫폼 harness 없이 더 분리하면 호출 순서 회귀 위험이 커 별도 고위험 경계로 유지한다. + +## 의도적으로 보존한 기존 결함 + +아래 항목은 이번 리팩터링에서 발견했지만 동작 변경을 섞지 않기 위해 고치지 않았다. 별도 bug-fix 브랜치에서 먼저 실패 테스트와 기대 동작을 합의해야 한다. + +| 영역 | 기존 동작 | +| ---------------------------- | ------------------------------------------------------------------------------------ | +| `PluginElement` subscription | 비동기 key/rawKey 구독이 unmount 뒤 완료되면 cleanup 누락 가능 | +| `PluginElement` state | 같은 tick의 shallow `setState`가 서로 다른 leaf 하나를 잃을 수 있음 | +| `NativeGridElements` | type+index 기반 action이 reorder/delete 뒤 다른 요소를 가리키거나 실패할 수 있음 | +| main dialog / color picker | 기존 timer·ref 재진입 경계가 완전히 정산되지 않음 | +| `SoundTrimModal` | 열린 상태에서 직접 unmount하면 playback/pointer cleanup 계약이 없음 | +| counter animation preview | Escape/window blur 및 복수 preview press의 첫 release 동작이 기존대로 유지됨 | +| `useLayerDnD` | active drag 중 unmount 시 document listener, scheduler, body cursor 정산 계약이 없음 | + +## 후속 작업 분류 + +### 선행 harness가 필요한 고위험 경계 + +- `WebGLTracksOGL` / `noteBuffer`: 실제 GPU resource 수명과 성능 회귀 harness +- `useNoteSystem`: fake clock 기반 timing·pool·subscription continuity 검증 +- `editorCoordinator`: queue/rebase의 결정적 trace와 failure injection +- editor transition/migration recovery: property/fuzz와 오류 우선순위 검증 +- OBS/Tauri window runtime: 실제 창·WebSocket·종료 timing 통합 테스트 +- ASIO/audio와 Windows keyboard: 실제 Windows driver/device smoke test +- 위 표의 기존 결함: 기대 동작 결정 후 별도 수정 + +### 낮은 우선순위 + +- `listPopupMenuRows.tsx`, `registryRewrite.ts` 등은 더 나눌 수 있지만 내부 pointer/registry 상태를 새 API로 노출하는 비용이 더 큼 +- `Grid.tsx`, `PropertiesPanel.tsx`의 추가 분리는 stable ID와 직접 orchestration test를 먼저 갖춘 뒤 재평가 +- Vitest의 기존 React `act(...)`, mock DOM prop, CSS parser stderr는 실패와 구분되지만 회귀 로그의 신호 대 잡음비를 낮추므로 별도 테스트 위생 작업으로 정리 +- cohesive wire schema와 단순 route table은 파일 크기만으로 분할하지 않음 + +## 재현 가능한 최종 게이트 + +```bash +npx tsc --noEmit +npm run lint +npm run format:check +npm test -- --reporter=dot +npm run build + +cd src-tauri +cargo fmt --all -- --check +cargo check --all-targets +cargo clippy --all-targets -- -D warnings +cargo test --all-targets --quiet +cargo check --all-targets --features asio-backend +cargo clippy --all-targets --features asio-backend -- -D warnings +cargo test --lib --features asio-backend audio::engine +``` + +## 완료 판정 + +- 공개 API·저장 형식·command/event 집합에 의도한 변경 없음 +- 2.0.2 기준 최신 `origin/main` 선행 변경과 command 150개를 누락 없이 통합 +- 단계별 커밋과 focused/전체 검증 통과 +- rewrite 전후 tree 동일성 확인 및 복구 branch 보존 +- Grid 핵심 표면과 패널 반복 UI 공용화 완료 +- 대형 프론트엔드·백엔드 파일의 고가치 책임 경계 분리 완료 +- 마지막 독립 감사에서 즉시 진행할 추가 P0–P2 후보 없음 +- 실제 장치·GPU·native window가 필요한 항목과 기존 결함은 검증 조건을 붙여 후속 범위로 보존 diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 355b55b29..ffc280c18 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -282,13 +282,13 @@ pub fn run() { commands::keys::keys::keys_set_counters, commands::keys::keys::raw_input_subscribe, commands::keys::keys::raw_input_unsubscribe, - commands::keys::keys::custom_tabs_list, - commands::keys::keys::custom_tabs_create, - commands::keys::keys::custom_tabs_rename, - commands::keys::keys::tabs_reorder, - commands::keys::keys::custom_tabs_delete, - commands::keys::keys::custom_tabs_select, - commands::keys::keys::custom_tabs_restore, + commands::keys::keys::custom_tabs::custom_tabs_list, + commands::keys::keys::custom_tabs::custom_tabs_create, + commands::keys::keys::custom_tabs::custom_tabs_rename, + commands::keys::keys::custom_tabs::tabs_reorder, + commands::keys::keys::custom_tabs::custom_tabs_delete, + commands::keys::keys::custom_tabs::custom_tabs_select, + commands::keys::keys::custom_tabs::custom_tabs_restore, commands::keys::keys::layer_groups_get, commands::keys::key_sound::key_sound_get_status, commands::keys::key_sound::key_sound_set_enabled, diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index 6d01129c3..546438dda 100644 --- a/src-tauri/src/audio/engine.rs +++ b/src-tauri/src/audio/engine.rs @@ -18,8 +18,6 @@ use std::{error::Error, fmt}; use anyhow::{Context, Result}; #[cfg(debug_assertions)] use log::debug; -#[cfg(all(windows, feature = "asio-backend"))] -use log::info; use log::warn; use parking_lot::RwLock; use rodio::{cpal, DeviceSinkBuilder, MixerDeviceSink, Source}; @@ -37,6 +35,17 @@ use symphonia::{ default::{get_codecs, get_probe}, }; +mod asio; +mod clips; +mod runtime; + +use asio::{ + backend_available as asio_backend_available, list_drivers as list_asio_drivers, + open_audio_sink as open_asio_audio_sink, +}; +use clips::{get_or_load_cached_clip, AudioSource, LoadedSoundpack}; +use runtime::audio_thread; + #[cfg(debug_assertions)] const LATENCY_SUMMARY_INTERVAL: u64 = 50; @@ -213,8 +222,8 @@ impl KeySoundOutputBackend { driver_name, buffer_size, } => Self::Asio { - driver_name: driver_name.trim().to_string(), - buffer_size: buffer_size.filter(|size| *size > 0), + driver_name: asio::normalize_driver_name(&driver_name), + buffer_size: asio::normalize_buffer_size(buffer_size), }, } } @@ -480,233 +489,6 @@ impl KeySoundEngine { } } -fn audio_thread( - receiver: Receiver, - state: Arc>, - fallback_callback: Arc< - dyn Fn(KeySoundOutputBackend, KeySoundOutputBackend) + Send + Sync + 'static, - >, -) { - #[cfg(any(target_os = "windows", target_os = "macos"))] - { - use thread_priority::{set_current_thread_priority, ThreadPriority}; - let _ = set_current_thread_priority(ThreadPriority::Max); - } - - let mut enabled = state.read().status.enabled; - let mut volume = state.read().status.volume; - let mut soundpack = state.read().soundpack.clone(); - #[cfg(debug_assertions)] - let mut latency_logging = state.read().status.latency_logging; - let mut requested_backend = state.read().output_state.requested.clone(); - let mut stream_handler = None; - let output_state = open_initial_output_backend( - requested_backend.clone(), - &mut stream_handler, - fallback_callback.as_ref(), - ); - requested_backend = output_state.requested.clone(); - state.write().output_state = output_state; - let mut file_cache: HashMap> = HashMap::new(); - #[cfg(debug_assertions)] - let mut latency_summary = LatencySummary::default(); - - while let Ok(command) = receiver.recv() { - match command { - AudioCommand::SetEnabled(value) => { - enabled = value; - } - AudioCommand::SetVolume(value) => { - volume = value; - } - AudioCommand::SetLatencyLogging(value) => { - #[cfg(debug_assertions)] - { - latency_logging = value; - } - #[cfg(not(debug_assertions))] - { - let _ = value; - } - } - AudioCommand::SetSoundpack(pack) => { - soundpack = pack; - } - AudioCommand::InvalidateFileCache { path } => { - file_cache.remove(&path); - } - AudioCommand::SetOutputBackend { backend, reply } => { - let output_state = switch_output_backend(backend, &mut stream_handler); - requested_backend = output_state.requested.clone(); - state.write().output_state = output_state.clone(); - let _ = reply.send(output_state); - } - AudioCommand::PlayLabels { - labels, - queued_at, - trace, - } => { - if !enabled { - continue; - } - let Some(pack) = soundpack.as_ref() else { - continue; - }; - #[cfg(debug_assertions)] - let audio_started_at = latency_logging.then_some(Instant::now()); - let Some(source) = pack.source_for_labels(&labels) else { - continue; - }; - #[cfg(not(debug_assertions))] - let _ = (queued_at, trace); - - #[cfg(debug_assertions)] - let play_started_at = latency_logging.then_some(Instant::now()); - if !play_on_stream( - &mut stream_handler, - source, - volume, - &mut requested_backend, - &state, - fallback_callback.as_ref(), - ) { - continue; - } - #[cfg(debug_assertions)] - { - let play_ms = play_started_at - .map(|started| started.elapsed().as_secs_f64() * 1000.0) - .unwrap_or(0.0); - if latency_logging { - let queue_ms = queued_at.elapsed().as_secs_f64() * 1000.0; - let dispatch_ms = - trace.map(KeySoundDispatchTrace::dispatch_ms).unwrap_or(0.0); - let thread_ms = audio_started_at - .map(|started| started.elapsed().as_secs_f64() * 1000.0) - .unwrap_or(0.0); - let total_ms = trace - .map(KeySoundDispatchTrace::total_elapsed_ms) - .unwrap_or(dispatch_ms + queue_ms + thread_ms); - debug!( - "[KeySound][Latency] route=soundpack dispatchMs={dispatch_ms:.3} queueMs={queue_ms:.3} playMs={play_ms:.3} threadMs={thread_ms:.3} totalMs={total_ms:.3} labels={labels:?}" - ); - latency_summary.push( - LatencySample { - dispatch_ms, - queue_ms, - play_ms, - thread_ms, - total_ms, - ..Default::default() - }, - false, - ); - if latency_summary.should_emit_summary() { - latency_summary.emit_summary(); - } - } - } - } - AudioCommand::PlayFile { - path, - per_key_volume, - queued_at, - trace, - } => { - if !enabled { - continue; - } - #[cfg(debug_assertions)] - let audio_started_at = latency_logging.then_some(Instant::now()); - #[cfg(not(debug_assertions))] - let _ = (queued_at, trace); - - #[cfg(debug_assertions)] - let clip_lookup_started_at = latency_logging.then_some(Instant::now()); - let (clip, clip_load_trace) = - match get_or_load_cached_clip(&path, &mut file_cache, { - #[cfg(debug_assertions)] - { - latency_logging - } - #[cfg(not(debug_assertions))] - { - false - } - }) { - Some(result) => result, - None => continue, - }; - #[cfg(debug_assertions)] - let cache_lookup_ms = clip_lookup_started_at - .map(|started| started.elapsed().as_secs_f64() * 1000.0) - .unwrap_or(0.0); - #[cfg(not(debug_assertions))] - let _ = clip_load_trace; - - let final_volume = (volume * per_key_volume).clamp(0.0, 2.0); - let source = - AudioSource::new(clip.samples.clone(), clip.channels, clip.sample_rate); - - #[cfg(debug_assertions)] - let play_started_at = latency_logging.then_some(Instant::now()); - if !play_on_stream( - &mut stream_handler, - source, - final_volume, - &mut requested_backend, - &state, - fallback_callback.as_ref(), - ) { - continue; - } - #[cfg(debug_assertions)] - { - let play_ms = play_started_at - .map(|started| started.elapsed().as_secs_f64() * 1000.0) - .unwrap_or(0.0); - if latency_logging { - let queue_ms = queued_at.elapsed().as_secs_f64() * 1000.0; - let dispatch_ms = - trace.map(KeySoundDispatchTrace::dispatch_ms).unwrap_or(0.0); - let thread_ms = audio_started_at - .map(|started| started.elapsed().as_secs_f64() * 1000.0) - .unwrap_or(0.0); - let total_ms = trace - .map(KeySoundDispatchTrace::total_elapsed_ms) - .unwrap_or(dispatch_ms + queue_ms + thread_ms); - let cache_label = if clip_load_trace.cache_hit { - "hit" - } else { - "miss" - }; - debug!( - "[KeySound][Latency] route=key-file dispatchMs={dispatch_ms:.3} queueMs={queue_ms:.3} cacheLookupMs={cache_lookup_ms:.3} decodeMs={:.3} playMs={play_ms:.3} threadMs={thread_ms:.3} totalMs={total_ms:.3} cache={} volume={final_volume:.3} path={path}", - clip_load_trace.decode_ms, - cache_label - ); - latency_summary.push( - LatencySample { - dispatch_ms, - queue_ms, - cache_lookup_ms, - decode_ms: clip_load_trace.decode_ms, - play_ms, - thread_ms, - total_ms, - }, - !clip_load_trace.cache_hit, - ); - if latency_summary.should_emit_summary() { - latency_summary.emit_summary(); - } - } - } - } - } - } -} - // sink별 에러 플래그를 분리하여 이전 sink의 콜백이 새 sink를 오염시키지 않도록 함 struct StreamHandler { sink: MixerDeviceSink, @@ -752,10 +534,6 @@ impl Error for AudioSinkOpenError { } } -fn asio_backend_available() -> bool { - cfg!(all(windows, feature = "asio-backend")) -} - fn stream_error_callback( label: &'static str, ) -> ( @@ -1043,147 +821,6 @@ fn open_system_device_audio_sink(id: &str, stored_name: &str) -> AudioSinkResult }) } -#[cfg(all(windows, feature = "asio-backend"))] -fn open_asio_audio_sink( - driver_name: &str, - buffer_size: Option, -) -> AudioSinkResult { - use cpal::traits::{DeviceTrait, HostTrait}; - - let driver_name = driver_name.trim(); - if driver_name.is_empty() { - return Err(AudioSinkOpenError::AsioDeviceNotFound); - } - - let host = cpal::host_from_id(cpal::HostId::Asio) - .map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; - let devices = host - .output_devices() - .map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; - - for device in devices { - let name = match device.description() { - Ok(description) => description.name().trim().to_string(), - Err(err) => { - warn!("[KeySound] failed to read ASIO device name: {err}"); - continue; - } - }; - if name == driver_name { - return open_device_audio_sink(device, buffer_size); - } - } - - Err(AudioSinkOpenError::AsioDeviceNotFound) -} - -#[cfg(not(all(windows, feature = "asio-backend")))] -fn open_asio_audio_sink( - _driver_name: &str, - _buffer_size: Option, -) -> AudioSinkResult { - Err(AudioSinkOpenError::AsioUnavailableBuild) -} - -/// ASIO 기본 버퍼 크기(프레임). 미지정 시 이 값으로 고정 오픈 -#[cfg(all(windows, feature = "asio-backend"))] -const DEFAULT_ASIO_BUFFER_FRAMES: u32 = 64; - -#[cfg(all(windows, feature = "asio-backend"))] -fn open_device_audio_sink( - device: cpal::Device, - buffer_size: Option, -) -> AudioSinkResult { - // 미지정(None)이면 기본 64로 오픈 - let frames = buffer_size - .filter(|frames| *frames > 0) - .unwrap_or(DEFAULT_ASIO_BUFFER_FRAMES); - - // 고정 버퍼 지정 시 그 값 그대로 오픈 - try_open_asio_sink(device, frames) -} - -#[cfg(all(windows, feature = "asio-backend"))] -fn try_open_asio_sink(device: cpal::Device, buffer_size: u32) -> AudioSinkResult { - use cpal::traits::DeviceTrait; - - // 일부 드라이버(Realtek ASIO 등)는 클럭 미확립 상태에서 sample rate 0을 보고함. - // rodio from_device 내부의 NonZero unwrap 패닉(release는 abort) 방지를 위한 사전 검증 - let default_config = device - .default_output_config() - .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; - if default_config.sample_rate() == 0 || default_config.channels() == 0 { - return Err(AudioSinkOpenError::OpenFailed(anyhow::anyhow!( - "ASIO 드라이버가 유효한 샘플레이트/채널 구성을 보고하지 않았습니다" - ))); - } - - let (error, callback) = stream_error_callback("ASIO stream"); - - let builder = DeviceSinkBuilder::from_device(device) - .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))? - .with_error_callback(callback); - - // 샘플레이트는 드라이버 현재값(default_output_config)을 그대로 사용 → ASIOSetSampleRate 회피. - // 버퍼는 명시 고정만 사용 - let sink = builder - .with_buffer_size(cpal::BufferSize::Fixed(buffer_size)) - .open_stream() - .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; - - let config = sink.config(); - info!( - "[KeySound] ASIO 스트림 오픈: 요청 버퍼={}, 적용 sample_rate={}Hz, buffer={:?}", - buffer_size, - config.sample_rate().get(), - config.buffer_size() - ); - - Ok(StreamHandler { sink, error }) -} - -#[cfg(all(windows, feature = "asio-backend"))] -fn list_asio_drivers() -> Vec { - use cpal::traits::{DeviceTrait, HostTrait}; - - let Ok(host) = cpal::host_from_id(cpal::HostId::Asio) else { - return Vec::new(); - }; - let Ok(devices) = host.output_devices() else { - return Vec::new(); - }; - - let mut names: Vec = devices - .filter_map(|device| { - let name = device - .description() - .ok() - .map(|description| description.name().trim().to_string()) - .filter(|name| !name.is_empty())?; - // 불량 드라이버 제외, 샘플레이트/채널 0 보고 - match device.default_output_config() { - Ok(config) if config.sample_rate() > 0 && config.channels() > 0 => Some(name), - Ok(_) => { - warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 유효하지 않은 샘플레이트/채널 보고"); - None - } - Err(err) => { - warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 기본 구성 조회 실패 ({err})"); - None - } - } - }) - .collect(); - names.sort(); - names.dedup(); - names -} - -#[cfg(not(all(windows, feature = "asio-backend")))] -fn list_asio_drivers() -> Vec { - Vec::new() -} - fn list_system_output_devices() -> Vec { use cpal::traits::{DeviceTrait, HostTrait}; @@ -1270,519 +907,5 @@ fn play_on_stream( } #[cfg(test)] -mod output_backend_tests { - use std::sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - Arc, - }; - - use super::{ - open_initial_output_backend, open_system_device_audio_sink, startup_fallback_forgets, - switch_output_backend_with_notification, AudioSinkOpenError, KeySoundEngine, - KeySoundOutputBackend, ERROR_CODE_ASIO_DEVICE_NOT_FOUND, ERROR_CODE_ASIO_OPEN_FAILED, - ERROR_CODE_ASIO_UNAVAILABLE_BUILD, ERROR_CODE_DEFAULT_OPEN_FAILED, - ERROR_CODE_DEVICE_NOT_FOUND, ERROR_CODE_DEVICE_OPEN_FAILED, - }; - - #[test] - fn device_backend_normalizes_id_and_name() { - assert_eq!( - KeySoundOutputBackend::Device { - id: " coreaudio:device-id ".to_string(), - name: " Speakers ".to_string(), - } - .normalized(), - KeySoundOutputBackend::Device { - id: "coreaudio:device-id".to_string(), - name: "Speakers".to_string(), - } - ); - } - - #[test] - fn invalid_device_id_is_reported_as_not_found() { - let result = open_system_device_audio_sink("invalid-device-id", "Speakers"); - assert!(matches!(result, Err(AudioSinkOpenError::DeviceNotFound))); - } - - #[test] - fn failed_device_selection_forgets_requested_backend_and_notifies() { - let mut stream_handler = None; - let notified = AtomicBool::new(false); - let fallback_callback = |failed, settled| { - assert_eq!( - failed, - KeySoundOutputBackend::Device { - id: "invalid-device-id".to_string(), - name: "Speakers".to_string(), - } - ); - assert_eq!(settled, KeySoundOutputBackend::DefaultDevice); - notified.store(true, Ordering::Relaxed); - }; - let output_state = switch_output_backend_with_notification( - KeySoundOutputBackend::Device { - id: "invalid-device-id".to_string(), - name: "Speakers".to_string(), - }, - &mut stream_handler, - &fallback_callback, - ); - - assert_eq!(output_state.requested, KeySoundOutputBackend::DefaultDevice); - assert!(notified.load(Ordering::Relaxed)); - if let Some(handler) = stream_handler.as_mut() { - handler.sink.log_on_drop(false); - } - } - - #[test] - fn startup_fallback_forgets_only_missing_devices() { - assert!(startup_fallback_forgets(ERROR_CODE_DEVICE_NOT_FOUND)); - assert!(startup_fallback_forgets(ERROR_CODE_ASIO_DEVICE_NOT_FOUND)); - assert!(!startup_fallback_forgets(ERROR_CODE_ASIO_OPEN_FAILED)); - assert!(!startup_fallback_forgets(ERROR_CODE_DEVICE_OPEN_FAILED)); - assert!(!startup_fallback_forgets(ERROR_CODE_DEFAULT_OPEN_FAILED)); - assert!(!startup_fallback_forgets(ERROR_CODE_ASIO_UNAVAILABLE_BUILD)); - } - - // 기동 시 장치 부재는 런타임과 같이 forget - 저장값이 기본 장치로 덮인다 - #[test] - fn startup_forgets_missing_device_and_notifies() { - let mut stream_handler = None; - let notified = Arc::new(AtomicBool::new(false)); - let notified_for_callback = Arc::clone(¬ified); - let fallback_callback = move |_failed, _settled| { - notified_for_callback.store(true, Ordering::Relaxed); - }; - let output_state = open_initial_output_backend( - KeySoundOutputBackend::Device { - id: "invalid-device-id".to_string(), - name: "Speakers".to_string(), - }, - &mut stream_handler, - &fallback_callback, - ); - - if let Some(handler) = stream_handler.as_mut() { - handler.sink.log_on_drop(false); - } - // 기본 장치조차 열 수 없는 환경(헤드리스 CI)에서는 폴백 판정 자체가 성립하지 않는다 - if output_state.error_code.as_deref() == Some(ERROR_CODE_DEFAULT_OPEN_FAILED) { - return; - } - assert_eq!(output_state.requested, KeySoundOutputBackend::DefaultDevice); - assert!(notified.load(Ordering::Relaxed)); - } - - #[test] - fn set_output_backend_does_not_notify_fallback_callback() { - let callback_count = Arc::new(AtomicUsize::new(0)); - let callback_count_for_engine = Arc::clone(&callback_count); - let engine = KeySoundEngine::with_output_backend( - KeySoundOutputBackend::DefaultDevice, - Arc::new(move |_, _| { - callback_count_for_engine.fetch_add(1, Ordering::Relaxed); - }), - ); - - let output_state = engine.set_output_backend(KeySoundOutputBackend::Device { - id: "invalid-device-id".to_string(), - name: "Speakers".to_string(), - }); - - assert_eq!(output_state.requested, KeySoundOutputBackend::DefaultDevice); - assert_eq!(callback_count.load(Ordering::Relaxed), 0); - } -} - -fn get_or_load_cached_clip( - path: &str, - cache: &mut HashMap>, - measure_decode_ms: bool, -) -> Option<(Arc, ClipLoadTrace)> { - if let Some(cached) = cache.get(path) { - return Some(( - cached.clone(), - ClipLoadTrace { - cache_hit: true, - decode_ms: 0.0, - }, - )); - } - - #[cfg(debug_assertions)] - let decode_started_at = measure_decode_ms.then_some(Instant::now()); - #[cfg(not(debug_assertions))] - let _ = measure_decode_ms; - match decode_audio_file_clip(path) { - Ok(clip) => { - let shared = Arc::new(clip); - cache.insert(path.to_string(), shared.clone()); - Some(( - shared, - ClipLoadTrace { - cache_hit: false, - #[cfg(debug_assertions)] - decode_ms: decode_started_at - .map(|started| started.elapsed().as_secs_f64() * 1000.0) - .unwrap_or(0.0), - #[cfg(not(debug_assertions))] - decode_ms: 0.0, - }, - )) - } - Err(error) => { - warn!( - "[KeySound] failed to decode key sound file '{}': {error:#}", - path - ); - None - } - } -} - -fn decode_audio_file_clip(path: &str) -> Result { - let file = - File::open(path).with_context(|| format!("failed to open key sound file: {}", path))?; - let media_source = MediaSourceStream::new(Box::new(file), Default::default()); - let path_ref = Path::new(path); - - let mut hint = Hint::new(); - if let Some(ext) = path_ref.extension().and_then(|value| value.to_str()) { - hint.with_extension(ext); - } - - let probe = get_probe() - .format( - &hint, - media_source, - &FormatOptions::default(), - &Default::default(), - ) - .context("failed to probe key sound file format")?; - let mut format = probe.format; - let track = format - .default_track() - .context("no default track in key sound file")?; - let track_id = track.id; - let mut decoder = get_codecs() - .make(&track.codec_params, &Default::default()) - .context("failed to create key sound decoder")?; - - let mut channels = track - .codec_params - .channels - .map(|value| value.count() as u16); - let mut sample_rate = track.codec_params.sample_rate; - let mut samples: Vec = Vec::new(); - - loop { - let packet = match format.next_packet() { - Ok(packet) => packet, - Err(SymphoniaError::IoError(io_error)) - if io_error.kind() == ErrorKind::UnexpectedEof => - { - break; - } - Err(err) => { - return Err(anyhow::Error::new(err).context("failed to read key sound packet")); - } - }; - - if packet.track_id() != track_id { - continue; - } - - let decoded = match decoder.decode(&packet) { - Ok(decoded) => decoded, - Err(SymphoniaError::DecodeError(_)) => continue, - Err(SymphoniaError::ResetRequired) => { - decoder.reset(); - continue; - } - Err(err) => { - return Err(anyhow::Error::new(err).context("failed to decode key sound packet")); - } - }; - - channels.get_or_insert(decoded.spec().channels.count() as u16); - sample_rate.get_or_insert(decoded.spec().rate); - - let mut sample_buffer = - SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()); - sample_buffer.copy_interleaved_ref(decoded); - samples.extend_from_slice(sample_buffer.samples()); - } - - if samples.is_empty() { - anyhow::bail!("decoded sample buffer is empty"); - } - - let channels = channels.context("missing channel count in key sound file")?; - let sample_rate = sample_rate.context("missing sample rate in key sound file")?; - - Ok(CachedAudioClip { - samples: Arc::from(samples.into_boxed_slice()), - channels, - sample_rate, - }) -} - -#[derive(Debug)] -struct LoadedSoundpack { - segments: HashMap>, - fallback: Option>, - channels: u16, - sample_rate: u32, -} - -impl LoadedSoundpack { - fn from_dir(soundpack_dir: &Path) -> Result { - let config_path = soundpack_dir.join("config.json"); - let config: SoundpackConfig = - serde_json::from_reader(File::open(&config_path).with_context(|| { - format!("failed to open soundpack config: {}", config_path.display()) - })?) - .context("failed to parse soundpack config.json")?; - - let audio_path = soundpack_dir.join(&config.audio_file); - let mut decoder = SoundDecoder::new(&audio_path)?; - let mut decoded_by_range: HashMap<(u64, u64), Arc<[f32]>> = HashMap::new(); - let mut segments = HashMap::new(); - - for (label, [start_ms, duration_ms]) in config.defines { - let cache_key = (start_ms, duration_ms); - let samples = if let Some(existing) = decoded_by_range.get(&cache_key) { - existing.clone() - } else { - let decoded = decoder.get_samples_buf(start_ms, duration_ms)?; - let shared: Arc<[f32]> = Arc::from(decoded.into_boxed_slice()); - decoded_by_range.insert(cache_key, shared.clone()); - shared - }; - segments.insert(normalize_label(&label), samples); - } - - let fallback = if let Some([start_ms, duration_ms]) = config.fallback { - let cache_key = (start_ms, duration_ms); - if let Some(existing) = decoded_by_range.get(&cache_key) { - Some(existing.clone()) - } else { - let decoded = decoder.get_samples_buf(start_ms, duration_ms)?; - let shared: Arc<[f32]> = Arc::from(decoded.into_boxed_slice()); - decoded_by_range.insert(cache_key, shared.clone()); - Some(shared) - } - } else { - None - }; - - Ok(Self { - segments, - fallback, - channels: decoder.channels, - sample_rate: decoder.sample_rate, - }) - } - - fn source_for_labels(&self, labels: &[String]) -> Option { - for label in labels { - let normalized = normalize_label(label); - if let Some(samples) = self.segments.get(&normalized) { - return Some(AudioSource::new( - samples.clone(), - self.channels, - self.sample_rate, - )); - } - } - - self.fallback - .as_ref() - .map(|samples| AudioSource::new(samples.clone(), self.channels, self.sample_rate)) - } -} - -fn normalize_label(label: &str) -> String { - label.trim().to_ascii_uppercase() -} - -#[derive(Debug, Deserialize)] -struct SoundpackConfig { - #[serde(default = "default_audio_file")] - audio_file: String, - defines: HashMap, - #[serde(default)] - fallback: Option<[u64; 2]>, -} - -fn default_audio_file() -> String { - "sound.ogg".to_string() -} - -#[derive(Clone, Debug)] -struct AudioSource { - samples: Arc<[f32]>, - channels: u16, - sample_rate: u32, - gain: f32, - pos: usize, -} - -/// 천장(1.0) 근처에서 부드럽게 수렴시키는 소프트 리미터 -/// knee 미만은 그대로 통과(일반 볼륨 무영향), 초과분만 1.0으로 압축 -fn soft_limit_sample(x: f32) -> f32 { - const KNEE: f32 = 0.95; - let mag = x.abs(); - if mag <= KNEE { - return x; - } - let over = (mag - KNEE) / (1.0 - KNEE); - let limited = KNEE + (1.0 - KNEE) * over.tanh(); - limited.copysign(x) -} - -impl AudioSource { - fn new(samples: Arc<[f32]>, channels: u16, sample_rate: u32) -> Self { - Self { - samples, - channels, - sample_rate, - gain: 1.0, - pos: 0, - } - } - - fn with_gain(mut self, gain: f32) -> Self { - self.gain = gain; - self - } -} - -impl Iterator for AudioSource { - type Item = f32; - - fn next(&mut self) -> Option { - let value = self.samples.get(self.pos)?; - self.pos += 1; - Some(soft_limit_sample(*value * self.gain)) - } -} - -impl Source for AudioSource { - fn current_span_len(&self) -> Option { - None - } - - fn channels(&self) -> NonZero { - NonZero::new(self.channels).expect("channels must be > 0") - } - - fn sample_rate(&self) -> NonZero { - NonZero::new(self.sample_rate).expect("sample_rate must be > 0") - } - - fn total_duration(&self) -> Option { - None - } -} - -struct SoundDecoder { - decoder: Box, - format: Box, - time_base: TimeBase, - sample_rate: u32, - channels: u16, -} - -impl SoundDecoder { - fn new(path: &Path) -> Result { - let file = File::open(path) - .with_context(|| format!("failed to open sound file: {}", path.display()))?; - let media_source = MediaSourceStream::new(Box::new(file), Default::default()); - - let mut hint = Hint::new(); - if let Some(ext) = path.extension().and_then(|value| value.to_str()) { - hint.with_extension(ext); - } - - let probe = get_probe() - .format( - &hint, - media_source, - &FormatOptions::default(), - &Default::default(), - ) - .context("failed to probe sound file format")?; - let format = probe.format; - let track = format - .default_track() - .context("no default track in sound file")?; - let decoder = get_codecs() - .make(&track.codec_params, &Default::default()) - .context("failed to create audio decoder")?; - - let (sample_rate, channels, time_base) = { - let params = decoder.codec_params(); - ( - params - .sample_rate - .context("missing sample rate in sound file")?, - params - .channels - .map(|v| v.count() as u16) - .context("missing channels in sound file")?, - params - .time_base - .context("missing time base in sound file")?, - ) - }; - - Ok(Self { - decoder, - format, - time_base, - sample_rate, - channels, - }) - } - - fn get_samples_buf(&mut self, start_ms: u64, duration_ms: u64) -> Result> { - self.format - .seek( - SeekMode::Accurate, - SeekTo::Time { - track_id: None, - time: Duration::from_millis(start_ms).into(), - }, - ) - .context("failed to seek sound file")?; - self.decoder.reset(); - - let mut decoded_duration_ms = 0_u64; - let mut samples = Vec::new(); - - while decoded_duration_ms < duration_ms { - let packet = self - .format - .next_packet() - .context("failed to fetch audio packet")?; - - let packet_time = self.time_base.calc_time(packet.dur); - decoded_duration_ms += - ((packet_time.seconds as f64 + packet_time.frac) * 1000.0) as u64; - - let decoded = self - .decoder - .decode(&packet) - .context("failed to decode audio packet")?; - let mut sample_buffer = - SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()); - sample_buffer.copy_interleaved_ref(decoded); - samples.extend_from_slice(sample_buffer.samples()); - } - - Ok(samples) - } -} +#[path = "engine/tests.rs"] +mod output_backend_tests; diff --git a/src-tauri/src/audio/engine/asio.rs b/src-tauri/src/audio/engine/asio.rs new file mode 100644 index 000000000..dffeb0261 --- /dev/null +++ b/src-tauri/src/audio/engine/asio.rs @@ -0,0 +1,359 @@ +/// ASIO 기본 버퍼 크기(프레임). 미지정 시 이 값으로 고정 오픈 +#[cfg(any(test, all(windows, feature = "asio-backend")))] +const DEFAULT_BUFFER_FRAMES: u32 = 64; + +use super::{AudioSinkOpenError, AudioSinkResult, StreamHandler}; + +pub(super) const fn backend_available() -> bool { + cfg!(all(windows, feature = "asio-backend")) +} + +pub(super) fn normalize_driver_name(driver_name: &str) -> String { + driver_name.trim().to_string() +} + +pub(super) fn normalize_buffer_size(buffer_size: Option) -> Option { + buffer_size.filter(|frames| *frames > 0) +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn effective_buffer_frames(buffer_size: Option) -> u32 { + normalize_buffer_size(buffer_size).unwrap_or(DEFAULT_BUFFER_FRAMES) +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn is_valid_output_config(sample_rate: u32, channels: u16) -> bool { + sample_rate > 0 && channels > 0 +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn eligible_catalog_driver_name(name: &str, sample_rate: u32, channels: u16) -> Option { + let name = normalize_driver_name(name); + (!name.is_empty() && is_valid_output_config(sample_rate, channels)).then_some(name) +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn normalize_driver_names(names: impl IntoIterator) -> Vec { + let mut names = names + .into_iter() + .map(|name| normalize_driver_name(&name)) + .filter(|name| !name.is_empty()) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +#[cfg(any(test, all(windows, feature = "asio-backend")))] +fn open_audio_sink_with( + driver_name: &str, + buffer_size: Option, + devices: Result, + mut describe: impl FnMut(&Device) -> Result, + mut open: impl FnMut(Device, u32) -> AudioSinkResult, +) -> AudioSinkResult +where + Devices: IntoIterator, + DescriptionError: std::fmt::Display, +{ + let driver_name = normalize_driver_name(driver_name); + if driver_name.is_empty() { + return Err(AudioSinkOpenError::AsioDeviceNotFound); + } + + let devices = devices.map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; + for device in devices { + let name = match describe(&device) { + Ok(name) => normalize_driver_name(&name), + Err(err) => { + log::warn!("[KeySound] failed to read ASIO device name: {err}"); + continue; + } + }; + if name == driver_name { + return open(device, effective_buffer_frames(buffer_size)); + } + } + + Err(AudioSinkOpenError::AsioDeviceNotFound) +} + +#[cfg(all(windows, feature = "asio-backend"))] +pub(super) fn open_audio_sink( + driver_name: &str, + buffer_size: Option, +) -> AudioSinkResult { + use cpal::traits::{DeviceTrait, HostTrait}; + + // 빈 선택은 host 초기화보다 먼저 거부 - 장치가 없는 환경에서도 동일 오류 유지 + if normalize_driver_name(driver_name).is_empty() { + return Err(AudioSinkOpenError::AsioDeviceNotFound); + } + let host = cpal::host_from_id(cpal::HostId::Asio) + .map_err(|_| AudioSinkOpenError::AsioDeviceNotFound)?; + open_audio_sink_with( + driver_name, + buffer_size, + host.output_devices(), + |device| { + device + .description() + .map(|description| description.name().to_string()) + }, + try_open_asio_sink, + ) +} + +#[cfg(not(all(windows, feature = "asio-backend")))] +pub(super) fn open_audio_sink( + _driver_name: &str, + _buffer_size: Option, +) -> AudioSinkResult { + Err(AudioSinkOpenError::AsioUnavailableBuild) +} + +#[cfg(all(windows, feature = "asio-backend"))] +fn try_open_asio_sink(device: cpal::Device, buffer_size: u32) -> AudioSinkResult { + use cpal::traits::DeviceTrait; + use rodio::DeviceSinkBuilder; + + // 일부 드라이버(Realtek ASIO 등)는 클럭 미확립 상태에서 sample rate 0을 보고함. + // rodio from_device 내부의 NonZero unwrap 패닉(release는 abort) 방지를 위한 사전 검증 + let default_config = device + .default_output_config() + .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; + if !is_valid_output_config(default_config.sample_rate(), default_config.channels()) { + return Err(AudioSinkOpenError::OpenFailed(anyhow::anyhow!( + "ASIO 드라이버가 유효한 샘플레이트/채널 구성을 보고하지 않았습니다" + ))); + } + + let (error, callback) = super::stream_error_callback("ASIO stream"); + let sink = DeviceSinkBuilder::from_device(device) + .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))? + .with_error_callback(callback) + // 샘플레이트는 드라이버 현재값 유지, 버퍼만 명시 고정 + .with_buffer_size(cpal::BufferSize::Fixed(buffer_size)) + .open_stream() + .map_err(|err| AudioSinkOpenError::OpenFailed(anyhow::Error::new(err)))?; + + let config = sink.config(); + log::info!( + "[KeySound] ASIO 스트림 오픈: 요청 버퍼={}, 적용 sample_rate={}Hz, buffer={:?}", + buffer_size, + config.sample_rate().get(), + config.buffer_size() + ); + + Ok(StreamHandler { sink, error }) +} + +#[cfg(all(windows, feature = "asio-backend"))] +pub(super) fn list_drivers() -> Vec { + use cpal::traits::{DeviceTrait, HostTrait}; + + let Ok(host) = cpal::host_from_id(cpal::HostId::Asio) else { + return Vec::new(); + }; + let Ok(devices) = host.output_devices() else { + return Vec::new(); + }; + + normalize_driver_names(devices.filter_map(|device| { + let name = device + .description() + .ok() + .map(|description| normalize_driver_name(description.name())) + .filter(|name| !name.is_empty())?; + match device.default_output_config() { + Ok(config) => { + let eligible = eligible_catalog_driver_name( + &name, + config.sample_rate(), + config.channels(), + ); + if eligible.is_none() { + log::warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 유효하지 않은 샘플레이트/채널 보고"); + } + eligible + } + Err(err) => { + log::warn!("[KeySound] ASIO 드라이버 '{name}' 목록 제외: 기본 구성 조회 실패 ({err})"); + None + } + } + })) +} + +#[cfg(not(all(windows, feature = "asio-backend")))] +pub(super) fn list_drivers() -> Vec { + Vec::new() +} + +#[cfg(test)] +mod tests { + #[cfg(not(all(windows, feature = "asio-backend")))] + use super::open_audio_sink; + use super::{ + backend_available, effective_buffer_frames, eligible_catalog_driver_name, + is_valid_output_config, normalize_buffer_size, normalize_driver_name, + normalize_driver_names, open_audio_sink_with, AudioSinkOpenError, DEFAULT_BUFFER_FRAMES, + }; + + #[derive(Clone, Copy)] + struct FakeDevice { + id: u8, + description: Result<&'static str, &'static str>, + } + + #[test] + fn driver_name_normalization_trims_only_outer_whitespace() { + assert_eq!( + normalize_driver_name(" Focusrite USB ASIO "), + "Focusrite USB ASIO" + ); + assert_eq!(normalize_driver_name(" "), ""); + } + + #[test] + fn buffer_normalization_rejects_zero_and_preserves_explicit_frames() { + assert_eq!(normalize_buffer_size(None), None); + assert_eq!(normalize_buffer_size(Some(0)), None); + assert_eq!(normalize_buffer_size(Some(128)), Some(128)); + } + + #[test] + fn effective_buffer_uses_default_only_for_missing_or_zero_values() { + assert_eq!(effective_buffer_frames(None), DEFAULT_BUFFER_FRAMES); + assert_eq!(effective_buffer_frames(Some(0)), DEFAULT_BUFFER_FRAMES); + assert_eq!(effective_buffer_frames(Some(256)), 256); + } + + #[test] + fn output_config_requires_nonzero_sample_rate_and_channels() { + assert!(is_valid_output_config(48_000, 2)); + assert!(!is_valid_output_config(0, 2)); + assert!(!is_valid_output_config(48_000, 0)); + } + + #[test] + fn catalog_policy_requires_nonempty_name_and_valid_output_config() { + assert_eq!( + eligible_catalog_driver_name(" Focusrite USB ASIO ", 48_000, 2), + Some("Focusrite USB ASIO".to_string()) + ); + assert_eq!(eligible_catalog_driver_name(" ", 48_000, 2), None); + assert_eq!(eligible_catalog_driver_name("Focusrite", 0, 2), None); + assert_eq!(eligible_catalog_driver_name("Focusrite", 48_000, 0), None); + } + + #[test] + fn driver_catalog_is_trimmed_sorted_deduplicated_and_nonempty() { + assert_eq!( + normalize_driver_names([ + " Zebra ASIO ".to_string(), + "Alpha ASIO".to_string(), + "".to_string(), + "Alpha ASIO ".to_string(), + ]), + vec!["Alpha ASIO".to_string(), "Zebra ASIO".to_string()] + ); + } + + #[test] + fn backend_capability_matches_compile_configuration() { + assert_eq!( + backend_available(), + cfg!(all(windows, feature = "asio-backend")) + ); + } + + #[test] + fn injected_open_skips_description_errors_and_selects_exact_normalized_name() { + let devices = vec![ + FakeDevice { + id: 1, + description: Err("description failed"), + }, + FakeDevice { + id: 2, + description: Ok("Focusrite USB ASIO Extra"), + }, + FakeDevice { + id: 3, + description: Ok(" Focusrite USB ASIO "), + }, + ]; + let mut opened = Vec::new(); + + let result = open_audio_sink_with( + " Focusrite USB ASIO ", + Some(0), + Ok::<_, ()>(devices), + |device| device.description.map(str::to_string), + |device, buffer_frames| { + opened.push((device.id, buffer_frames)); + Ok(device.id) + }, + ); + + assert_eq!(result.expect("matching device should open"), 3); + assert_eq!(opened, vec![(3, DEFAULT_BUFFER_FRAMES)]); + } + + #[test] + fn injected_open_maps_catalog_failures_and_propagates_open_failure() { + let empty_name = open_audio_sink_with( + " ", + Some(128), + Err::, _>("must not inspect catalog"), + |device| device.description.map(str::to_string), + |_device, _buffer_frames| Ok(()), + ); + assert!(matches!( + empty_name, + Err(AudioSinkOpenError::AsioDeviceNotFound) + )); + + let unavailable = open_audio_sink_with( + "Focusrite USB ASIO", + Some(128), + Err::, _>("enumeration failed"), + |device| device.description.map(str::to_string), + |_device, _buffer_frames| Ok(()), + ); + assert!(matches!( + unavailable, + Err(AudioSinkOpenError::AsioDeviceNotFound) + )); + + let open_failed: Result<(), AudioSinkOpenError> = open_audio_sink_with( + "Focusrite USB ASIO", + Some(128), + Ok::<_, ()>(vec![FakeDevice { + id: 1, + description: Ok("Focusrite USB ASIO"), + }]), + |device| device.description.map(str::to_string), + |_device, buffer_frames| { + assert_eq!(buffer_frames, 128); + Err(AudioSinkOpenError::OpenFailed(anyhow::anyhow!( + "driver busy" + ))) + }, + ); + assert!(matches!( + open_failed, + Err(AudioSinkOpenError::OpenFailed(err)) if err.to_string() == "driver busy" + )); + } + + #[cfg(not(all(windows, feature = "asio-backend")))] + #[test] + fn unsupported_build_returns_the_typed_unavailable_error() { + assert!(matches!( + open_audio_sink("Focusrite USB ASIO", Some(64)), + Err(AudioSinkOpenError::AsioUnavailableBuild) + )); + } +} diff --git a/src-tauri/src/audio/engine/clips.rs b/src-tauri/src/audio/engine/clips.rs new file mode 100644 index 000000000..2ce0abbb7 --- /dev/null +++ b/src-tauri/src/audio/engine/clips.rs @@ -0,0 +1,392 @@ +use super::*; + +pub(super) fn get_or_load_cached_clip( + path: &str, + cache: &mut HashMap>, + measure_decode_ms: bool, +) -> Option<(Arc, ClipLoadTrace)> { + if let Some(cached) = cache.get(path) { + return Some(( + cached.clone(), + ClipLoadTrace { + cache_hit: true, + decode_ms: 0.0, + }, + )); + } + + #[cfg(debug_assertions)] + let decode_started_at = measure_decode_ms.then_some(Instant::now()); + #[cfg(not(debug_assertions))] + let _ = measure_decode_ms; + match decode_audio_file_clip(path) { + Ok(clip) => { + let shared = Arc::new(clip); + cache.insert(path.to_string(), shared.clone()); + Some(( + shared, + ClipLoadTrace { + cache_hit: false, + #[cfg(debug_assertions)] + decode_ms: decode_started_at + .map(|started| started.elapsed().as_secs_f64() * 1000.0) + .unwrap_or(0.0), + #[cfg(not(debug_assertions))] + decode_ms: 0.0, + }, + )) + } + Err(error) => { + warn!( + "[KeySound] failed to decode key sound file '{}': {error:#}", + path + ); + None + } + } +} + +fn decode_audio_file_clip(path: &str) -> Result { + let file = + File::open(path).with_context(|| format!("failed to open key sound file: {}", path))?; + let media_source = MediaSourceStream::new(Box::new(file), Default::default()); + let path_ref = Path::new(path); + + let mut hint = Hint::new(); + if let Some(ext) = path_ref.extension().and_then(|value| value.to_str()) { + hint.with_extension(ext); + } + + let probe = get_probe() + .format( + &hint, + media_source, + &FormatOptions::default(), + &Default::default(), + ) + .context("failed to probe key sound file format")?; + let mut format = probe.format; + let track = format + .default_track() + .context("no default track in key sound file")?; + let track_id = track.id; + let mut decoder = get_codecs() + .make(&track.codec_params, &Default::default()) + .context("failed to create key sound decoder")?; + + let mut channels = track + .codec_params + .channels + .map(|value| value.count() as u16); + let mut sample_rate = track.codec_params.sample_rate; + let mut samples: Vec = Vec::new(); + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::IoError(io_error)) + if io_error.kind() == ErrorKind::UnexpectedEof => + { + break; + } + Err(err) => { + return Err(anyhow::Error::new(err).context("failed to read key sound packet")); + } + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(SymphoniaError::DecodeError(_)) => continue, + Err(SymphoniaError::ResetRequired) => { + decoder.reset(); + continue; + } + Err(err) => { + return Err(anyhow::Error::new(err).context("failed to decode key sound packet")); + } + }; + + channels.get_or_insert(decoded.spec().channels.count() as u16); + sample_rate.get_or_insert(decoded.spec().rate); + + let mut sample_buffer = + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()); + sample_buffer.copy_interleaved_ref(decoded); + samples.extend_from_slice(sample_buffer.samples()); + } + + if samples.is_empty() { + anyhow::bail!("decoded sample buffer is empty"); + } + + let channels = channels.context("missing channel count in key sound file")?; + let sample_rate = sample_rate.context("missing sample rate in key sound file")?; + + Ok(CachedAudioClip { + samples: Arc::from(samples.into_boxed_slice()), + channels, + sample_rate, + }) +} + +#[derive(Debug)] +pub(super) struct LoadedSoundpack { + pub(super) segments: HashMap>, + fallback: Option>, + channels: u16, + sample_rate: u32, +} + +impl LoadedSoundpack { + pub(super) fn from_dir(soundpack_dir: &Path) -> Result { + let config_path = soundpack_dir.join("config.json"); + let config: SoundpackConfig = + serde_json::from_reader(File::open(&config_path).with_context(|| { + format!("failed to open soundpack config: {}", config_path.display()) + })?) + .context("failed to parse soundpack config.json")?; + + let audio_path = soundpack_dir.join(&config.audio_file); + let mut decoder = SoundDecoder::new(&audio_path)?; + let mut decoded_by_range: HashMap<(u64, u64), Arc<[f32]>> = HashMap::new(); + let mut segments = HashMap::new(); + + for (label, [start_ms, duration_ms]) in config.defines { + let cache_key = (start_ms, duration_ms); + let samples = if let Some(existing) = decoded_by_range.get(&cache_key) { + existing.clone() + } else { + let decoded = decoder.get_samples_buf(start_ms, duration_ms)?; + let shared: Arc<[f32]> = Arc::from(decoded.into_boxed_slice()); + decoded_by_range.insert(cache_key, shared.clone()); + shared + }; + segments.insert(normalize_label(&label), samples); + } + + let fallback = if let Some([start_ms, duration_ms]) = config.fallback { + let cache_key = (start_ms, duration_ms); + if let Some(existing) = decoded_by_range.get(&cache_key) { + Some(existing.clone()) + } else { + let decoded = decoder.get_samples_buf(start_ms, duration_ms)?; + let shared: Arc<[f32]> = Arc::from(decoded.into_boxed_slice()); + decoded_by_range.insert(cache_key, shared.clone()); + Some(shared) + } + } else { + None + }; + + Ok(Self { + segments, + fallback, + channels: decoder.channels, + sample_rate: decoder.sample_rate, + }) + } + + pub(super) fn source_for_labels(&self, labels: &[String]) -> Option { + for label in labels { + let normalized = normalize_label(label); + if let Some(samples) = self.segments.get(&normalized) { + return Some(AudioSource::new( + samples.clone(), + self.channels, + self.sample_rate, + )); + } + } + + self.fallback + .as_ref() + .map(|samples| AudioSource::new(samples.clone(), self.channels, self.sample_rate)) + } +} + +fn normalize_label(label: &str) -> String { + label.trim().to_ascii_uppercase() +} + +#[derive(Debug, Deserialize)] +struct SoundpackConfig { + #[serde(default = "default_audio_file")] + audio_file: String, + defines: HashMap, + #[serde(default)] + fallback: Option<[u64; 2]>, +} + +fn default_audio_file() -> String { + "sound.ogg".to_string() +} + +#[derive(Clone, Debug)] +pub(super) struct AudioSource { + samples: Arc<[f32]>, + channels: u16, + sample_rate: u32, + gain: f32, + pos: usize, +} + +/// 천장(1.0) 근처에서 부드럽게 수렴시키는 소프트 리미터 +/// knee 미만은 그대로 통과(일반 볼륨 무영향), 초과분만 1.0으로 압축 +fn soft_limit_sample(x: f32) -> f32 { + const KNEE: f32 = 0.95; + let mag = x.abs(); + if mag <= KNEE { + return x; + } + let over = (mag - KNEE) / (1.0 - KNEE); + let limited = KNEE + (1.0 - KNEE) * over.tanh(); + limited.copysign(x) +} + +impl AudioSource { + pub(super) fn new(samples: Arc<[f32]>, channels: u16, sample_rate: u32) -> Self { + Self { + samples, + channels, + sample_rate, + gain: 1.0, + pos: 0, + } + } + + pub(super) fn with_gain(mut self, gain: f32) -> Self { + self.gain = gain; + self + } +} + +impl Iterator for AudioSource { + type Item = f32; + + fn next(&mut self) -> Option { + let value = self.samples.get(self.pos)?; + self.pos += 1; + Some(soft_limit_sample(*value * self.gain)) + } +} + +impl Source for AudioSource { + fn current_span_len(&self) -> Option { + None + } + + fn channels(&self) -> NonZero { + NonZero::new(self.channels).expect("channels must be > 0") + } + + fn sample_rate(&self) -> NonZero { + NonZero::new(self.sample_rate).expect("sample_rate must be > 0") + } + + fn total_duration(&self) -> Option { + None + } +} + +struct SoundDecoder { + decoder: Box, + format: Box, + time_base: TimeBase, + sample_rate: u32, + channels: u16, +} + +impl SoundDecoder { + fn new(path: &Path) -> Result { + let file = File::open(path) + .with_context(|| format!("failed to open sound file: {}", path.display()))?; + let media_source = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|value| value.to_str()) { + hint.with_extension(ext); + } + + let probe = get_probe() + .format( + &hint, + media_source, + &FormatOptions::default(), + &Default::default(), + ) + .context("failed to probe sound file format")?; + let format = probe.format; + let track = format + .default_track() + .context("no default track in sound file")?; + let decoder = get_codecs() + .make(&track.codec_params, &Default::default()) + .context("failed to create audio decoder")?; + + let (sample_rate, channels, time_base) = { + let params = decoder.codec_params(); + ( + params + .sample_rate + .context("missing sample rate in sound file")?, + params + .channels + .map(|v| v.count() as u16) + .context("missing channels in sound file")?, + params + .time_base + .context("missing time base in sound file")?, + ) + }; + + Ok(Self { + decoder, + format, + time_base, + sample_rate, + channels, + }) + } + + fn get_samples_buf(&mut self, start_ms: u64, duration_ms: u64) -> Result> { + self.format + .seek( + SeekMode::Accurate, + SeekTo::Time { + track_id: None, + time: Duration::from_millis(start_ms).into(), + }, + ) + .context("failed to seek sound file")?; + self.decoder.reset(); + + let mut decoded_duration_ms = 0_u64; + let mut samples = Vec::new(); + + while decoded_duration_ms < duration_ms { + let packet = self + .format + .next_packet() + .context("failed to fetch audio packet")?; + + let packet_time = self.time_base.calc_time(packet.dur); + decoded_duration_ms += + ((packet_time.seconds as f64 + packet_time.frac) * 1000.0) as u64; + + let decoded = self + .decoder + .decode(&packet) + .context("failed to decode audio packet")?; + let mut sample_buffer = + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()); + sample_buffer.copy_interleaved_ref(decoded); + samples.extend_from_slice(sample_buffer.samples()); + } + + Ok(samples) + } +} diff --git a/src-tauri/src/audio/engine/runtime.rs b/src-tauri/src/audio/engine/runtime.rs new file mode 100644 index 000000000..d7a2185b7 --- /dev/null +++ b/src-tauri/src/audio/engine/runtime.rs @@ -0,0 +1,228 @@ +use super::*; + +pub(super) fn audio_thread( + receiver: Receiver, + state: Arc>, + fallback_callback: Arc< + dyn Fn(KeySoundOutputBackend, KeySoundOutputBackend) + Send + Sync + 'static, + >, +) { + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + use thread_priority::{set_current_thread_priority, ThreadPriority}; + let _ = set_current_thread_priority(ThreadPriority::Max); + } + + let mut enabled = state.read().status.enabled; + let mut volume = state.read().status.volume; + let mut soundpack = state.read().soundpack.clone(); + #[cfg(debug_assertions)] + let mut latency_logging = state.read().status.latency_logging; + let mut requested_backend = state.read().output_state.requested.clone(); + let mut stream_handler = None; + let output_state = open_initial_output_backend( + requested_backend.clone(), + &mut stream_handler, + fallback_callback.as_ref(), + ); + requested_backend = output_state.requested.clone(); + state.write().output_state = output_state; + let mut file_cache: HashMap> = HashMap::new(); + #[cfg(debug_assertions)] + let mut latency_summary = LatencySummary::default(); + + while let Ok(command) = receiver.recv() { + match command { + AudioCommand::SetEnabled(value) => { + enabled = value; + } + AudioCommand::SetVolume(value) => { + volume = value; + } + AudioCommand::SetLatencyLogging(value) => { + #[cfg(debug_assertions)] + { + latency_logging = value; + } + #[cfg(not(debug_assertions))] + { + let _ = value; + } + } + AudioCommand::SetSoundpack(pack) => { + soundpack = pack; + } + AudioCommand::InvalidateFileCache { path } => { + file_cache.remove(&path); + } + AudioCommand::SetOutputBackend { backend, reply } => { + let output_state = switch_output_backend(backend, &mut stream_handler); + requested_backend = output_state.requested.clone(); + state.write().output_state = output_state.clone(); + let _ = reply.send(output_state); + } + AudioCommand::PlayLabels { + labels, + queued_at, + trace, + } => { + if !enabled { + continue; + } + let Some(pack) = soundpack.as_ref() else { + continue; + }; + #[cfg(debug_assertions)] + let audio_started_at = latency_logging.then_some(Instant::now()); + let Some(source) = pack.source_for_labels(&labels) else { + continue; + }; + #[cfg(not(debug_assertions))] + let _ = (queued_at, trace); + + #[cfg(debug_assertions)] + let play_started_at = latency_logging.then_some(Instant::now()); + if !play_on_stream( + &mut stream_handler, + source, + volume, + &mut requested_backend, + &state, + fallback_callback.as_ref(), + ) { + continue; + } + #[cfg(debug_assertions)] + { + let play_ms = play_started_at + .map(|started| started.elapsed().as_secs_f64() * 1000.0) + .unwrap_or(0.0); + if latency_logging { + let queue_ms = queued_at.elapsed().as_secs_f64() * 1000.0; + let dispatch_ms = + trace.map(KeySoundDispatchTrace::dispatch_ms).unwrap_or(0.0); + let thread_ms = audio_started_at + .map(|started| started.elapsed().as_secs_f64() * 1000.0) + .unwrap_or(0.0); + let total_ms = trace + .map(KeySoundDispatchTrace::total_elapsed_ms) + .unwrap_or(dispatch_ms + queue_ms + thread_ms); + debug!( + "[KeySound][Latency] route=soundpack dispatchMs={dispatch_ms:.3} queueMs={queue_ms:.3} playMs={play_ms:.3} threadMs={thread_ms:.3} totalMs={total_ms:.3} labels={labels:?}" + ); + latency_summary.push( + LatencySample { + dispatch_ms, + queue_ms, + play_ms, + thread_ms, + total_ms, + ..Default::default() + }, + false, + ); + if latency_summary.should_emit_summary() { + latency_summary.emit_summary(); + } + } + } + } + AudioCommand::PlayFile { + path, + per_key_volume, + queued_at, + trace, + } => { + if !enabled { + continue; + } + #[cfg(debug_assertions)] + let audio_started_at = latency_logging.then_some(Instant::now()); + #[cfg(not(debug_assertions))] + let _ = (queued_at, trace); + + #[cfg(debug_assertions)] + let clip_lookup_started_at = latency_logging.then_some(Instant::now()); + let (clip, clip_load_trace) = + match get_or_load_cached_clip(&path, &mut file_cache, { + #[cfg(debug_assertions)] + { + latency_logging + } + #[cfg(not(debug_assertions))] + { + false + } + }) { + Some(result) => result, + None => continue, + }; + #[cfg(debug_assertions)] + let cache_lookup_ms = clip_lookup_started_at + .map(|started| started.elapsed().as_secs_f64() * 1000.0) + .unwrap_or(0.0); + #[cfg(not(debug_assertions))] + let _ = clip_load_trace; + + let final_volume = (volume * per_key_volume).clamp(0.0, 2.0); + let source = + AudioSource::new(clip.samples.clone(), clip.channels, clip.sample_rate); + + #[cfg(debug_assertions)] + let play_started_at = latency_logging.then_some(Instant::now()); + if !play_on_stream( + &mut stream_handler, + source, + final_volume, + &mut requested_backend, + &state, + fallback_callback.as_ref(), + ) { + continue; + } + #[cfg(debug_assertions)] + { + let play_ms = play_started_at + .map(|started| started.elapsed().as_secs_f64() * 1000.0) + .unwrap_or(0.0); + if latency_logging { + let queue_ms = queued_at.elapsed().as_secs_f64() * 1000.0; + let dispatch_ms = + trace.map(KeySoundDispatchTrace::dispatch_ms).unwrap_or(0.0); + let thread_ms = audio_started_at + .map(|started| started.elapsed().as_secs_f64() * 1000.0) + .unwrap_or(0.0); + let total_ms = trace + .map(KeySoundDispatchTrace::total_elapsed_ms) + .unwrap_or(dispatch_ms + queue_ms + thread_ms); + let cache_label = if clip_load_trace.cache_hit { + "hit" + } else { + "miss" + }; + debug!( + "[KeySound][Latency] route=key-file dispatchMs={dispatch_ms:.3} queueMs={queue_ms:.3} cacheLookupMs={cache_lookup_ms:.3} decodeMs={:.3} playMs={play_ms:.3} threadMs={thread_ms:.3} totalMs={total_ms:.3} cache={} volume={final_volume:.3} path={path}", + clip_load_trace.decode_ms, + cache_label + ); + latency_summary.push( + LatencySample { + dispatch_ms, + queue_ms, + cache_lookup_ms, + decode_ms: clip_load_trace.decode_ms, + play_ms, + thread_ms, + total_ms, + }, + !clip_load_trace.cache_hit, + ); + if latency_summary.should_emit_summary() { + latency_summary.emit_summary(); + } + } + } + } + } + } +} diff --git a/src-tauri/src/audio/engine/tests.rs b/src-tauri/src/audio/engine/tests.rs new file mode 100644 index 000000000..7eecd370b --- /dev/null +++ b/src-tauri/src/audio/engine/tests.rs @@ -0,0 +1,187 @@ +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; + +use super::{ + asio_output_error_code, asio_output_error_message, open_initial_output_backend, + open_system_device_audio_sink, output_fallback_error, startup_fallback_forgets, + switch_output_backend_with_notification, AudioSinkOpenError, KeySoundEngine, + KeySoundOutputBackend, ERROR_CODE_ASIO_DEVICE_NOT_FOUND, ERROR_CODE_ASIO_OPEN_FAILED, + ERROR_CODE_ASIO_UNAVAILABLE_BUILD, ERROR_CODE_DEFAULT_OPEN_FAILED, ERROR_CODE_DEVICE_NOT_FOUND, + ERROR_CODE_DEVICE_OPEN_FAILED, +}; + +#[test] +fn device_backend_normalizes_id_and_name() { + assert_eq!( + KeySoundOutputBackend::Device { + id: " coreaudio:device-id ".to_string(), + name: " Speakers ".to_string(), + } + .normalized(), + KeySoundOutputBackend::Device { + id: "coreaudio:device-id".to_string(), + name: "Speakers".to_string(), + } + ); +} + +#[test] +fn asio_backend_normalizes_driver_name_and_zero_buffer() { + assert_eq!( + KeySoundOutputBackend::Asio { + driver_name: " Focusrite USB ASIO ".to_string(), + buffer_size: Some(0), + } + .normalized(), + KeySoundOutputBackend::Asio { + driver_name: "Focusrite USB ASIO".to_string(), + buffer_size: None, + } + ); +} + +#[test] +fn asio_error_contract_distinguishes_build_device_and_open_failures() { + let cases = [ + ( + AudioSinkOpenError::AsioUnavailableBuild, + ERROR_CODE_ASIO_UNAVAILABLE_BUILD, + "ASIO 미지원 빌드", + ), + ( + AudioSinkOpenError::AsioDeviceNotFound, + ERROR_CODE_ASIO_DEVICE_NOT_FOUND, + "ASIO 장치를 찾을 수 없습니다", + ), + ( + AudioSinkOpenError::DeviceNotFound, + ERROR_CODE_DEVICE_NOT_FOUND, + "출력 장치를 찾을 수 없습니다", + ), + ]; + + for (error, expected_code, expected_message) in cases { + assert_eq!(asio_output_error_code(&error), expected_code); + assert_eq!(asio_output_error_message(&error), expected_message); + } + + let open_error = AudioSinkOpenError::OpenFailed(anyhow::anyhow!("driver busy")); + assert_eq!( + asio_output_error_code(&open_error), + ERROR_CODE_ASIO_OPEN_FAILED + ); + assert_eq!( + asio_output_error_message(&open_error), + "ASIO 장치를 열 수 없어 기본 출력으로 재생합니다" + ); + + let backend = KeySoundOutputBackend::Asio { + driver_name: "Focusrite USB ASIO".to_string(), + buffer_size: Some(64), + }; + assert_eq!( + output_fallback_error(&backend, &open_error), + ( + "ASIO 장치를 열 수 없어 기본 출력으로 재생합니다".to_string(), + ERROR_CODE_ASIO_OPEN_FAILED.to_string(), + ) + ); +} + +#[test] +fn invalid_device_id_is_reported_as_not_found() { + let result = open_system_device_audio_sink("invalid-device-id", "Speakers"); + assert!(matches!(result, Err(AudioSinkOpenError::DeviceNotFound))); +} + +#[test] +fn failed_device_selection_forgets_requested_backend_and_notifies() { + let mut stream_handler = None; + let notified = AtomicBool::new(false); + let fallback_callback = |failed, settled| { + assert_eq!( + failed, + KeySoundOutputBackend::Device { + id: "invalid-device-id".to_string(), + name: "Speakers".to_string(), + } + ); + assert_eq!(settled, KeySoundOutputBackend::DefaultDevice); + notified.store(true, Ordering::Relaxed); + }; + let output_state = switch_output_backend_with_notification( + KeySoundOutputBackend::Device { + id: "invalid-device-id".to_string(), + name: "Speakers".to_string(), + }, + &mut stream_handler, + &fallback_callback, + ); + + assert_eq!(output_state.requested, KeySoundOutputBackend::DefaultDevice); + assert!(notified.load(Ordering::Relaxed)); + if let Some(handler) = stream_handler.as_mut() { + handler.sink.log_on_drop(false); + } +} + +#[test] +fn startup_fallback_forgets_only_missing_devices() { + assert!(startup_fallback_forgets(ERROR_CODE_DEVICE_NOT_FOUND)); + assert!(startup_fallback_forgets(ERROR_CODE_ASIO_DEVICE_NOT_FOUND)); + assert!(!startup_fallback_forgets(ERROR_CODE_ASIO_OPEN_FAILED)); + assert!(!startup_fallback_forgets(ERROR_CODE_DEVICE_OPEN_FAILED)); + assert!(!startup_fallback_forgets(ERROR_CODE_DEFAULT_OPEN_FAILED)); + assert!(!startup_fallback_forgets(ERROR_CODE_ASIO_UNAVAILABLE_BUILD)); +} + +// 기동 시 장치 부재는 런타임과 같이 forget - 저장값이 기본 장치로 덮인다 +#[test] +fn startup_forgets_missing_device_and_notifies() { + let mut stream_handler = None; + let notified = Arc::new(AtomicBool::new(false)); + let notified_for_callback = Arc::clone(¬ified); + let fallback_callback = move |_failed, _settled| { + notified_for_callback.store(true, Ordering::Relaxed); + }; + let output_state = open_initial_output_backend( + KeySoundOutputBackend::Device { + id: "invalid-device-id".to_string(), + name: "Speakers".to_string(), + }, + &mut stream_handler, + &fallback_callback, + ); + + if let Some(handler) = stream_handler.as_mut() { + handler.sink.log_on_drop(false); + } + // 기본 장치조차 열 수 없는 환경(헤드리스 CI)에서는 폴백 판정 자체가 성립하지 않는다 + if output_state.error_code.as_deref() == Some(ERROR_CODE_DEFAULT_OPEN_FAILED) { + return; + } + assert_eq!(output_state.requested, KeySoundOutputBackend::DefaultDevice); + assert!(notified.load(Ordering::Relaxed)); +} + +#[test] +fn set_output_backend_does_not_notify_fallback_callback() { + let callback_count = Arc::new(AtomicUsize::new(0)); + let callback_count_for_engine = Arc::clone(&callback_count); + let engine = KeySoundEngine::with_output_backend( + KeySoundOutputBackend::DefaultDevice, + Arc::new(move |_, _| { + callback_count_for_engine.fetch_add(1, Ordering::Relaxed); + }), + ); + + let output_state = engine.set_output_backend(KeySoundOutputBackend::Device { + id: "invalid-device-id".to_string(), + name: "Speakers".to_string(), + }); + + assert_eq!(output_state.requested, KeySoundOutputBackend::DefaultDevice); + assert_eq!(callback_count.load(Ordering::Relaxed), 0); +} diff --git a/src-tauri/src/commands/editor/css.rs b/src-tauri/src/commands/editor/css.rs index f91df92cb..b967b9970 100644 --- a/src-tauri/src/commands/editor/css.rs +++ b/src-tauri/src/commands/editor/css.rs @@ -1,18 +1,10 @@ use std::{ - io::Read, - net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs}, path::{Path, PathBuf}, - sync::{Arc, OnceLock}, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{SystemTime, UNIX_EPOCH}, }; -use reqwest::{ - blocking::Client, - header::{ACCEPT, LOCATION}, -}; use serde::Serialize; use tauri::{AppHandle, Manager, WebviewWindow}; -use tokio::sync::Semaphore; use crate::{ commands::{ @@ -34,12 +26,14 @@ use crate::{ }, }; -const CSS_IMPORT_FETCH_TIMEOUT: Duration = Duration::from_secs(5); -const MAX_CONCURRENT_CSS_IMPORT_FETCHES: usize = 4; -const MAX_CSS_IMPORT_BYTES: usize = 1024 * 1024; -const MAX_CSS_IMPORT_REDIRECTS: usize = 3; -const CSS_IMPORT_USER_AGENT: &str = concat!("DmNote/", env!("CARGO_PKG_VERSION")); -static CSS_IMPORT_FETCH_LIMIT: OnceLock> = OnceLock::new(); +mod import_fetch; +pub use import_fetch::*; + +#[cfg(test)] +use import_fetch::{ + ensure_css_import_window, fetch_css_import, is_cloud_metadata_ip, read_css_import_body, + resolve_css_import_addrs, validate_css_import_url, MAX_CSS_IMPORT_BYTES, +}; /// OBS 브릿지에 CSS 설정 변경을 settings_diff로 전달 (전체 스냅샷 브로드캐스트 방지) fn notify_obs_css(state: &AppState) { @@ -76,193 +70,6 @@ pub struct CssLoadResponse { pub path: Option, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CssImportFetchResult { - pub final_url: String, - pub text: String, -} - -// 클라우드 메타데이터 주소 - IPv4-mapped IPv6 표기(::ffff:a9fe:a9fe)도 같은 주소로 본다 -fn is_cloud_metadata_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(ip) => matches!( - ip.octets(), - [169, 254, 169, 254] | [169, 254, 170, 2] | [100, 100, 100, 200] | [192, 0, 0, 192] - ), - IpAddr::V6(ip) => match ip.to_ipv4_mapped() { - Some(mapped) => is_cloud_metadata_ip(IpAddr::V4(mapped)), - None => ip == Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254), - }, - } -} - -fn is_cloud_metadata_url(url: &url::Url) -> bool { - match url.host() { - Some(url::Host::Domain(host)) => matches!( - host.trim_end_matches('.').to_ascii_lowercase().as_str(), - "metadata.google.internal" | "metadata.azure.internal" - ), - Some(url::Host::Ipv4(ip)) => is_cloud_metadata_ip(IpAddr::V4(ip)), - Some(url::Host::Ipv6(ip)) => is_cloud_metadata_ip(IpAddr::V6(ip)), - None => false, - } -} - -// 호스트를 먼저 해석해 메타데이터 주소로 향하는 DNS 이름·리바인딩을 차단하고, -// 실제 요청은 검사한 주소에만 고정한다 -fn resolve_css_import_addrs(url: &url::Url) -> CmdResult> { - let port = url - .port_or_known_default() - .ok_or_else(|| CommandError::msg("CSS import URL has no port"))?; - let addrs: Vec = match url.host() { - Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(IpAddr::V4(ip), port)], - Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(IpAddr::V6(ip), port)], - Some(url::Host::Domain(domain)) => (domain.trim_end_matches('.'), port) - .to_socket_addrs() - .map_err(|error| { - CommandError::msg(format!( - "failed to resolve CSS import host '{domain}': {error}" - )) - })? - .collect(), - None => return Err(CommandError::msg("CSS import URL has no host")), - }; - if addrs.is_empty() { - return Err(CommandError::msg( - "CSS import host resolved to no addresses", - )); - } - if addrs.iter().any(|addr| is_cloud_metadata_ip(addr.ip())) { - return Err(CommandError::msg( - "CSS import access to cloud metadata endpoints is blocked", - )); - } - Ok(addrs) -} - -fn validate_css_import_url(raw: &str) -> CmdResult { - let url = url::Url::parse(raw) - .map_err(|error| CommandError::msg(format!("invalid CSS import URL: {error}")))?; - if !matches!(url.scheme(), "http" | "https") { - return Err(CommandError::msg(format!( - "unsupported CSS import URL scheme '{}': only http and https are allowed", - url.scheme() - ))); - } - if is_cloud_metadata_url(&url) { - return Err(CommandError::msg( - "CSS import access to cloud metadata endpoints is blocked", - )); - } - Ok(url) -} - -fn read_css_import_body(reader: impl Read) -> CmdResult> { - let mut bytes = Vec::new(); - reader - .take((MAX_CSS_IMPORT_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|error| { - CommandError::msg(format!("failed to read CSS import response: {error}")) - })?; - if bytes.len() > MAX_CSS_IMPORT_BYTES { - return Err(CommandError::msg(format!( - "CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes" - ))); - } - Ok(bytes) -} - -fn ensure_css_import_window(window_label: &str) -> CmdResult<()> { - if window_label != super::MAIN_WINDOW_LABEL { - return Err(CommandError::msg( - "CSS import fetch is only available in the main window", - )); - } - Ok(()) -} - -fn css_import_fetch_limit() -> &'static Arc { - CSS_IMPORT_FETCH_LIMIT - .get_or_init(|| Arc::new(Semaphore::new(MAX_CONCURRENT_CSS_IMPORT_FETCHES))) -} - -// hop마다 검증·해석·주소 고정이 필요하므로 리다이렉트는 직접 따라간다 -fn css_import_client(url: &url::Url, pinned: &[SocketAddr]) -> CmdResult { - let mut builder = Client::builder() - .timeout(CSS_IMPORT_FETCH_TIMEOUT) - .user_agent(CSS_IMPORT_USER_AGENT) - .redirect(reqwest::redirect::Policy::none()); - if let Some(url::Host::Domain(domain)) = url.host() { - builder = builder.resolve_to_addrs(domain, pinned); - } - builder.build().map_err(|error| { - CommandError::msg(format!("failed to initialize CSS import client: {error}")) - }) -} - -fn fetch_css_import(url: String) -> CmdResult { - let mut url = validate_css_import_url(&url)?; - for _ in 0..=MAX_CSS_IMPORT_REDIRECTS { - let pinned = resolve_css_import_addrs(&url)?; - let response = css_import_client(&url, &pinned)? - .get(url.clone()) - .header(ACCEPT, "text/css,*/*;q=0.1") - .send() - .map_err(|error| CommandError::msg(format!("failed to fetch CSS import: {error}")))?; - if response.status().is_redirection() { - let location = response - .headers() - .get(LOCATION) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| CommandError::msg("CSS import redirect without Location header"))?; - let next = url - .join(location) - .map_err(|error| CommandError::msg(format!("error following redirect: {error}")))?; - url = validate_css_import_url(next.as_str()) - .map_err(|error| CommandError::msg(format!("error following redirect: {error}")))?; - continue; - } - let response = response - .error_for_status() - .map_err(|error| CommandError::msg(format!("CSS import request failed: {error}")))?; - if response - .content_length() - .is_some_and(|length| length > MAX_CSS_IMPORT_BYTES as u64) - { - return Err(CommandError::msg(format!( - "CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes" - ))); - } - let final_url = response.url().to_string(); - let bytes = read_css_import_body(response)?; - return Ok(CssImportFetchResult { - final_url, - text: String::from_utf8_lossy(&bytes).into_owned(), - }); - } - Err(CommandError::msg("CSS import exceeded redirect limit")) -} - -#[tauri::command] -pub async fn css_fetch_import( - window: WebviewWindow, - url: String, -) -> CmdResult { - ensure_css_import_window(window.label())?; - let permit = Arc::clone(css_import_fetch_limit()) - .acquire_owned() - .await - .map_err(|error| CommandError::msg(format!("CSS import fetch limit closed: {error}")))?; - tauri::async_runtime::spawn_blocking(move || { - let _permit = permit; - fetch_css_import(url) - }) - .await - .map_err(|error| CommandError::msg(format!("CSS import fetch task failed: {error}")))? -} - #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct CustomCssHistoryItem { @@ -1251,273 +1058,4 @@ fn css_tab_export_from_path( } #[cfg(test)] -mod tests { - use super::{ - ensure_css_extension, ensure_css_import_window, fetch_css_import, is_cloud_metadata_ip, - prepare_tab_css_for_set_with, read_css_import_body, replace_tab_css_override, - resolve_css_import_addrs, validate_css_import_url, write_tab_css_export, - MAX_CSS_IMPORT_BYTES, - }; - use crate::models::{AppStoreData, TabCss}; - use parking_lot::Mutex; - use std::{ - fs, - path::Path, - sync::{mpsc, Arc}, - thread, - }; - - fn test_directory(label: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "dmnote-css-command-{label}-{}", - uuid::Uuid::new_v4() - )) - } - - #[test] - fn css_import_url_accepts_only_http_and_https() { - assert_eq!( - validate_css_import_url("https://example.com/theme.css") - .unwrap() - .scheme(), - "https" - ); - assert_eq!( - validate_css_import_url("http://example.com/theme.css") - .unwrap() - .scheme(), - "http" - ); - for invalid in ["file:///tmp/theme.css", "data:text/css,body{}", "theme.css"] { - assert!(validate_css_import_url(invalid).is_err(), "{invalid}"); - } - } - - #[test] - fn css_import_fetch_is_limited_to_the_main_window() { - ensure_css_import_window(super::super::MAIN_WINDOW_LABEL).unwrap(); - for label in ["overlay", "panel"] { - assert_eq!( - ensure_css_import_window(label).unwrap_err().to_string(), - "CSS import fetch is only available in the main window" - ); - } - } - - #[test] - fn css_import_blocks_only_explicit_metadata_targets() { - for allowed in [ - "http://127.0.0.1:5500/theme.css", - "http://10.0.0.1/theme.css", - "https://example.com/theme.css", - ] { - validate_css_import_url(allowed).unwrap(); - } - for blocked in [ - "http://169.254.169.254/latest/meta-data", - "http://169.254.170.2/v2/credentials", - "http://100.100.100.200/latest/meta-data", - "http://192.0.0.192/metadata", - "http://[fd00:ec2::254]/latest/meta-data", - "http://metadata.google.internal/computeMetadata/v1", - "http://metadata.azure.internal/metadata/instance", - ] { - assert!(validate_css_import_url(blocked).is_err(), "{blocked}"); - } - } - - #[test] - fn css_import_blocks_ipv4_mapped_metadata_literals_and_pins_resolved_addresses() { - assert!( - validate_css_import_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err() - ); - assert!(is_cloud_metadata_ip( - "::ffff:169.254.169.254".parse().unwrap() - )); - assert!(is_cloud_metadata_ip("fd00:ec2::254".parse().unwrap())); - assert!(!is_cloud_metadata_ip("::1".parse().unwrap())); - assert!(!is_cloud_metadata_ip("127.0.0.1".parse().unwrap())); - - let literal = url::Url::parse("http://127.0.0.1:5500/theme.css").unwrap(); - assert_eq!( - resolve_css_import_addrs(&literal).unwrap(), - vec!["127.0.0.1:5500".parse::().unwrap()] - ); - let mapped = url::Url::parse("http://[::ffff:169.254.169.254]/latest").unwrap(); - assert!(resolve_css_import_addrs(&mapped).is_err()); - } - - #[test] - fn css_import_follows_local_redirect_without_prompt() { - use std::io::{Read as _, Write as _}; - use std::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let server = thread::spawn(move || { - for request_index in 0..2 { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0_u8; 2_048]; - let _ = stream.read(&mut request).unwrap(); - if request_index == 0 { - stream - .write_all( - b"HTTP/1.1 302 Found\r\nLocation: /theme.css\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .unwrap(); - } else { - stream - .write_all( - b"HTTP/1.1 200 OK\r\nContent-Length: 22\r\nConnection: close\r\n\r\n.counter { color:red }", - ) - .unwrap(); - } - } - }); - let fetched = fetch_css_import(format!("http://{address}/start.css")).unwrap(); - - server.join().unwrap(); - assert_eq!(fetched.final_url, format!("http://{address}/theme.css")); - assert_eq!(fetched.text, ".counter { color:red }"); - } - - #[test] - fn css_import_blocks_metadata_redirect_before_requesting_it() { - use std::io::{Read as _, Write as _}; - use std::net::TcpListener; - - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap(); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0_u8; 2_048]; - let _ = stream.read(&mut request).unwrap(); - stream - .write_all( - b"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .unwrap(); - }); - - let error = fetch_css_import(format!("http://{address}/start.css")).unwrap_err(); - - server.join().unwrap(); - assert!( - error.to_string().contains("error following redirect"), - "unexpected redirect error: {error}" - ); - } - - #[test] - fn css_import_body_enforces_the_one_mibibyte_limit() { - let accepted = vec![b'a'; MAX_CSS_IMPORT_BYTES]; - assert_eq!( - read_css_import_body(std::io::Cursor::new(accepted.clone())).unwrap(), - accepted - ); - - let rejected = vec![b'a'; MAX_CSS_IMPORT_BYTES + 1]; - assert_eq!( - read_css_import_body(std::io::Cursor::new(rejected)) - .unwrap_err() - .to_string(), - format!("CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes") - ); - } - - #[test] - fn unauthorized_tab_set_preserves_content_without_persisting_path() { - let css = TabCss { - path: Some("/tmp/not-authorized.css".to_string()), - content: "preserved".to_string(), - enabled: true, - }; - - let prepared = prepare_tab_css_for_set_with(css, |_| false); - - assert_eq!(prepared.path, None); - assert_eq!(prepared.content, "preserved"); - assert!(prepared.enabled); - } - - #[test] - fn authorized_tab_set_persists_the_canonical_path() { - let root = test_directory("authorized"); - fs::create_dir_all(&root).unwrap(); - let path = root.join("theme.css"); - fs::write(&path, "body {}").unwrap(); - let css = TabCss { - path: Some(path.to_string_lossy().to_string()), - content: "preserved".to_string(), - enabled: true, - }; - - let prepared = prepare_tab_css_for_set_with(css, |_| true); - - let canonical = fs::canonicalize(&path) - .unwrap() - .to_string_lossy() - .to_string(); - assert_eq!(prepared.path.as_deref(), Some(canonical.as_str())); - assert_eq!(prepared.content, "preserved"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn export_corrects_extension_and_atomically_replaces_existing_file() { - let root = test_directory("export"); - fs::create_dir_all(&root).unwrap(); - let selected = root.join("theme.txt"); - let export = ensure_css_extension(selected); - fs::write(&export, "old").unwrap(); - - write_tab_css_export(&export, "new").unwrap(); - - assert_eq!( - export.extension().and_then(|value| value.to_str()), - Some("css") - ); - assert_eq!(fs::read_to_string(&export).unwrap(), "new"); - assert_eq!(fs::read_dir(&root).unwrap().count(), 1); - assert!(Path::new(&export).is_file()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn clear_waiting_on_activation_lock_wins_with_the_last_commit() { - let operation_lock = Arc::new(Mutex::new(())); - let store = Arc::new(Mutex::new(AppStoreData::default())); - let (locked_tx, locked_rx) = mpsc::channel(); - let (continue_tx, continue_rx) = mpsc::channel(); - - let activation_lock = operation_lock.clone(); - let activation_store = store.clone(); - let activate = thread::spawn(move || { - let _guard = activation_lock.lock(); - locked_tx.send(()).unwrap(); - continue_rx.recv().unwrap(); - replace_tab_css_override( - &mut activation_store.lock(), - "4key", - Some(TabCss { - path: Some("/tmp/theme.css".to_string()), - content: "active".to_string(), - enabled: true, - }), - ); - }); - locked_rx.recv().unwrap(); - - let clear_lock = operation_lock.clone(); - let clear_store = store.clone(); - let clear = thread::spawn(move || { - let _guard = clear_lock.lock(); - replace_tab_css_override(&mut clear_store.lock(), "4key", None); - }); - continue_tx.send(()).unwrap(); - activate.join().unwrap(); - clear.join().unwrap(); - - assert!(!store.lock().tab_css_overrides.contains_key("4key")); - } -} +mod tests; diff --git a/src-tauri/src/commands/editor/css/import_fetch.rs b/src-tauri/src/commands/editor/css/import_fetch.rs new file mode 100644 index 000000000..0bc44a5f9 --- /dev/null +++ b/src-tauri/src/commands/editor/css/import_fetch.rs @@ -0,0 +1,210 @@ +use std::{ + io::Read, + net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs}, + sync::{Arc, OnceLock}, + time::Duration, +}; + +use reqwest::{ + blocking::Client, + header::{ACCEPT, LOCATION}, +}; +use serde::Serialize; +use tauri::WebviewWindow; +use tokio::sync::Semaphore; + +use crate::errors::{CmdResult, CommandError}; + +const CSS_IMPORT_FETCH_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_CONCURRENT_CSS_IMPORT_FETCHES: usize = 4; +pub(super) const MAX_CSS_IMPORT_BYTES: usize = 1024 * 1024; +const MAX_CSS_IMPORT_REDIRECTS: usize = 3; +const CSS_IMPORT_USER_AGENT: &str = concat!("DmNote/", env!("CARGO_PKG_VERSION")); +static CSS_IMPORT_FETCH_LIMIT: OnceLock> = OnceLock::new(); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CssImportFetchResult { + pub final_url: String, + pub text: String, +} + +// 클라우드 메타데이터 주소 - IPv4-mapped IPv6 표기(::ffff:a9fe:a9fe)도 같은 주소로 본다 +pub(super) fn is_cloud_metadata_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => matches!( + ip.octets(), + [169, 254, 169, 254] | [169, 254, 170, 2] | [100, 100, 100, 200] | [192, 0, 0, 192] + ), + IpAddr::V6(ip) => match ip.to_ipv4_mapped() { + Some(mapped) => is_cloud_metadata_ip(IpAddr::V4(mapped)), + None => ip == Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254), + }, + } +} + +fn is_cloud_metadata_url(url: &url::Url) -> bool { + match url.host() { + Some(url::Host::Domain(host)) => matches!( + host.trim_end_matches('.').to_ascii_lowercase().as_str(), + "metadata.google.internal" | "metadata.azure.internal" + ), + Some(url::Host::Ipv4(ip)) => is_cloud_metadata_ip(IpAddr::V4(ip)), + Some(url::Host::Ipv6(ip)) => is_cloud_metadata_ip(IpAddr::V6(ip)), + None => false, + } +} + +// 호스트를 먼저 해석해 메타데이터 주소로 향하는 DNS 이름·리바인딩을 차단하고, +// 실제 요청은 검사한 주소에만 고정한다 +pub(super) fn resolve_css_import_addrs(url: &url::Url) -> CmdResult> { + let port = url + .port_or_known_default() + .ok_or_else(|| CommandError::msg("CSS import URL has no port"))?; + let addrs: Vec = match url.host() { + Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(IpAddr::V4(ip), port)], + Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(IpAddr::V6(ip), port)], + Some(url::Host::Domain(domain)) => (domain.trim_end_matches('.'), port) + .to_socket_addrs() + .map_err(|error| { + CommandError::msg(format!( + "failed to resolve CSS import host '{domain}': {error}" + )) + })? + .collect(), + None => return Err(CommandError::msg("CSS import URL has no host")), + }; + if addrs.is_empty() { + return Err(CommandError::msg( + "CSS import host resolved to no addresses", + )); + } + if addrs.iter().any(|addr| is_cloud_metadata_ip(addr.ip())) { + return Err(CommandError::msg( + "CSS import access to cloud metadata endpoints is blocked", + )); + } + Ok(addrs) +} + +pub(super) fn validate_css_import_url(raw: &str) -> CmdResult { + let url = url::Url::parse(raw) + .map_err(|error| CommandError::msg(format!("invalid CSS import URL: {error}")))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(CommandError::msg(format!( + "unsupported CSS import URL scheme '{}': only http and https are allowed", + url.scheme() + ))); + } + if is_cloud_metadata_url(&url) { + return Err(CommandError::msg( + "CSS import access to cloud metadata endpoints is blocked", + )); + } + Ok(url) +} + +pub(super) fn read_css_import_body(reader: impl Read) -> CmdResult> { + let mut bytes = Vec::new(); + reader + .take((MAX_CSS_IMPORT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| { + CommandError::msg(format!("failed to read CSS import response: {error}")) + })?; + if bytes.len() > MAX_CSS_IMPORT_BYTES { + return Err(CommandError::msg(format!( + "CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes" + ))); + } + Ok(bytes) +} + +pub(super) fn ensure_css_import_window(window_label: &str) -> CmdResult<()> { + if window_label != super::super::MAIN_WINDOW_LABEL { + return Err(CommandError::msg( + "CSS import fetch is only available in the main window", + )); + } + Ok(()) +} + +fn css_import_fetch_limit() -> &'static Arc { + CSS_IMPORT_FETCH_LIMIT + .get_or_init(|| Arc::new(Semaphore::new(MAX_CONCURRENT_CSS_IMPORT_FETCHES))) +} + +// hop마다 검증·해석·주소 고정이 필요하므로 리다이렉트는 직접 따라간다 +fn css_import_client(url: &url::Url, pinned: &[SocketAddr]) -> CmdResult { + let mut builder = Client::builder() + .timeout(CSS_IMPORT_FETCH_TIMEOUT) + .user_agent(CSS_IMPORT_USER_AGENT) + .redirect(reqwest::redirect::Policy::none()); + if let Some(url::Host::Domain(domain)) = url.host() { + builder = builder.resolve_to_addrs(domain, pinned); + } + builder.build().map_err(|error| { + CommandError::msg(format!("failed to initialize CSS import client: {error}")) + }) +} + +pub(super) fn fetch_css_import(url: String) -> CmdResult { + let mut url = validate_css_import_url(&url)?; + for _ in 0..=MAX_CSS_IMPORT_REDIRECTS { + let pinned = resolve_css_import_addrs(&url)?; + let response = css_import_client(&url, &pinned)? + .get(url.clone()) + .header(ACCEPT, "text/css,*/*;q=0.1") + .send() + .map_err(|error| CommandError::msg(format!("failed to fetch CSS import: {error}")))?; + if response.status().is_redirection() { + let location = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| CommandError::msg("CSS import redirect without Location header"))?; + let next = url + .join(location) + .map_err(|error| CommandError::msg(format!("error following redirect: {error}")))?; + url = validate_css_import_url(next.as_str()) + .map_err(|error| CommandError::msg(format!("error following redirect: {error}")))?; + continue; + } + let response = response + .error_for_status() + .map_err(|error| CommandError::msg(format!("CSS import request failed: {error}")))?; + if response + .content_length() + .is_some_and(|length| length > MAX_CSS_IMPORT_BYTES as u64) + { + return Err(CommandError::msg(format!( + "CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes" + ))); + } + let final_url = response.url().to_string(); + let bytes = read_css_import_body(response)?; + return Ok(CssImportFetchResult { + final_url, + text: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + Err(CommandError::msg("CSS import exceeded redirect limit")) +} + +#[tauri::command] +pub async fn css_fetch_import( + window: WebviewWindow, + url: String, +) -> CmdResult { + ensure_css_import_window(window.label())?; + let permit = Arc::clone(css_import_fetch_limit()) + .acquire_owned() + .await + .map_err(|error| CommandError::msg(format!("CSS import fetch limit closed: {error}")))?; + tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + fetch_css_import(url) + }) + .await + .map_err(|error| CommandError::msg(format!("CSS import fetch task failed: {error}")))? +} diff --git a/src-tauri/src/commands/editor/css/tests.rs b/src-tauri/src/commands/editor/css/tests.rs new file mode 100644 index 000000000..0a936d0d4 --- /dev/null +++ b/src-tauri/src/commands/editor/css/tests.rs @@ -0,0 +1,265 @@ +use super::{ + ensure_css_extension, ensure_css_import_window, fetch_css_import, is_cloud_metadata_ip, + prepare_tab_css_for_set_with, read_css_import_body, replace_tab_css_override, + resolve_css_import_addrs, validate_css_import_url, write_tab_css_export, MAX_CSS_IMPORT_BYTES, +}; +use crate::models::{AppStoreData, TabCss}; +use parking_lot::Mutex; +use std::{ + fs, + path::Path, + sync::{mpsc, Arc}, + thread, +}; + +fn test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "dmnote-css-command-{label}-{}", + uuid::Uuid::new_v4() + )) +} + +#[test] +fn css_import_url_accepts_only_http_and_https() { + assert_eq!( + validate_css_import_url("https://example.com/theme.css") + .unwrap() + .scheme(), + "https" + ); + assert_eq!( + validate_css_import_url("http://example.com/theme.css") + .unwrap() + .scheme(), + "http" + ); + for invalid in ["file:///tmp/theme.css", "data:text/css,body{}", "theme.css"] { + assert!(validate_css_import_url(invalid).is_err(), "{invalid}"); + } +} + +#[test] +fn css_import_fetch_is_limited_to_the_main_window() { + ensure_css_import_window(super::super::MAIN_WINDOW_LABEL).unwrap(); + for label in ["overlay", "panel"] { + assert_eq!( + ensure_css_import_window(label).unwrap_err().to_string(), + "CSS import fetch is only available in the main window" + ); + } +} + +#[test] +fn css_import_blocks_only_explicit_metadata_targets() { + for allowed in [ + "http://127.0.0.1:5500/theme.css", + "http://10.0.0.1/theme.css", + "https://example.com/theme.css", + ] { + validate_css_import_url(allowed).unwrap(); + } + for blocked in [ + "http://169.254.169.254/latest/meta-data", + "http://169.254.170.2/v2/credentials", + "http://100.100.100.200/latest/meta-data", + "http://192.0.0.192/metadata", + "http://[fd00:ec2::254]/latest/meta-data", + "http://metadata.google.internal/computeMetadata/v1", + "http://metadata.azure.internal/metadata/instance", + ] { + assert!(validate_css_import_url(blocked).is_err(), "{blocked}"); + } +} + +#[test] +fn css_import_blocks_ipv4_mapped_metadata_literals_and_pins_resolved_addresses() { + assert!(validate_css_import_url("http://[::ffff:169.254.169.254]/latest/meta-data").is_err()); + assert!(is_cloud_metadata_ip( + "::ffff:169.254.169.254".parse().unwrap() + )); + assert!(is_cloud_metadata_ip("fd00:ec2::254".parse().unwrap())); + assert!(!is_cloud_metadata_ip("::1".parse().unwrap())); + assert!(!is_cloud_metadata_ip("127.0.0.1".parse().unwrap())); + + let literal = url::Url::parse("http://127.0.0.1:5500/theme.css").unwrap(); + assert_eq!( + resolve_css_import_addrs(&literal).unwrap(), + vec!["127.0.0.1:5500".parse::().unwrap()] + ); + let mapped = url::Url::parse("http://[::ffff:169.254.169.254]/latest").unwrap(); + assert!(resolve_css_import_addrs(&mapped).is_err()); +} + +#[test] +fn css_import_follows_local_redirect_without_prompt() { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for request_index in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2_048]; + let _ = stream.read(&mut request).unwrap(); + if request_index == 0 { + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: /theme.css\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + } else { + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 22\r\nConnection: close\r\n\r\n.counter { color:red }", + ) + .unwrap(); + } + } + }); + let fetched = fetch_css_import(format!("http://{address}/start.css")).unwrap(); + + server.join().unwrap(); + assert_eq!(fetched.final_url, format!("http://{address}/theme.css")); + assert_eq!(fetched.text, ".counter { color:red }"); +} + +#[test] +fn css_import_blocks_metadata_redirect_before_requesting_it() { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2_048]; + let _ = stream.read(&mut request).unwrap(); + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + }); + + let error = fetch_css_import(format!("http://{address}/start.css")).unwrap_err(); + + server.join().unwrap(); + assert!( + error.to_string().contains("error following redirect"), + "unexpected redirect error: {error}" + ); +} + +#[test] +fn css_import_body_enforces_the_one_mibibyte_limit() { + let accepted = vec![b'a'; MAX_CSS_IMPORT_BYTES]; + assert_eq!( + read_css_import_body(std::io::Cursor::new(accepted.clone())).unwrap(), + accepted + ); + + let rejected = vec![b'a'; MAX_CSS_IMPORT_BYTES + 1]; + assert_eq!( + read_css_import_body(std::io::Cursor::new(rejected)) + .unwrap_err() + .to_string(), + format!("CSS import response exceeds {MAX_CSS_IMPORT_BYTES} bytes") + ); +} + +#[test] +fn unauthorized_tab_set_preserves_content_without_persisting_path() { + let css = TabCss { + path: Some("/tmp/not-authorized.css".to_string()), + content: "preserved".to_string(), + enabled: true, + }; + + let prepared = prepare_tab_css_for_set_with(css, |_| false); + + assert_eq!(prepared.path, None); + assert_eq!(prepared.content, "preserved"); + assert!(prepared.enabled); +} + +#[test] +fn authorized_tab_set_persists_the_canonical_path() { + let root = test_directory("authorized"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("theme.css"); + fs::write(&path, "body {}").unwrap(); + let css = TabCss { + path: Some(path.to_string_lossy().to_string()), + content: "preserved".to_string(), + enabled: true, + }; + + let prepared = prepare_tab_css_for_set_with(css, |_| true); + + let canonical = fs::canonicalize(&path) + .unwrap() + .to_string_lossy() + .to_string(); + assert_eq!(prepared.path.as_deref(), Some(canonical.as_str())); + assert_eq!(prepared.content, "preserved"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn export_corrects_extension_and_atomically_replaces_existing_file() { + let root = test_directory("export"); + fs::create_dir_all(&root).unwrap(); + let selected = root.join("theme.txt"); + let export = ensure_css_extension(selected); + fs::write(&export, "old").unwrap(); + + write_tab_css_export(&export, "new").unwrap(); + + assert_eq!( + export.extension().and_then(|value| value.to_str()), + Some("css") + ); + assert_eq!(fs::read_to_string(&export).unwrap(), "new"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(Path::new(&export).is_file()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn clear_waiting_on_activation_lock_wins_with_the_last_commit() { + let operation_lock = Arc::new(Mutex::new(())); + let store = Arc::new(Mutex::new(AppStoreData::default())); + let (locked_tx, locked_rx) = mpsc::channel(); + let (continue_tx, continue_rx) = mpsc::channel(); + + let activation_lock = operation_lock.clone(); + let activation_store = store.clone(); + let activate = thread::spawn(move || { + let _guard = activation_lock.lock(); + locked_tx.send(()).unwrap(); + continue_rx.recv().unwrap(); + replace_tab_css_override( + &mut activation_store.lock(), + "4key", + Some(TabCss { + path: Some("/tmp/theme.css".to_string()), + content: "active".to_string(), + enabled: true, + }), + ); + }); + locked_rx.recv().unwrap(); + + let clear_lock = operation_lock.clone(); + let clear_store = store.clone(); + let clear = thread::spawn(move || { + let _guard = clear_lock.lock(); + replace_tab_css_override(&mut clear_store.lock(), "4key", None); + }); + continue_tx.send(()).unwrap(); + activate.join().unwrap(); + clear.join().unwrap(); + + assert!(!store.lock().tab_css_overrides.contains_key("4key")); +} diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 92eae2714..954f2b36f 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -34,6 +34,8 @@ use crate::{ }, }; +pub mod custom_tabs; + const MAX_CUSTOM_TABS: usize = 30; fn publish_legacy_key_noop_runtime( @@ -862,541 +864,6 @@ fn keys_reset_mode_inner( }) } -#[tauri::command] -pub async fn custom_tabs_list(app: AppHandle) -> CmdResult> { - run_blocking(app, |_, state| Ok(state.store.snapshot().custom_tabs)).await -} - -#[tauri::command] -pub async fn custom_tabs_create( - app: AppHandle, - window: WebviewWindow, - name: String, - observed_history_epoch: Option, -) -> CmdResult { - let id = generate_custom_tab_id(); - run_history_mutation( - app, - window.label().to_string(), - move |app, state, admission| { - custom_tabs_create_inner(state, app, id, name, observed_history_epoch, admission) - }, - ) - .await -} - -fn custom_tabs_create_inner( - state: &AppState, - app: &AppHandle, - id: String, - name: String, - observed_history_epoch: Option, - admission: HistoryAdmissionLease, -) -> CmdResult { - let (transaction, key_runtime_applied) = state - .commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { - state - .store - .commit_aux_editor_transaction_with_runtime_counters_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch, - origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), - touched_fields: &[EditorField::Keys, EditorField::KeyPositions], - }, - admission, - runtime_counters, - |store| { - let name = match validate_custom_tab_name(&name, &store.custom_tabs, None) { - Ok(name) => name, - Err(error) => return Ok(Err(error.to_string())), - }; - if store.custom_tabs.len() >= MAX_CUSTOM_TABS { - return Ok(Err("max-reached".to_string())); - } - let tab = CustomTab { - id: id.clone(), - name, - }; - store.custom_tabs.push(tab.clone()); - store.tab_order = normalize_tab_order(&store.tab_order, &store.custom_tabs); - store.keys.insert(id.clone(), Vec::new()); - store.key_positions.insert(id.clone(), Vec::new()); - store.selected_key_type = id.clone(); - Ok(Ok(( - tab, - store.custom_tabs.clone(), - store.tab_order.clone(), - store.bar_count, - ))) - }, - ) - })?; - let (tab, custom_tabs, tab_order, bar_count) = match transaction.value { - Ok(result) => result, - Err(error) => { - return Ok(CustomTabCreateResult { - result: None, - error: Some(error), - }); - } - }; - if key_runtime_applied { - publish_editor_change_after_key_runtime(state, app, &transaction.change); - } else { - publish_editor_change(state, app, &transaction.change, false); - } - - emit_best_effort( - app, - "customTabs:changed", - &CustomTabChangePayload { - custom_tabs: custom_tabs.clone(), - tab_order, - bar_count, - selected_key_type: id.clone(), - selection_authoritative: true, - }, - ); - emit_best_effort(app, "keys:changed", &transaction.change.document.keys); - emit_best_effort( - app, - "positions:changed", - &transaction.change.document.key_positions, - ); - emit_best_effort( - app, - "keys:mode-changed", - &serde_json::json!({ "mode": &id }), - ); - emit_aux_history_status(app, &transaction.change); - - Ok(CustomTabCreateResult { - result: Some(tab), - error: None, - }) -} - -#[tauri::command] -pub async fn custom_tabs_rename( - app: AppHandle, - window: WebviewWindow, - id: String, - name: String, - observed_history_epoch: Option, -) -> CmdResult { - run_history_mutation( - app, - window.label().to_string(), - move |app, state, admission| { - custom_tabs_rename_inner(state, app, id, name, observed_history_epoch, admission) - }, - ) - .await -} - -fn custom_tabs_rename_inner( - state: &AppState, - app: &AppHandle, - id: String, - name: String, - observed_history_epoch: Option, - admission: HistoryAdmissionLease, -) -> CmdResult { - let (transaction, _) = - state.commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { - state - .store - .commit_aux_editor_transaction_with_runtime_counters_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch, - origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_rename".to_string()), - touched_fields: &[], - }, - admission, - runtime_counters, - |store| Ok(rename_custom_tab_metadata(store, &id, &name)), - ) - })?; - let (snapshot, error, changed) = transaction.value; - if changed { - publish_editor_change(state, app, &transaction.change, false); - emit_best_effort( - app, - "customTabs:changed", - &CustomTabChangePayload { - custom_tabs: snapshot.custom_tabs.clone(), - tab_order: snapshot.tab_order.clone(), - bar_count: snapshot.bar_count, - selected_key_type: snapshot.selected_key_type.clone(), - selection_authoritative: false, - }, - ); - state.refresh_obs_snapshot(); - } - emit_aux_history_status(app, &transaction.change); - - Ok(TabMetadataResult { - result: Some(snapshot), - error, - }) -} - -#[tauri::command] -pub async fn tabs_reorder( - app: AppHandle, - window: WebviewWindow, - op: TabOrderOpV1, -) -> CmdResult { - run_history_mutation( - app, - window.label().to_string(), - move |app, state, admission| tabs_reorder_inner(state, app, op, admission), - ) - .await -} - -fn tabs_reorder_inner( - state: &AppState, - app: &AppHandle, - op: TabOrderOpV1, - admission: HistoryAdmissionLease, -) -> CmdResult { - let (transaction, _) = - state.commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { - state - .store - .commit_aux_editor_transaction_with_runtime_counters_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch: None, - origin: EditorCommitOrigin::LegacyAdapter("tabs_reorder".to_string()), - touched_fields: &[], - }, - admission, - runtime_counters, - |store| Ok(reorder_tab_metadata(store, &op)), - ) - })?; - let (snapshot, error, changed) = transaction.value; - if let Some(payload) = reorder_change_payload(&snapshot, changed) { - publish_editor_change(state, app, &transaction.change, false); - emit_best_effort(app, "customTabs:changed", &payload); - state.refresh_obs_snapshot(); - } - emit_aux_history_status(app, &transaction.change); - - Ok(TabMetadataResult { - result: Some(snapshot), - error, - }) -} - -#[tauri::command] -pub async fn custom_tabs_delete( - app: AppHandle, - window: WebviewWindow, - id: String, - observed_history_epoch: Option, -) -> CmdResult { - run_history_mutation( - app, - window.label().to_string(), - move |app, state, admission| { - custom_tabs_delete_inner(state, app, id, observed_history_epoch, admission) - }, - ) - .await -} - -fn custom_tabs_delete_inner( - state: &AppState, - app: &AppHandle, - id: String, - observed_history_epoch: Option, - admission: HistoryAdmissionLease, -) -> CmdResult { - let (transaction, key_runtime_applied) = state - .commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { - state - .store - .commit_aux_editor_reset_transaction_with_runtime_counters_admission( - AuxEditorResetTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch, - origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_delete".to_string()), - touched_fields: &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - plugin_instances_reset: PluginInstancesResetScope::Mode(id.clone()), - }, - admission, - runtime_counters, - |store| { - let Some(plan) = plan_custom_tab_delete(store, &id) else { - return Ok(Err(store.selected_key_type.clone())); - }; - delete_custom_tab_data(store, &id, &plan); - Ok(Ok(( - store.custom_tabs.clone(), - store.tab_order.clone(), - store.bar_count, - store.selected_key_type.clone(), - store.tab_note_overrides.clone(), - ))) - }, - ) - })?; - let (custom_tabs, tab_order, bar_count, selected_key_type, tab_note_overrides) = - match transaction.value { - Ok(result) => result, - Err(selected) => { - return Ok(CustomTabDeleteResult { - success: false, - selected, - error: Some("not-found".to_string()), - }); - } - }; - if key_runtime_applied { - publish_editor_change_after_key_runtime(state, app, &transaction.change); - } else { - publish_editor_change(state, app, &transaction.change, false); - } - publish_reset_plugin_instances(app, &transaction.change); - state.unwatch_tab_css(&id); - - emit_best_effort( - app, - "customTabs:changed", - &CustomTabChangePayload { - custom_tabs, - tab_order, - bar_count, - selected_key_type: selected_key_type.clone(), - selection_authoritative: true, - }, - ); - emit_best_effort(app, "keys:changed", &transaction.change.document.keys); - emit_best_effort( - app, - "positions:changed", - &transaction.change.document.key_positions, - ); - emit_best_effort( - app, - "statPositions:changed", - &transaction.change.document.stat_positions, - ); - emit_best_effort( - app, - "graphPositions:changed", - &transaction.change.document.graph_positions, - ); - emit_best_effort( - app, - "knobPositions:changed", - &transaction.change.document.knob_positions, - ); - emit_best_effort( - app, - "layerGroups:changed", - &transaction.change.document.layer_groups, - ); - emit_best_effort(app, "tabNote:changed_all", &tab_note_overrides); - emit_best_effort( - app, - "tabCss:changed", - &crate::commands::editor::css::TabCssResponse { - tab_id: id, - css: None, - }, - ); - emit_best_effort( - app, - "keys:mode-changed", - &serde_json::json!({ "mode": &selected_key_type }), - ); - emit_aux_history_status(app, &transaction.change); - - Ok(CustomTabDeleteResult { - success: true, - selected: selected_key_type, - error: None, - }) -} - -#[derive(Serialize)] -pub struct CustomTabSelectResult { - pub success: bool, - pub selected: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[tauri::command] -pub async fn custom_tabs_select( - app: AppHandle, - window: WebviewWindow, - id: String, - observed_history_epoch: Option, -) -> CmdResult { - run_history_mutation( - app, - window.label().to_string(), - move |app, state, admission| { - custom_tabs_select_inner(state, app, id, observed_history_epoch, admission) - }, - ) - .await -} - -fn custom_tabs_select_inner( - state: &AppState, - app: &AppHandle, - id: String, - observed_history_epoch: Option, - admission: HistoryAdmissionLease, -) -> CmdResult { - let requested = id; - let transaction = state.store.commit_aux_editor_transaction_with_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::Mode, - observed_history_epoch, - origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_select".to_string()), - touched_fields: &[], - }, - admission, - move |store| Ok(select_mode_if_available(store, &requested)), - )?; - let (success, selected) = transaction.value.clone(); - if success - && state.apply_committed_editor_keys_without_counters( - transaction.change.runtime_publication_generation, - &transaction.change.document.keys, - &selected, - ) - { - emit_best_effort( - app, - "keys:mode-changed", - &serde_json::json!({ "mode": &selected }), - ); - state.refresh_obs_snapshot(); - } - emit_aux_history_status(app, &transaction.change); - - Ok(CustomTabSelectResult { - success, - selected, - error: (!success).then(|| "not-found".to_string()), - }) -} - -/// 커스텀 탭 목록과 선택 모드를 원자적으로 복원 -#[tauri::command] -pub async fn custom_tabs_restore( - app: AppHandle, - window: WebviewWindow, - custom_tabs: Vec, - selected_key_type: String, - observed_history_epoch: Option, -) -> CmdResult<()> { - run_history_mutation( - app, - window.label().to_string(), - move |app, state, admission| { - custom_tabs_restore_inner( - state, - app, - custom_tabs, - selected_key_type, - observed_history_epoch, - admission, - ) - }, - ) - .await -} - -fn custom_tabs_restore_inner( - state: &AppState, - app: &AppHandle, - custom_tabs: Vec, - selected_key_type: String, - observed_history_epoch: Option, - admission: HistoryAdmissionLease, -) -> CmdResult<()> { - let (transaction, _) = - state.commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { - state - .store - .commit_aux_editor_transaction_with_runtime_counters_admission( - AuxEditorTransactionOptions { - scope: HistoryScope::CustomTabs, - observed_history_epoch, - origin: EditorCommitOrigin::LegacyAdapter( - "custom_tabs_restore".to_string(), - ), - touched_fields: &[], - }, - admission, - runtime_counters, - move |store| { - let tab_order = normalize_tab_order(&store.tab_order, &custom_tabs); - validate_history_restore_metadata( - &EditorDocumentV1::from_store(store), - &custom_tabs, - &tab_order, - &selected_key_type, - )?; - store.custom_tabs = custom_tabs; - store.tab_order = tab_order; - store.bar_count = normalize_bar_count(store.bar_count, &store.tab_order); - store.selected_key_type = selected_key_type; - Ok(( - store.custom_tabs.clone(), - store.tab_order.clone(), - store.bar_count, - store.selected_key_type.clone(), - )) - }, - ) - })?; - let (custom_tabs, tab_order, bar_count, selected_key_type) = transaction.value; - - state.apply_committed_editor_keys_without_counters( - transaction.change.runtime_publication_generation, - &transaction.change.document.keys, - &selected_key_type, - ); - emit_best_effort( - app, - "customTabs:changed", - &CustomTabChangePayload { - custom_tabs, - tab_order, - bar_count, - selected_key_type: selected_key_type.clone(), - selection_authoritative: true, - }, - ); - emit_best_effort( - app, - "keys:mode-changed", - &serde_json::json!({ "mode": &selected_key_type }), - ); - state.refresh_obs_snapshot(); - emit_aux_history_status(app, &transaction.change); - Ok(()) -} - #[tauri::command] pub async fn keys_reset_counters( app: AppHandle, @@ -1537,1232 +1004,4 @@ pub fn raw_input_unsubscribe(state: State<'_, AppState>) -> CmdResult anyhow::Result<()> { - Ok(()) - } - - fn emit_key_counter( - &self, - _mode: &str, - _key: &str, - _count: u32, - _session_id: &str, - _revision: u64, - ) -> anyhow::Result<()> { - Ok(()) - } - } - - fn populated_custom_tab_store() -> AppStoreData { - let position = default_positions() - .values() - .next() - .and_then(|positions| positions.first()) - .cloned() - .expect("default position fixture"); - let mut store = AppStoreData { - custom_tabs: vec![ - CustomTab { - id: "custom-before".to_string(), - name: "Before".to_string(), - }, - CustomTab { - id: TARGET_TAB.to_string(), - name: "Target".to_string(), - }, - ], - selected_key_type: TARGET_TAB.to_string(), - ..AppStoreData::default() - }; - store - .keys - .insert(TARGET_TAB.to_string(), vec![KeySlot::from("KeyD")]); - store - .key_positions - .insert(TARGET_TAB.to_string(), vec![position.clone()]); - store.stat_positions.insert( - TARGET_TAB.to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: position.clone(), - }], - ); - store.graph_positions.insert( - TARGET_TAB.to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#ffffff".to_string(), - show_avg_line: true, - position: position.clone(), - }], - ); - store.knob_positions.insert( - TARGET_TAB.to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position, - }], - ); - store.layer_groups.insert( - TARGET_TAB.to_string(), - vec![LayerGroupDef { - id: "group".to_string(), - name: "Group".to_string(), - }], - ); - store - .tab_css_overrides - .insert(TARGET_TAB.to_string(), TabCss::default()); - store - .tab_note_overrides - .insert(TARGET_TAB.to_string(), TabNoteSettings::default()); - store.key_counters.insert( - TARGET_TAB.to_string(), - [("KeyD".to_string(), 7)].into_iter().collect(), - ); - store - } - - #[test] - fn deleting_selected_custom_tab_clears_all_tab_scoped_data() { - let mut store = populated_custom_tab_store(); - store.bar_count = 5; - let plan = plan_custom_tab_delete(&store, TARGET_TAB).expect("delete plan"); - - assert_eq!(plan.next_selected, "custom-before"); - delete_custom_tab_data(&mut store, TARGET_TAB, &plan); - - assert!(!store.custom_tabs.iter().any(|tab| tab.id == TARGET_TAB)); - assert!(!store.keys.contains_key(TARGET_TAB)); - assert!(!store.key_positions.contains_key(TARGET_TAB)); - assert!(!store.stat_positions.contains_key(TARGET_TAB)); - assert!(!store.graph_positions.contains_key(TARGET_TAB)); - assert!(!store.knob_positions.contains_key(TARGET_TAB)); - assert!(!store.layer_groups.contains_key(TARGET_TAB)); - assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); - assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); - assert!(!store.key_counters.contains_key(TARGET_TAB)); - assert_eq!(store.selected_key_type, "custom-before"); - assert_eq!(store.bar_count, 4); - } - - #[test] - fn deleting_first_selected_tab_chooses_next_builtin_neighbor() { - let mut store = populated_custom_tab_store(); - store.tab_order = [TARGET_TAB, "4key", "custom-before", "5key", "6key", "8key"] - .map(str::to_string) - .to_vec(); - - let plan = plan_custom_tab_delete(&store, TARGET_TAB).expect("delete plan"); - - assert_eq!(plan.next_selected, "4key"); - assert_eq!( - plan.tab_order, - ["4key", "custom-before", "5key", "6key", "8key"] - ); - } - - #[test] - fn rename_rejects_long_reserved_duplicate_and_builtin_targets() { - let mut store = populated_custom_tab_store(); - - let (_, long_error, _) = rename_custom_tab_metadata(&mut store, TARGET_TAB, "12345678901"); - let (_, reserved_error, _) = rename_custom_tab_metadata(&mut store, TARGET_TAB, "4key"); - let (_, duplicate_error, _) = rename_custom_tab_metadata(&mut store, TARGET_TAB, "Before"); - let (_, builtin_error, _) = rename_custom_tab_metadata(&mut store, "4key", "Built in"); - - assert_eq!(long_error.as_deref(), Some("name-too-long")); - assert_eq!(reserved_error.as_deref(), Some("reserved-name")); - assert_eq!(duplicate_error.as_deref(), Some("duplicate-name")); - assert_eq!(builtin_error.as_deref(), Some("unknown-tab")); - assert_eq!( - store - .custom_tabs - .iter() - .find(|tab| tab.id == TARGET_TAB) - .unwrap() - .name, - "Target" - ); - } - - #[test] - fn reorder_applies_after_unrelated_tab_is_created() { - let mut store = crate::state::migration::normalize_state(AppStoreData::default()); - let unrelated_id = "created-after-drag".to_string(); - store.custom_tabs.push(CustomTab { - id: unrelated_id.clone(), - name: "Created later".to_string(), - }); - store.tab_order = - crate::state::tab_metadata::normalize_tab_order(&store.tab_order, &store.custom_tabs); - - let (snapshot, error, changed) = reorder_tab_metadata( - &mut store, - &TabOrderOpV1::Swap { - a: "4key".to_string(), - b: "5key".to_string(), - }, - ); - - assert!(error.is_none()); - assert!(changed); - assert_eq!( - snapshot.tab_order, - ["5key", "4key", "6key", "8key", unrelated_id.as_str()] - ); - } - - #[test] - fn reorder_applies_after_unrelated_tab_is_deleted() { - let mut store = crate::state::migration::normalize_state(AppStoreData::default()); - let unrelated_id = "deleted-after-drag".to_string(); - store.custom_tabs.push(CustomTab { - id: unrelated_id.clone(), - name: "Deleted later".to_string(), - }); - store.tab_order.push(unrelated_id.clone()); - store.custom_tabs.retain(|tab| tab.id != unrelated_id); - store.tab_order.retain(|id| id != &unrelated_id); - - let (snapshot, error, changed) = reorder_tab_metadata( - &mut store, - &TabOrderOpV1::Swap { - a: "6key".to_string(), - b: "8key".to_string(), - }, - ); - - assert!(error.is_none()); - assert!(changed); - assert_eq!(snapshot.tab_order, ["4key", "5key", "8key", "6key"]); - } - - #[test] - fn reorder_unknown_tab_repairs_noncanonical_metadata() { - let mut store = crate::state::migration::normalize_state(AppStoreData::default()); - store.tab_order = ["4key", "unknown", "4key", "5key"] - .map(str::to_string) - .to_vec(); - store.bar_count = 9; - - let (snapshot, error, changed) = reorder_tab_metadata( - &mut store, - &TabOrderOpV1::Swap { - a: "missing".to_string(), - b: "5key".to_string(), - }, - ); - - assert_eq!(error.as_deref(), Some("unknown-tab")); - assert!(changed); - assert_eq!(snapshot.tab_order, ["4key", "5key", "6key", "8key"]); - assert_eq!(snapshot.bar_count, 4); - assert_eq!(store.tab_order, snapshot.tab_order); - assert_eq!(store.bar_count, snapshot.bar_count); - assert!(reorder_change_payload(&snapshot, changed).is_some()); - } - - #[test] - fn reorder_same_tab_repairs_noncanonical_metadata() { - let mut store = crate::state::migration::normalize_state(AppStoreData::default()); - store.tab_order = ["4key", "unknown", "4key", "5key"] - .map(str::to_string) - .to_vec(); - store.bar_count = 9; - - let (snapshot, error, changed) = reorder_tab_metadata( - &mut store, - &TabOrderOpV1::Swap { - a: "4key".to_string(), - b: "4key".to_string(), - }, - ); - - assert!(error.is_none()); - assert!(changed); - assert_eq!(snapshot.tab_order, ["4key", "5key", "6key", "8key"]); - assert_eq!(snapshot.bar_count, 4); - assert_eq!(store.tab_order, snapshot.tab_order); - assert_eq!(store.bar_count, snapshot.bar_count); - assert!(reorder_change_payload(&snapshot, changed).is_some()); - } - - #[test] - fn reorder_rejects_each_missing_operand_with_current_snapshot() { - for op in [ - TabOrderOpV1::Swap { - a: "missing".to_string(), - b: "5key".to_string(), - }, - TabOrderOpV1::Swap { - a: "4key".to_string(), - b: "missing".to_string(), - }, - ] { - let mut store = crate::state::migration::normalize_state(AppStoreData::default()); - let before = store.clone(); - - let (snapshot, error, changed) = reorder_tab_metadata(&mut store, &op); - - assert_eq!(error.as_deref(), Some("unknown-tab")); - assert!(!changed); - assert_eq!(snapshot.custom_tabs, before.custom_tabs); - assert_eq!(snapshot.tab_order, before.tab_order); - assert_eq!(snapshot.bar_count, before.bar_count); - assert_eq!(snapshot.selected_key_type, before.selected_key_type); - assert_eq!(store, before); - } - } - - #[test] - fn reorder_same_tab_is_a_no_op() { - let mut store = crate::state::migration::normalize_state(AppStoreData::default()); - let before = store.clone(); - - let (snapshot, error, changed) = reorder_tab_metadata( - &mut store, - &TabOrderOpV1::Swap { - a: "4key".to_string(), - b: "4key".to_string(), - }, - ); - - assert!(error.is_none()); - assert!(!changed); - assert_eq!(snapshot.tab_order, before.tab_order); - assert_eq!(store, before); - } - - #[test] - fn reorder_true_no_op_preserves_history_events_and_store() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - let before = store.snapshot(); - let history_before = store.history_status(); - - for (op, expected_error) in [ - ( - TabOrderOpV1::Swap { - a: "4key".to_string(), - b: "4key".to_string(), - }, - None, - ), - ( - TabOrderOpV1::Swap { - a: "missing".to_string(), - b: "5key".to_string(), - }, - Some("unknown-tab"), - ), - ] { - let transaction = store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("tabs_reorder_test".to_string()), - &[], - |data| Ok(reorder_tab_metadata(data, &op)), - ) - .unwrap(); - let (snapshot, error, changed) = &transaction.value; - - assert_eq!(error.as_deref(), expected_error); - assert!(!changed); - assert!(transaction.change.event.is_none()); - assert!(transaction.change.history_status.is_none()); - assert!(reorder_change_payload(snapshot, *changed).is_none()); - assert_eq!(store.snapshot(), before); - } - let history_after = store.history_status(); - assert_eq!( - history_after.history_revision, - history_before.history_revision - ); - assert!(!history_after.can_undo); - store.flush_and_shutdown().unwrap(); - } - - #[test] - fn reorder_history_undo_restores_tab_order_and_bar_count_together() { - let directory = tempfile::tempdir().unwrap(); - let tab_id = "reorder-tab".to_string(); - let mut data = crate::state::migration::normalize_state(AppStoreData::default()); - data.custom_tabs.push(CustomTab { - id: tab_id.clone(), - name: "Reorder".to_string(), - }); - data.keys.insert(tab_id.clone(), Vec::new()); - data.key_positions.insert(tab_id.clone(), Vec::new()); - data.tab_order = ["4key", "5key", "6key", "8key", tab_id.as_str()] - .map(str::to_string) - .to_vec(); - data.bar_count = 2; - crate::state::native_element_id::backfill_store_element_ids(&mut data); - std::fs::write( - directory.path().join("store.json"), - serde_json::to_vec_pretty(&data).unwrap(), - ) - .unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - let before = store.snapshot(); - let reordered = [tab_id.as_str(), "5key", "6key", "8key", "4key"] - .map(str::to_string) - .to_vec(); - - store - .commit_aux_editor_transaction( - HistoryScope::CustomTabs, - None, - EditorCommitOrigin::LegacyAdapter("tabs_reorder_test".to_string()), - &[], - |data| { - let (_, error, changed) = reorder_tab_metadata( - data, - &TabOrderOpV1::Swap { - a: "4key".to_string(), - b: tab_id.clone(), - }, - ); - assert!(error.is_none()); - assert!(changed); - Ok(()) - }, - ) - .unwrap(); - assert_eq!(store.snapshot().tab_order, reordered); - assert_eq!(store.snapshot().bar_count, 2); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let gate = store.history_gate(); - let barrier = gate.close(&operation_id).unwrap(); - let counters = store.snapshot().key_counters; - let undo = store - .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) - .unwrap(); - drop(barrier); - - assert_eq!(store.snapshot().tab_order, before.tab_order); - assert_eq!(store.snapshot().bar_count, before.bar_count); - assert!(undo.status.can_redo); - - let operation_id = uuid::Uuid::new_v4().to_string(); - let barrier = gate.close(&operation_id).unwrap(); - let counters = store.snapshot().key_counters; - let redo = store - .apply_history_operation(HistoryDirection::Redo, &operation_id, &counters, || {}) - .unwrap(); - drop(barrier); - - assert_eq!(store.snapshot().tab_order, reordered); - assert_eq!(store.snapshot().bar_count, 2); - assert!(redo.status.can_undo); - store.flush_and_shutdown().unwrap(); - } - - #[test] - fn tab_order_op_rejects_unknown_kind_and_unknown_fields() { - let unknown_kind = serde_json::from_value::(serde_json::json!({ - "kind": "move", - "a": "4key", - "b": "5key" - })); - let unknown_field = serde_json::from_value::(serde_json::json!({ - "kind": "swap", - "a": "4key", - "b": "5key", - "extra": true - })); - - assert!(unknown_kind.is_err()); - assert!(unknown_field.is_err()); - } - - #[test] - fn reset_all_clears_knob_positions_and_zeroes_default_counters() { - let mut store = populated_custom_tab_store(); - reset_all_editor_data( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - ); - - assert!(store.knob_positions.is_empty()); - assert!(store.custom_tabs.is_empty()); - assert_eq!(store.selected_key_type, "4key"); - assert_eq!(store.stat_positions.len(), default_stat_positions().len()); - assert!(store - .key_counters - .values() - .flat_map(|mode| mode.values()) - .all(|count| *count == 0)); - } - - #[test] - fn reset_all_issues_a_fresh_globally_unique_id_generation_each_time() { - let mut store = populated_custom_tab_store(); - reset_all_editor_data( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - ); - let first = store - .key_positions - .values() - .flatten() - .map(|position| position.id.clone()) - .collect::>(); - let first_count = store.key_positions.values().map(Vec::len).sum::(); - - reset_all_editor_data( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - ); - let second = store - .key_positions - .values() - .flatten() - .map(|position| position.id.clone()) - .collect::>(); - - assert_eq!(first.len(), first_count); - assert_eq!(second.len(), first_count); - assert!(first.is_disjoint(&second)); - assert!(second - .iter() - .all(|id| crate::state::native_element_id::is_valid_element_id(id))); - } - - #[test] - fn reset_all_migrates_default_data_url_images_immediately() { - let dir = std::env::temp_dir().join(format!( - "dmnote-reset-all-default-images-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&dir).unwrap(); - let mut store = populated_custom_tab_store(); - - reset_all_editor_data_with_images( - &mut store, - default_keys(), - default_positions(), - default_stat_positions(), - &dir, - ); - - let positions = store - .key_positions - .values() - .flatten() - .chain( - store - .stat_positions - .values() - .flatten() - .map(|stat| &stat.position), - ) - .chain( - store - .graph_positions - .values() - .flatten() - .map(|graph| &graph.position), - ) - .chain( - store - .knob_positions - .values() - .flatten() - .map(|knob| &knob.position), - ) - .collect::>(); - let image_paths = positions - .iter() - .flat_map(|position| [&position.active_image, &position.inactive_image]) - .flatten() - .filter(|image| !image.is_empty()) - .collect::>(); - assert!(!image_paths.is_empty()); - assert!(image_paths.iter().all(|image| !image.starts_with("data:"))); - assert!(image_paths - .iter() - .all(|image| std::path::Path::new(image.as_str()).is_file())); - assert!(store.stat_positions.values().flatten().all(|stat| { - crate::state::native_element_id::is_valid_element_id(&stat.position.id) - })); - - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn custom_mode_reset_is_supported_and_preserves_tab_identity() { - let mut store = populated_custom_tab_store(); - let tabs_before = store.custom_tabs.clone(); - let kind = reset_mode_kind(&store, TARGET_TAB); - - assert_eq!(kind, Some(ModeResetKind::Custom)); - reset_mode_data(&mut store, TARGET_TAB, kind.unwrap()); - - assert_eq!(store.custom_tabs, tabs_before); - assert!(store.keys[TARGET_TAB].is_empty()); - assert!(store.key_positions[TARGET_TAB].is_empty()); - assert!(store.stat_positions[TARGET_TAB].is_empty()); - assert!(store.graph_positions[TARGET_TAB].is_empty()); - assert!(store.knob_positions[TARGET_TAB].is_empty()); - assert!(!store.layer_groups.contains_key(TARGET_TAB)); - assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); - assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); - assert!(store.key_counters[TARGET_TAB].is_empty()); - } - - #[test] - fn default_mode_reset_clears_knob_positions() { - let mut store = AppStoreData::default(); - store.knob_positions.insert( - "4key".to_string(), - populated_custom_tab_store().knob_positions[TARGET_TAB].clone(), - ); - - reset_mode_data(&mut store, "4key", ModeResetKind::Default); - - assert!(store.knob_positions["4key"].is_empty()); - assert_eq!( - store.stat_positions["4key"].len(), - default_stat_positions()["4key"].len() - ); - } - - #[test] - fn ghost_mode_request_leaves_store_keyboard_and_events_unchanged() { - let mut store = AppStoreData { - selected_key_type: "8key".to_string(), - ..AppStoreData::default() - }; - store - .keys - .insert("ghost-mode".to_string(), vec![KeySlot::from("KeyA")]); - let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); - let commit_calls = Cell::new(0); - let emit_calls = Cell::new(0); - - let response = set_mode_with( - &store, - "ghost-mode".to_string(), - |candidate| { - commit_calls.set(commit_calls.get() + 1); - Ok(candidate) - }, - |effective| { - keyboard.set_mode(effective.to_string()); - emit_calls.set(emit_calls.get() + 1); - Ok(()) - }, - ) - .unwrap(); - - assert!(!response.success); - assert_eq!(response.mode, "8key"); - assert_eq!(store.selected_key_type, "8key"); - assert_eq!(keyboard.current_mode(), "8key"); - assert_eq!(commit_calls.get(), 0); - assert_eq!(emit_calls.get(), 0); - } - - #[test] - fn absent_mode_request_remains_a_no_op() { - let store = AppStoreData { - selected_key_type: "8key".to_string(), - ..AppStoreData::default() - }; - let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); - let commit_calls = Cell::new(0); - let emit_calls = Cell::new(0); - - let response = set_mode_with( - &store, - "missing-mode".to_string(), - |candidate| { - commit_calls.set(commit_calls.get() + 1); - Ok(candidate) - }, - |effective| { - keyboard.set_mode(effective.to_string()); - emit_calls.set(emit_calls.get() + 1); - Ok(()) - }, - ) - .unwrap(); - - assert!(!response.success); - assert_eq!(response.mode, "8key"); - assert_eq!(store.selected_key_type, "8key"); - assert_eq!(keyboard.current_mode(), "8key"); - assert_eq!(commit_calls.get(), 0); - assert_eq!(emit_calls.get(), 0); - } - - #[test] - fn selection_after_concurrent_delete_uses_locked_store_state() { - let stale_snapshot = populated_custom_tab_store(); - assert!(super::is_selectable_mode(&stale_snapshot, TARGET_TAB)); - - let mut locked_store = stale_snapshot; - let delete_plan = plan_custom_tab_delete(&locked_store, TARGET_TAB).unwrap(); - delete_custom_tab_data(&mut locked_store, TARGET_TAB, &delete_plan); - let selected_after_delete = locked_store.selected_key_type.clone(); - - let (success, selected) = select_mode_if_available(&mut locked_store, TARGET_TAB); - - assert!(!success); - assert_eq!(selected, selected_after_delete); - assert_eq!(locked_store.selected_key_type, selected_after_delete); - assert!(!locked_store.keys.contains_key(TARGET_TAB)); - } - - #[test] - fn reset_mode_with_changed_keys_preserves_other_live_mode_counters() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let customized = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET KEY"); - Ok(()) - }, - ) - .unwrap(); - drop(customized); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let reset_mode = "4key"; - let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); - let preserved_mode = "5key"; - let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); - for expected in 1..=3 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), - Some(expected) - ); - } - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), - Some(expected) - ); - } - assert_eq!( - state.store.snapshot().key_counters[preserved_mode][&preserved_key], - 0 - ); - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(reset_mode.to_string()), - admission, - |data| { - reset_mode_data_with_images( - data, - reset_mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - assert!(key_runtime_applied); - assert!(transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - - assert_eq!( - state.snapshot_key_counters()[preserved_mode][&preserved_key], - 7 - ); - assert!(state.snapshot_key_counters()[reset_mode] - .values() - .all(|count| *count == 0)); - assert_eq!( - state.store.snapshot().key_counters[preserved_mode][&preserved_key], - 7 - ); - state.shutdown(); - } - - #[test] - fn reset_mode_with_default_keys_applies_counter_only_reset_to_runtime() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let mode = "4key"; - let key = state.store.snapshot().keys[mode][0].canonical(); - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, &key), - Some(expected) - ); - } - assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); - let generation_before = state.store.runtime_publication_generation(); - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(mode.to_string()), - admission, - |data| { - reset_mode_data_with_images( - data, - mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - assert!(key_runtime_applied); - assert!(!transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - assert!(transaction.change.runtime_publication_generation > generation_before); - - assert_eq!(state.snapshot_key_counters()[mode][&key], 0); - assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); - state.shutdown(); - } - - #[test] - fn reset_mode_replays_queued_increment_for_unchanged_mapping() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let reset_mode = "4key"; - let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); - let preserved_mode = "5key"; - let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); - for expected in 1..=3 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), - Some(expected) - ); - } - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), - Some(expected) - ); - } - - let admission = state.store.admit_editor_mutation().unwrap(); - let (_, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(reset_mode.to_string()), - admission, - |data| { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key,), - None - ); - reset_mode_data_with_images( - data, - reset_mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert_eq!(state.snapshot_key_counters()[reset_mode][&reset_key], 1); - assert_eq!( - state.snapshot_key_counters()[preserved_mode][&preserved_key], - 7 - ); - assert_eq!( - state.store.snapshot().key_counters[reset_mode][&reset_key], - 0 - ); - assert_eq!( - state.store.snapshot().key_counters[preserved_mode][&preserved_key], - 7 - ); - state.shutdown(); - } - - #[test] - fn reset_mode_drops_queued_increment_for_replaced_key() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let mode = "4key"; - let replaced_key = "QA REPLACED KEY"; - let setup = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.keys.get_mut(mode).unwrap()[0] = KeySlot::from(replaced_key); - Ok(()) - }, - ) - .unwrap(); - drop(setup); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - - let admission = state.store.admit_editor_mutation().unwrap(); - let (_, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::Mode(mode.to_string()), - admission, - |data| { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, replaced_key), - None - ); - reset_mode_data_with_images( - data, - mode, - ModeResetKind::Default, - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert!(!state.snapshot_key_counters()[mode].contains_key(replaced_key)); - assert!(!state.store.snapshot().key_counters[mode].contains_key(replaced_key)); - state.shutdown(); - drop(state); - let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); - assert!(!reloaded.snapshot().key_counters[mode].contains_key(replaced_key)); - reloaded.flush_and_shutdown().unwrap(); - } - - #[test] - fn reset_all_drops_queued_increment_for_removed_custom_mode() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let mode = "qa-removed-custom-mode"; - let key = "QA REMOVED KEY"; - let mut position = default_positions()["4key"][0].clone(); - position.id = uuid::Uuid::new_v4().to_string(); - let setup = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.custom_tabs.push(CustomTab { - id: mode.to_string(), - name: "Removed during reset".to_string(), - }); - data.keys.insert(mode.to_string(), vec![KeySlot::from(key)]); - data.key_positions.insert(mode.to_string(), vec![position]); - data.selected_key_type = mode.to_string(); - Ok(()) - }, - ) - .unwrap(); - drop(setup); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - - let admission = state.store.admit_editor_mutation().unwrap(); - let (_, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::All, - admission, - |data| { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, key), - None - ); - reset_all_editor_data_with_images( - data, - default_keys(), - default_positions(), - default_stat_positions(), - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert!(!state.snapshot_key_counters().contains_key(mode)); - assert!(!state.store.snapshot().key_counters.contains_key(mode)); - state.shutdown(); - drop(state); - let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); - assert!(!reloaded.snapshot().key_counters.contains_key(mode)); - reloaded.flush_and_shutdown().unwrap(); - } - - #[test] - fn reset_all_with_default_keys_applies_counter_only_reset_to_runtime() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - let mode = "4key"; - let key = state.store.snapshot().keys[mode][0].canonical(); - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, &key), - Some(expected) - ); - } - assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); - let generation_before = state.store.runtime_publication_generation(); - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::All, - admission, - |data| { - reset_all_editor_data_with_images( - data, - default_keys(), - default_positions(), - default_stat_positions(), - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - assert!(key_runtime_applied); - assert!(!transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - assert!(transaction.change.runtime_publication_generation > generation_before); - - assert_eq!(state.snapshot_key_counters()[mode][&key], 0); - assert!(state - .store - .snapshot() - .key_counters - .values() - .flat_map(|counters| counters.values()) - .all(|count| *count == 0)); - state.shutdown(); - } - - #[test] - fn reset_all_with_changed_keys_zeroes_every_live_counter() { - let directory = tempfile::tempdir().unwrap(); - let store = AppStore::initialize_for_test(directory.path()).unwrap(); - store - .update(|data| data.key_counter_enabled = true) - .unwrap(); - let customized = store - .commit_legacy_editor_transaction( - EditorCommitOrigin::LegacyAdapter("reset-all-test-setup".to_string()), - &[EditorField::Keys, EditorField::KeyPositions], - |data| { - data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET ALL KEY"); - Ok(()) - }, - ) - .unwrap(); - drop(customized); - let state = AppState::initialize(store).unwrap(); - let emitter = NoopCounterEmitter; - for mode in ["4key", "5key"] { - let key = state.store.snapshot().keys[mode][0].canonical(); - for expected in 1..=7 { - assert_eq!( - state.increment_key_counter_and_emit(&emitter, mode, &key), - Some(expected) - ); - } - } - - let admission = state.store.admit_editor_mutation().unwrap(); - let (transaction, key_runtime_applied) = state - .commit_legacy_editor_reset_preserving_runtime_counters( - &emitter, - EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), - &[ - EditorField::Keys, - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - EditorField::LayerGroups, - ], - PluginInstancesResetScope::All, - admission, - |data| { - reset_all_editor_data_with_images( - data, - default_keys(), - default_positions(), - default_stat_positions(), - directory.path(), - ); - Ok(()) - }, - ) - .unwrap(); - - assert!(key_runtime_applied); - assert!(transaction - .change - .result - .changed_fields - .contains(&EditorField::Keys)); - assert_eq!(transaction.change.document.keys, *default_keys()); - assert!(state - .snapshot_key_counters() - .values() - .flat_map(|counters| counters.values()) - .all(|count| *count == 0)); - assert!(state - .store - .snapshot() - .key_counters - .values() - .flat_map(|counters| counters.values()) - .all(|count| *count == 0)); - state.shutdown(); - } -} +mod tests; diff --git a/src-tauri/src/commands/keys/keys/custom_tabs.rs b/src-tauri/src/commands/keys/keys/custom_tabs.rs new file mode 100644 index 000000000..be9fc015b --- /dev/null +++ b/src-tauri/src/commands/keys/keys/custom_tabs.rs @@ -0,0 +1,536 @@ +use super::*; + +#[tauri::command] +pub async fn custom_tabs_list(app: AppHandle) -> CmdResult> { + run_blocking(app, |_, state| Ok(state.store.snapshot().custom_tabs)).await +} + +#[tauri::command] +pub async fn custom_tabs_create( + app: AppHandle, + window: WebviewWindow, + name: String, + observed_history_epoch: Option, +) -> CmdResult { + let id = generate_custom_tab_id(); + run_history_mutation( + app, + window.label().to_string(), + move |app, state, admission| { + custom_tabs_create_inner(state, app, id, name, observed_history_epoch, admission) + }, + ) + .await +} + +fn custom_tabs_create_inner( + state: &AppState, + app: &AppHandle, + id: String, + name: String, + observed_history_epoch: Option, + admission: HistoryAdmissionLease, +) -> CmdResult { + let (transaction, key_runtime_applied) = state + .commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { + state + .store + .commit_aux_editor_transaction_with_runtime_counters_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch, + origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_create".to_string()), + touched_fields: &[EditorField::Keys, EditorField::KeyPositions], + }, + admission, + runtime_counters, + |store| { + let name = match validate_custom_tab_name(&name, &store.custom_tabs, None) { + Ok(name) => name, + Err(error) => return Ok(Err(error.to_string())), + }; + if store.custom_tabs.len() >= MAX_CUSTOM_TABS { + return Ok(Err("max-reached".to_string())); + } + let tab = CustomTab { + id: id.clone(), + name, + }; + store.custom_tabs.push(tab.clone()); + store.tab_order = normalize_tab_order(&store.tab_order, &store.custom_tabs); + store.keys.insert(id.clone(), Vec::new()); + store.key_positions.insert(id.clone(), Vec::new()); + store.selected_key_type = id.clone(); + Ok(Ok(( + tab, + store.custom_tabs.clone(), + store.tab_order.clone(), + store.bar_count, + ))) + }, + ) + })?; + let (tab, custom_tabs, tab_order, bar_count) = match transaction.value { + Ok(result) => result, + Err(error) => { + return Ok(CustomTabCreateResult { + result: None, + error: Some(error), + }); + } + }; + if key_runtime_applied { + publish_editor_change_after_key_runtime(state, app, &transaction.change); + } else { + publish_editor_change(state, app, &transaction.change, false); + } + + emit_best_effort( + app, + "customTabs:changed", + &CustomTabChangePayload { + custom_tabs: custom_tabs.clone(), + tab_order, + bar_count, + selected_key_type: id.clone(), + selection_authoritative: true, + }, + ); + emit_best_effort(app, "keys:changed", &transaction.change.document.keys); + emit_best_effort( + app, + "positions:changed", + &transaction.change.document.key_positions, + ); + emit_best_effort( + app, + "keys:mode-changed", + &serde_json::json!({ "mode": &id }), + ); + emit_aux_history_status(app, &transaction.change); + + Ok(CustomTabCreateResult { + result: Some(tab), + error: None, + }) +} + +#[tauri::command] +pub async fn custom_tabs_rename( + app: AppHandle, + window: WebviewWindow, + id: String, + name: String, + observed_history_epoch: Option, +) -> CmdResult { + run_history_mutation( + app, + window.label().to_string(), + move |app, state, admission| { + custom_tabs_rename_inner(state, app, id, name, observed_history_epoch, admission) + }, + ) + .await +} + +fn custom_tabs_rename_inner( + state: &AppState, + app: &AppHandle, + id: String, + name: String, + observed_history_epoch: Option, + admission: HistoryAdmissionLease, +) -> CmdResult { + let (transaction, _) = + state.commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { + state + .store + .commit_aux_editor_transaction_with_runtime_counters_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch, + origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_rename".to_string()), + touched_fields: &[], + }, + admission, + runtime_counters, + |store| Ok(rename_custom_tab_metadata(store, &id, &name)), + ) + })?; + let (snapshot, error, changed) = transaction.value; + if changed { + publish_editor_change(state, app, &transaction.change, false); + emit_best_effort( + app, + "customTabs:changed", + &CustomTabChangePayload { + custom_tabs: snapshot.custom_tabs.clone(), + tab_order: snapshot.tab_order.clone(), + bar_count: snapshot.bar_count, + selected_key_type: snapshot.selected_key_type.clone(), + selection_authoritative: false, + }, + ); + state.refresh_obs_snapshot(); + } + emit_aux_history_status(app, &transaction.change); + + Ok(TabMetadataResult { + result: Some(snapshot), + error, + }) +} + +#[tauri::command] +pub async fn tabs_reorder( + app: AppHandle, + window: WebviewWindow, + op: TabOrderOpV1, +) -> CmdResult { + run_history_mutation( + app, + window.label().to_string(), + move |app, state, admission| tabs_reorder_inner(state, app, op, admission), + ) + .await +} + +fn tabs_reorder_inner( + state: &AppState, + app: &AppHandle, + op: TabOrderOpV1, + admission: HistoryAdmissionLease, +) -> CmdResult { + let (transaction, _) = + state.commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { + state + .store + .commit_aux_editor_transaction_with_runtime_counters_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch: None, + origin: EditorCommitOrigin::LegacyAdapter("tabs_reorder".to_string()), + touched_fields: &[], + }, + admission, + runtime_counters, + |store| Ok(reorder_tab_metadata(store, &op)), + ) + })?; + let (snapshot, error, changed) = transaction.value; + if let Some(payload) = reorder_change_payload(&snapshot, changed) { + publish_editor_change(state, app, &transaction.change, false); + emit_best_effort(app, "customTabs:changed", &payload); + state.refresh_obs_snapshot(); + } + emit_aux_history_status(app, &transaction.change); + + Ok(TabMetadataResult { + result: Some(snapshot), + error, + }) +} + +#[tauri::command] +pub async fn custom_tabs_delete( + app: AppHandle, + window: WebviewWindow, + id: String, + observed_history_epoch: Option, +) -> CmdResult { + run_history_mutation( + app, + window.label().to_string(), + move |app, state, admission| { + custom_tabs_delete_inner(state, app, id, observed_history_epoch, admission) + }, + ) + .await +} + +fn custom_tabs_delete_inner( + state: &AppState, + app: &AppHandle, + id: String, + observed_history_epoch: Option, + admission: HistoryAdmissionLease, +) -> CmdResult { + let (transaction, key_runtime_applied) = state + .commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { + state + .store + .commit_aux_editor_reset_transaction_with_runtime_counters_admission( + AuxEditorResetTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch, + origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_delete".to_string()), + touched_fields: &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + plugin_instances_reset: PluginInstancesResetScope::Mode(id.clone()), + }, + admission, + runtime_counters, + |store| { + let Some(plan) = plan_custom_tab_delete(store, &id) else { + return Ok(Err(store.selected_key_type.clone())); + }; + delete_custom_tab_data(store, &id, &plan); + Ok(Ok(( + store.custom_tabs.clone(), + store.tab_order.clone(), + store.bar_count, + store.selected_key_type.clone(), + store.tab_note_overrides.clone(), + ))) + }, + ) + })?; + let (custom_tabs, tab_order, bar_count, selected_key_type, tab_note_overrides) = + match transaction.value { + Ok(result) => result, + Err(selected) => { + return Ok(CustomTabDeleteResult { + success: false, + selected, + error: Some("not-found".to_string()), + }); + } + }; + if key_runtime_applied { + publish_editor_change_after_key_runtime(state, app, &transaction.change); + } else { + publish_editor_change(state, app, &transaction.change, false); + } + publish_reset_plugin_instances(app, &transaction.change); + state.unwatch_tab_css(&id); + + emit_best_effort( + app, + "customTabs:changed", + &CustomTabChangePayload { + custom_tabs, + tab_order, + bar_count, + selected_key_type: selected_key_type.clone(), + selection_authoritative: true, + }, + ); + emit_best_effort(app, "keys:changed", &transaction.change.document.keys); + emit_best_effort( + app, + "positions:changed", + &transaction.change.document.key_positions, + ); + emit_best_effort( + app, + "statPositions:changed", + &transaction.change.document.stat_positions, + ); + emit_best_effort( + app, + "graphPositions:changed", + &transaction.change.document.graph_positions, + ); + emit_best_effort( + app, + "knobPositions:changed", + &transaction.change.document.knob_positions, + ); + emit_best_effort( + app, + "layerGroups:changed", + &transaction.change.document.layer_groups, + ); + emit_best_effort(app, "tabNote:changed_all", &tab_note_overrides); + emit_best_effort( + app, + "tabCss:changed", + &crate::commands::editor::css::TabCssResponse { + tab_id: id, + css: None, + }, + ); + emit_best_effort( + app, + "keys:mode-changed", + &serde_json::json!({ "mode": &selected_key_type }), + ); + emit_aux_history_status(app, &transaction.change); + + Ok(CustomTabDeleteResult { + success: true, + selected: selected_key_type, + error: None, + }) +} + +#[derive(Serialize)] +pub struct CustomTabSelectResult { + pub success: bool, + pub selected: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[tauri::command] +pub async fn custom_tabs_select( + app: AppHandle, + window: WebviewWindow, + id: String, + observed_history_epoch: Option, +) -> CmdResult { + run_history_mutation( + app, + window.label().to_string(), + move |app, state, admission| { + custom_tabs_select_inner(state, app, id, observed_history_epoch, admission) + }, + ) + .await +} + +fn custom_tabs_select_inner( + state: &AppState, + app: &AppHandle, + id: String, + observed_history_epoch: Option, + admission: HistoryAdmissionLease, +) -> CmdResult { + let requested = id; + let transaction = state.store.commit_aux_editor_transaction_with_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::Mode, + observed_history_epoch, + origin: EditorCommitOrigin::LegacyAdapter("custom_tabs_select".to_string()), + touched_fields: &[], + }, + admission, + move |store| Ok(select_mode_if_available(store, &requested)), + )?; + let (success, selected) = transaction.value.clone(); + if success + && state.apply_committed_editor_keys_without_counters( + transaction.change.runtime_publication_generation, + &transaction.change.document.keys, + &selected, + ) + { + emit_best_effort( + app, + "keys:mode-changed", + &serde_json::json!({ "mode": &selected }), + ); + state.refresh_obs_snapshot(); + } + emit_aux_history_status(app, &transaction.change); + + Ok(CustomTabSelectResult { + success, + selected, + error: (!success).then(|| "not-found".to_string()), + }) +} + +/// 커스텀 탭 목록과 선택 모드를 원자적으로 복원 +#[tauri::command] +pub async fn custom_tabs_restore( + app: AppHandle, + window: WebviewWindow, + custom_tabs: Vec, + selected_key_type: String, + observed_history_epoch: Option, +) -> CmdResult<()> { + run_history_mutation( + app, + window.label().to_string(), + move |app, state, admission| { + custom_tabs_restore_inner( + state, + app, + custom_tabs, + selected_key_type, + observed_history_epoch, + admission, + ) + }, + ) + .await +} + +fn custom_tabs_restore_inner( + state: &AppState, + app: &AppHandle, + custom_tabs: Vec, + selected_key_type: String, + observed_history_epoch: Option, + admission: HistoryAdmissionLease, +) -> CmdResult<()> { + let (transaction, _) = + state.commit_editor_transaction_preserving_runtime_counters(app, |runtime_counters| { + state + .store + .commit_aux_editor_transaction_with_runtime_counters_admission( + AuxEditorTransactionOptions { + scope: HistoryScope::CustomTabs, + observed_history_epoch, + origin: EditorCommitOrigin::LegacyAdapter( + "custom_tabs_restore".to_string(), + ), + touched_fields: &[], + }, + admission, + runtime_counters, + move |store| { + let tab_order = normalize_tab_order(&store.tab_order, &custom_tabs); + validate_history_restore_metadata( + &EditorDocumentV1::from_store(store), + &custom_tabs, + &tab_order, + &selected_key_type, + )?; + store.custom_tabs = custom_tabs; + store.tab_order = tab_order; + store.bar_count = normalize_bar_count(store.bar_count, &store.tab_order); + store.selected_key_type = selected_key_type; + Ok(( + store.custom_tabs.clone(), + store.tab_order.clone(), + store.bar_count, + store.selected_key_type.clone(), + )) + }, + ) + })?; + let (custom_tabs, tab_order, bar_count, selected_key_type) = transaction.value; + + state.apply_committed_editor_keys_without_counters( + transaction.change.runtime_publication_generation, + &transaction.change.document.keys, + &selected_key_type, + ); + emit_best_effort( + app, + "customTabs:changed", + &CustomTabChangePayload { + custom_tabs, + tab_order, + bar_count, + selected_key_type: selected_key_type.clone(), + selection_authoritative: true, + }, + ); + emit_best_effort( + app, + "keys:mode-changed", + &serde_json::json!({ "mode": &selected_key_type }), + ); + state.refresh_obs_snapshot(); + emit_aux_history_status(app, &transaction.change); + Ok(()) +} diff --git a/src-tauri/src/commands/keys/keys/tests.rs b/src-tauri/src/commands/keys/keys/tests.rs new file mode 100644 index 000000000..e1b1795c8 --- /dev/null +++ b/src-tauri/src/commands/keys/keys/tests.rs @@ -0,0 +1,1219 @@ +use super::{ + delete_custom_tab_data, plan_custom_tab_delete, rename_custom_tab_metadata, + reorder_change_payload, reorder_tab_metadata, reset_all_editor_data, + reset_all_editor_data_with_images, reset_mode_data, reset_mode_data_with_images, + reset_mode_kind, select_mode_if_available, set_mode_with, ModeResetKind, TabOrderOpV1, +}; +use crate::{ + defaults::{default_keys, default_positions, default_stat_positions}, + keyboard::KeyboardManager, + models::{ + AppStoreData, CustomTab, EditorCommitOrigin, EditorField, GraphPosition, GraphStatType, + GraphType, KeyCounters, KeySlot, KnobPosition, LayerGroupDef, StatPosition, StatType, + TabCss, TabNoteSettings, + }, + state::{ + app_state::KeyCounterEventEmitter, + history::{HistoryDirection, HistoryScope}, + store::PluginInstancesResetScope, + AppState, AppStore, + }, +}; +use std::{cell::Cell, collections::HashSet}; + +const TARGET_TAB: &str = "custom-target"; + +struct NoopCounterEmitter; + +impl KeyCounterEventEmitter for NoopCounterEmitter { + fn emit_key_counters( + &self, + _counters: &KeyCounters, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn emit_key_counter( + &self, + _mode: &str, + _key: &str, + _count: u32, + _session_id: &str, + _revision: u64, + ) -> anyhow::Result<()> { + Ok(()) + } +} + +fn populated_custom_tab_store() -> AppStoreData { + let position = default_positions() + .values() + .next() + .and_then(|positions| positions.first()) + .cloned() + .expect("default position fixture"); + let mut store = AppStoreData { + custom_tabs: vec![ + CustomTab { + id: "custom-before".to_string(), + name: "Before".to_string(), + }, + CustomTab { + id: TARGET_TAB.to_string(), + name: "Target".to_string(), + }, + ], + selected_key_type: TARGET_TAB.to_string(), + ..AppStoreData::default() + }; + store + .keys + .insert(TARGET_TAB.to_string(), vec![KeySlot::from("KeyD")]); + store + .key_positions + .insert(TARGET_TAB.to_string(), vec![position.clone()]); + store.stat_positions.insert( + TARGET_TAB.to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }], + ); + store.graph_positions.insert( + TARGET_TAB.to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: position.clone(), + }], + ); + store.knob_positions.insert( + TARGET_TAB.to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }], + ); + store.layer_groups.insert( + TARGET_TAB.to_string(), + vec![LayerGroupDef { + id: "group".to_string(), + name: "Group".to_string(), + }], + ); + store + .tab_css_overrides + .insert(TARGET_TAB.to_string(), TabCss::default()); + store + .tab_note_overrides + .insert(TARGET_TAB.to_string(), TabNoteSettings::default()); + store.key_counters.insert( + TARGET_TAB.to_string(), + [("KeyD".to_string(), 7)].into_iter().collect(), + ); + store +} + +#[test] +fn deleting_selected_custom_tab_clears_all_tab_scoped_data() { + let mut store = populated_custom_tab_store(); + store.bar_count = 5; + let plan = plan_custom_tab_delete(&store, TARGET_TAB).expect("delete plan"); + + assert_eq!(plan.next_selected, "custom-before"); + delete_custom_tab_data(&mut store, TARGET_TAB, &plan); + + assert!(!store.custom_tabs.iter().any(|tab| tab.id == TARGET_TAB)); + assert!(!store.keys.contains_key(TARGET_TAB)); + assert!(!store.key_positions.contains_key(TARGET_TAB)); + assert!(!store.stat_positions.contains_key(TARGET_TAB)); + assert!(!store.graph_positions.contains_key(TARGET_TAB)); + assert!(!store.knob_positions.contains_key(TARGET_TAB)); + assert!(!store.layer_groups.contains_key(TARGET_TAB)); + assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); + assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); + assert!(!store.key_counters.contains_key(TARGET_TAB)); + assert_eq!(store.selected_key_type, "custom-before"); + assert_eq!(store.bar_count, 4); +} + +#[test] +fn deleting_first_selected_tab_chooses_next_builtin_neighbor() { + let mut store = populated_custom_tab_store(); + store.tab_order = [TARGET_TAB, "4key", "custom-before", "5key", "6key", "8key"] + .map(str::to_string) + .to_vec(); + + let plan = plan_custom_tab_delete(&store, TARGET_TAB).expect("delete plan"); + + assert_eq!(plan.next_selected, "4key"); + assert_eq!( + plan.tab_order, + ["4key", "custom-before", "5key", "6key", "8key"] + ); +} + +#[test] +fn rename_rejects_long_reserved_duplicate_and_builtin_targets() { + let mut store = populated_custom_tab_store(); + + let (_, long_error, _) = rename_custom_tab_metadata(&mut store, TARGET_TAB, "12345678901"); + let (_, reserved_error, _) = rename_custom_tab_metadata(&mut store, TARGET_TAB, "4key"); + let (_, duplicate_error, _) = rename_custom_tab_metadata(&mut store, TARGET_TAB, "Before"); + let (_, builtin_error, _) = rename_custom_tab_metadata(&mut store, "4key", "Built in"); + + assert_eq!(long_error.as_deref(), Some("name-too-long")); + assert_eq!(reserved_error.as_deref(), Some("reserved-name")); + assert_eq!(duplicate_error.as_deref(), Some("duplicate-name")); + assert_eq!(builtin_error.as_deref(), Some("unknown-tab")); + assert_eq!( + store + .custom_tabs + .iter() + .find(|tab| tab.id == TARGET_TAB) + .unwrap() + .name, + "Target" + ); +} + +#[test] +fn reorder_applies_after_unrelated_tab_is_created() { + let mut store = crate::state::migration::normalize_state(AppStoreData::default()); + let unrelated_id = "created-after-drag".to_string(); + store.custom_tabs.push(CustomTab { + id: unrelated_id.clone(), + name: "Created later".to_string(), + }); + store.tab_order = + crate::state::tab_metadata::normalize_tab_order(&store.tab_order, &store.custom_tabs); + + let (snapshot, error, changed) = reorder_tab_metadata( + &mut store, + &TabOrderOpV1::Swap { + a: "4key".to_string(), + b: "5key".to_string(), + }, + ); + + assert!(error.is_none()); + assert!(changed); + assert_eq!( + snapshot.tab_order, + ["5key", "4key", "6key", "8key", unrelated_id.as_str()] + ); +} + +#[test] +fn reorder_applies_after_unrelated_tab_is_deleted() { + let mut store = crate::state::migration::normalize_state(AppStoreData::default()); + let unrelated_id = "deleted-after-drag".to_string(); + store.custom_tabs.push(CustomTab { + id: unrelated_id.clone(), + name: "Deleted later".to_string(), + }); + store.tab_order.push(unrelated_id.clone()); + store.custom_tabs.retain(|tab| tab.id != unrelated_id); + store.tab_order.retain(|id| id != &unrelated_id); + + let (snapshot, error, changed) = reorder_tab_metadata( + &mut store, + &TabOrderOpV1::Swap { + a: "6key".to_string(), + b: "8key".to_string(), + }, + ); + + assert!(error.is_none()); + assert!(changed); + assert_eq!(snapshot.tab_order, ["4key", "5key", "8key", "6key"]); +} + +#[test] +fn reorder_unknown_tab_repairs_noncanonical_metadata() { + let mut store = crate::state::migration::normalize_state(AppStoreData::default()); + store.tab_order = ["4key", "unknown", "4key", "5key"] + .map(str::to_string) + .to_vec(); + store.bar_count = 9; + + let (snapshot, error, changed) = reorder_tab_metadata( + &mut store, + &TabOrderOpV1::Swap { + a: "missing".to_string(), + b: "5key".to_string(), + }, + ); + + assert_eq!(error.as_deref(), Some("unknown-tab")); + assert!(changed); + assert_eq!(snapshot.tab_order, ["4key", "5key", "6key", "8key"]); + assert_eq!(snapshot.bar_count, 4); + assert_eq!(store.tab_order, snapshot.tab_order); + assert_eq!(store.bar_count, snapshot.bar_count); + assert!(reorder_change_payload(&snapshot, changed).is_some()); +} + +#[test] +fn reorder_same_tab_repairs_noncanonical_metadata() { + let mut store = crate::state::migration::normalize_state(AppStoreData::default()); + store.tab_order = ["4key", "unknown", "4key", "5key"] + .map(str::to_string) + .to_vec(); + store.bar_count = 9; + + let (snapshot, error, changed) = reorder_tab_metadata( + &mut store, + &TabOrderOpV1::Swap { + a: "4key".to_string(), + b: "4key".to_string(), + }, + ); + + assert!(error.is_none()); + assert!(changed); + assert_eq!(snapshot.tab_order, ["4key", "5key", "6key", "8key"]); + assert_eq!(snapshot.bar_count, 4); + assert_eq!(store.tab_order, snapshot.tab_order); + assert_eq!(store.bar_count, snapshot.bar_count); + assert!(reorder_change_payload(&snapshot, changed).is_some()); +} + +#[test] +fn reorder_rejects_each_missing_operand_with_current_snapshot() { + for op in [ + TabOrderOpV1::Swap { + a: "missing".to_string(), + b: "5key".to_string(), + }, + TabOrderOpV1::Swap { + a: "4key".to_string(), + b: "missing".to_string(), + }, + ] { + let mut store = crate::state::migration::normalize_state(AppStoreData::default()); + let before = store.clone(); + + let (snapshot, error, changed) = reorder_tab_metadata(&mut store, &op); + + assert_eq!(error.as_deref(), Some("unknown-tab")); + assert!(!changed); + assert_eq!(snapshot.custom_tabs, before.custom_tabs); + assert_eq!(snapshot.tab_order, before.tab_order); + assert_eq!(snapshot.bar_count, before.bar_count); + assert_eq!(snapshot.selected_key_type, before.selected_key_type); + assert_eq!(store, before); + } +} + +#[test] +fn reorder_same_tab_is_a_no_op() { + let mut store = crate::state::migration::normalize_state(AppStoreData::default()); + let before = store.clone(); + + let (snapshot, error, changed) = reorder_tab_metadata( + &mut store, + &TabOrderOpV1::Swap { + a: "4key".to_string(), + b: "4key".to_string(), + }, + ); + + assert!(error.is_none()); + assert!(!changed); + assert_eq!(snapshot.tab_order, before.tab_order); + assert_eq!(store, before); +} + +#[test] +fn reorder_true_no_op_preserves_history_events_and_store() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + let before = store.snapshot(); + let history_before = store.history_status(); + + for (op, expected_error) in [ + ( + TabOrderOpV1::Swap { + a: "4key".to_string(), + b: "4key".to_string(), + }, + None, + ), + ( + TabOrderOpV1::Swap { + a: "missing".to_string(), + b: "5key".to_string(), + }, + Some("unknown-tab"), + ), + ] { + let transaction = store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("tabs_reorder_test".to_string()), + &[], + |data| Ok(reorder_tab_metadata(data, &op)), + ) + .unwrap(); + let (snapshot, error, changed) = &transaction.value; + + assert_eq!(error.as_deref(), expected_error); + assert!(!changed); + assert!(transaction.change.event.is_none()); + assert!(transaction.change.history_status.is_none()); + assert!(reorder_change_payload(snapshot, *changed).is_none()); + assert_eq!(store.snapshot(), before); + } + let history_after = store.history_status(); + assert_eq!( + history_after.history_revision, + history_before.history_revision + ); + assert!(!history_after.can_undo); + store.flush_and_shutdown().unwrap(); +} + +#[test] +fn reorder_history_undo_restores_tab_order_and_bar_count_together() { + let directory = tempfile::tempdir().unwrap(); + let tab_id = "reorder-tab".to_string(); + let mut data = crate::state::migration::normalize_state(AppStoreData::default()); + data.custom_tabs.push(CustomTab { + id: tab_id.clone(), + name: "Reorder".to_string(), + }); + data.keys.insert(tab_id.clone(), Vec::new()); + data.key_positions.insert(tab_id.clone(), Vec::new()); + data.tab_order = ["4key", "5key", "6key", "8key", tab_id.as_str()] + .map(str::to_string) + .to_vec(); + data.bar_count = 2; + crate::state::native_element_id::backfill_store_element_ids(&mut data); + std::fs::write( + directory.path().join("store.json"), + serde_json::to_vec_pretty(&data).unwrap(), + ) + .unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + let before = store.snapshot(); + let reordered = [tab_id.as_str(), "5key", "6key", "8key", "4key"] + .map(str::to_string) + .to_vec(); + + store + .commit_aux_editor_transaction( + HistoryScope::CustomTabs, + None, + EditorCommitOrigin::LegacyAdapter("tabs_reorder_test".to_string()), + &[], + |data| { + let (_, error, changed) = reorder_tab_metadata( + data, + &TabOrderOpV1::Swap { + a: "4key".to_string(), + b: tab_id.clone(), + }, + ); + assert!(error.is_none()); + assert!(changed); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(store.snapshot().tab_order, reordered); + assert_eq!(store.snapshot().bar_count, 2); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + + assert_eq!(store.snapshot().tab_order, before.tab_order); + assert_eq!(store.snapshot().bar_count, before.bar_count); + assert!(undo.status.can_redo); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + let counters = store.snapshot().key_counters; + let redo = store + .apply_history_operation(HistoryDirection::Redo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + + assert_eq!(store.snapshot().tab_order, reordered); + assert_eq!(store.snapshot().bar_count, 2); + assert!(redo.status.can_undo); + store.flush_and_shutdown().unwrap(); +} + +#[test] +fn tab_order_op_rejects_unknown_kind_and_unknown_fields() { + let unknown_kind = serde_json::from_value::(serde_json::json!({ + "kind": "move", + "a": "4key", + "b": "5key" + })); + let unknown_field = serde_json::from_value::(serde_json::json!({ + "kind": "swap", + "a": "4key", + "b": "5key", + "extra": true + })); + + assert!(unknown_kind.is_err()); + assert!(unknown_field.is_err()); +} + +#[test] +fn reset_all_clears_knob_positions_and_zeroes_default_counters() { + let mut store = populated_custom_tab_store(); + reset_all_editor_data( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + ); + + assert!(store.knob_positions.is_empty()); + assert!(store.custom_tabs.is_empty()); + assert_eq!(store.selected_key_type, "4key"); + assert_eq!(store.stat_positions.len(), default_stat_positions().len()); + assert!(store + .key_counters + .values() + .flat_map(|mode| mode.values()) + .all(|count| *count == 0)); +} + +#[test] +fn reset_all_issues_a_fresh_globally_unique_id_generation_each_time() { + let mut store = populated_custom_tab_store(); + reset_all_editor_data( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + ); + let first = store + .key_positions + .values() + .flatten() + .map(|position| position.id.clone()) + .collect::>(); + let first_count = store.key_positions.values().map(Vec::len).sum::(); + + reset_all_editor_data( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + ); + let second = store + .key_positions + .values() + .flatten() + .map(|position| position.id.clone()) + .collect::>(); + + assert_eq!(first.len(), first_count); + assert_eq!(second.len(), first_count); + assert!(first.is_disjoint(&second)); + assert!(second + .iter() + .all(|id| crate::state::native_element_id::is_valid_element_id(id))); +} + +#[test] +fn reset_all_migrates_default_data_url_images_immediately() { + let dir = std::env::temp_dir().join(format!( + "dmnote-reset-all-default-images-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut store = populated_custom_tab_store(); + + reset_all_editor_data_with_images( + &mut store, + default_keys(), + default_positions(), + default_stat_positions(), + &dir, + ); + + let positions = store + .key_positions + .values() + .flatten() + .chain( + store + .stat_positions + .values() + .flatten() + .map(|stat| &stat.position), + ) + .chain( + store + .graph_positions + .values() + .flatten() + .map(|graph| &graph.position), + ) + .chain( + store + .knob_positions + .values() + .flatten() + .map(|knob| &knob.position), + ) + .collect::>(); + let image_paths = positions + .iter() + .flat_map(|position| [&position.active_image, &position.inactive_image]) + .flatten() + .filter(|image| !image.is_empty()) + .collect::>(); + assert!(!image_paths.is_empty()); + assert!(image_paths.iter().all(|image| !image.starts_with("data:"))); + assert!(image_paths + .iter() + .all(|image| std::path::Path::new(image.as_str()).is_file())); + assert!(store + .stat_positions + .values() + .flatten() + .all(|stat| { crate::state::native_element_id::is_valid_element_id(&stat.position.id) })); + + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn custom_mode_reset_is_supported_and_preserves_tab_identity() { + let mut store = populated_custom_tab_store(); + let tabs_before = store.custom_tabs.clone(); + let kind = reset_mode_kind(&store, TARGET_TAB); + + assert_eq!(kind, Some(ModeResetKind::Custom)); + reset_mode_data(&mut store, TARGET_TAB, kind.unwrap()); + + assert_eq!(store.custom_tabs, tabs_before); + assert!(store.keys[TARGET_TAB].is_empty()); + assert!(store.key_positions[TARGET_TAB].is_empty()); + assert!(store.stat_positions[TARGET_TAB].is_empty()); + assert!(store.graph_positions[TARGET_TAB].is_empty()); + assert!(store.knob_positions[TARGET_TAB].is_empty()); + assert!(!store.layer_groups.contains_key(TARGET_TAB)); + assert!(!store.tab_css_overrides.contains_key(TARGET_TAB)); + assert!(!store.tab_note_overrides.contains_key(TARGET_TAB)); + assert!(store.key_counters[TARGET_TAB].is_empty()); +} + +#[test] +fn default_mode_reset_clears_knob_positions() { + let mut store = AppStoreData::default(); + store.knob_positions.insert( + "4key".to_string(), + populated_custom_tab_store().knob_positions[TARGET_TAB].clone(), + ); + + reset_mode_data(&mut store, "4key", ModeResetKind::Default); + + assert!(store.knob_positions["4key"].is_empty()); + assert_eq!( + store.stat_positions["4key"].len(), + default_stat_positions()["4key"].len() + ); +} + +#[test] +fn ghost_mode_request_leaves_store_keyboard_and_events_unchanged() { + let mut store = AppStoreData { + selected_key_type: "8key".to_string(), + ..AppStoreData::default() + }; + store + .keys + .insert("ghost-mode".to_string(), vec![KeySlot::from("KeyA")]); + let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); + let commit_calls = Cell::new(0); + let emit_calls = Cell::new(0); + + let response = set_mode_with( + &store, + "ghost-mode".to_string(), + |candidate| { + commit_calls.set(commit_calls.get() + 1); + Ok(candidate) + }, + |effective| { + keyboard.set_mode(effective.to_string()); + emit_calls.set(emit_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert!(!response.success); + assert_eq!(response.mode, "8key"); + assert_eq!(store.selected_key_type, "8key"); + assert_eq!(keyboard.current_mode(), "8key"); + assert_eq!(commit_calls.get(), 0); + assert_eq!(emit_calls.get(), 0); +} + +#[test] +fn absent_mode_request_remains_a_no_op() { + let store = AppStoreData { + selected_key_type: "8key".to_string(), + ..AppStoreData::default() + }; + let keyboard = KeyboardManager::new(store.keys.clone(), "8key"); + let commit_calls = Cell::new(0); + let emit_calls = Cell::new(0); + + let response = set_mode_with( + &store, + "missing-mode".to_string(), + |candidate| { + commit_calls.set(commit_calls.get() + 1); + Ok(candidate) + }, + |effective| { + keyboard.set_mode(effective.to_string()); + emit_calls.set(emit_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert!(!response.success); + assert_eq!(response.mode, "8key"); + assert_eq!(store.selected_key_type, "8key"); + assert_eq!(keyboard.current_mode(), "8key"); + assert_eq!(commit_calls.get(), 0); + assert_eq!(emit_calls.get(), 0); +} + +#[test] +fn selection_after_concurrent_delete_uses_locked_store_state() { + let stale_snapshot = populated_custom_tab_store(); + assert!(super::is_selectable_mode(&stale_snapshot, TARGET_TAB)); + + let mut locked_store = stale_snapshot; + let delete_plan = plan_custom_tab_delete(&locked_store, TARGET_TAB).unwrap(); + delete_custom_tab_data(&mut locked_store, TARGET_TAB, &delete_plan); + let selected_after_delete = locked_store.selected_key_type.clone(); + + let (success, selected) = select_mode_if_available(&mut locked_store, TARGET_TAB); + + assert!(!success); + assert_eq!(selected, selected_after_delete); + assert_eq!(locked_store.selected_key_type, selected_after_delete); + assert!(!locked_store.keys.contains_key(TARGET_TAB)); +} + +#[test] +fn reset_mode_with_changed_keys_preserves_other_live_mode_counters() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let customized = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET KEY"); + Ok(()) + }, + ) + .unwrap(); + drop(customized); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let reset_mode = "4key"; + let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); + let preserved_mode = "5key"; + let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); + for expected in 1..=3 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), + Some(expected) + ); + } + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), + Some(expected) + ); + } + assert_eq!( + state.store.snapshot().key_counters[preserved_mode][&preserved_key], + 0 + ); + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(reset_mode.to_string()), + admission, + |data| { + reset_mode_data_with_images( + data, + reset_mode, + ModeResetKind::Default, + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + assert!(key_runtime_applied); + assert!(transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + + assert_eq!( + state.snapshot_key_counters()[preserved_mode][&preserved_key], + 7 + ); + assert!(state.snapshot_key_counters()[reset_mode] + .values() + .all(|count| *count == 0)); + assert_eq!( + state.store.snapshot().key_counters[preserved_mode][&preserved_key], + 7 + ); + state.shutdown(); +} + +#[test] +fn reset_mode_with_default_keys_applies_counter_only_reset_to_runtime() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let mode = "4key"; + let key = state.store.snapshot().keys[mode][0].canonical(); + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, &key), + Some(expected) + ); + } + assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); + let generation_before = state.store.runtime_publication_generation(); + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(mode.to_string()), + admission, + |data| { + reset_mode_data_with_images(data, mode, ModeResetKind::Default, directory.path()); + Ok(()) + }, + ) + .unwrap(); + assert!(key_runtime_applied); + assert!(!transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + assert!(transaction.change.runtime_publication_generation > generation_before); + + assert_eq!(state.snapshot_key_counters()[mode][&key], 0); + assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); + state.shutdown(); +} + +#[test] +fn reset_mode_replays_queued_increment_for_unchanged_mapping() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let reset_mode = "4key"; + let reset_key = state.store.snapshot().keys[reset_mode][0].canonical(); + let preserved_mode = "5key"; + let preserved_key = state.store.snapshot().keys[preserved_mode][0].canonical(); + for expected in 1..=3 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key), + Some(expected) + ); + } + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, preserved_mode, &preserved_key), + Some(expected) + ); + } + + let admission = state.store.admit_editor_mutation().unwrap(); + let (_, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(reset_mode.to_string()), + admission, + |data| { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, reset_mode, &reset_key,), + None + ); + reset_mode_data_with_images( + data, + reset_mode, + ModeResetKind::Default, + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert_eq!(state.snapshot_key_counters()[reset_mode][&reset_key], 1); + assert_eq!( + state.snapshot_key_counters()[preserved_mode][&preserved_key], + 7 + ); + assert_eq!( + state.store.snapshot().key_counters[reset_mode][&reset_key], + 0 + ); + assert_eq!( + state.store.snapshot().key_counters[preserved_mode][&preserved_key], + 7 + ); + state.shutdown(); +} + +#[test] +fn reset_mode_drops_queued_increment_for_replaced_key() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let mode = "4key"; + let replaced_key = "QA REPLACED KEY"; + let setup = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.keys.get_mut(mode).unwrap()[0] = KeySlot::from(replaced_key); + Ok(()) + }, + ) + .unwrap(); + drop(setup); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + + let admission = state.store.admit_editor_mutation().unwrap(); + let (_, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_mode".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::Mode(mode.to_string()), + admission, + |data| { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, replaced_key), + None + ); + reset_mode_data_with_images(data, mode, ModeResetKind::Default, directory.path()); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert!(!state.snapshot_key_counters()[mode].contains_key(replaced_key)); + assert!(!state.store.snapshot().key_counters[mode].contains_key(replaced_key)); + state.shutdown(); + drop(state); + let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); + assert!(!reloaded.snapshot().key_counters[mode].contains_key(replaced_key)); + reloaded.flush_and_shutdown().unwrap(); +} + +#[test] +fn reset_all_drops_queued_increment_for_removed_custom_mode() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let mode = "qa-removed-custom-mode"; + let key = "QA REMOVED KEY"; + let mut position = default_positions()["4key"][0].clone(); + position.id = uuid::Uuid::new_v4().to_string(); + let setup = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-queue-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.custom_tabs.push(CustomTab { + id: mode.to_string(), + name: "Removed during reset".to_string(), + }); + data.keys.insert(mode.to_string(), vec![KeySlot::from(key)]); + data.key_positions.insert(mode.to_string(), vec![position]); + data.selected_key_type = mode.to_string(); + Ok(()) + }, + ) + .unwrap(); + drop(setup); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + + let admission = state.store.admit_editor_mutation().unwrap(); + let (_, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::All, + admission, + |data| { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, key), + None + ); + reset_all_editor_data_with_images( + data, + default_keys(), + default_positions(), + default_stat_positions(), + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert!(!state.snapshot_key_counters().contains_key(mode)); + assert!(!state.store.snapshot().key_counters.contains_key(mode)); + state.shutdown(); + drop(state); + let reloaded = AppStore::initialize_for_test(directory.path()).unwrap(); + assert!(!reloaded.snapshot().key_counters.contains_key(mode)); + reloaded.flush_and_shutdown().unwrap(); +} + +#[test] +fn reset_all_with_default_keys_applies_counter_only_reset_to_runtime() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + let mode = "4key"; + let key = state.store.snapshot().keys[mode][0].canonical(); + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, &key), + Some(expected) + ); + } + assert_eq!(state.store.snapshot().key_counters[mode][&key], 0); + let generation_before = state.store.runtime_publication_generation(); + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::All, + admission, + |data| { + reset_all_editor_data_with_images( + data, + default_keys(), + default_positions(), + default_stat_positions(), + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + assert!(key_runtime_applied); + assert!(!transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + assert!(transaction.change.runtime_publication_generation > generation_before); + + assert_eq!(state.snapshot_key_counters()[mode][&key], 0); + assert!(state + .store + .snapshot() + .key_counters + .values() + .flat_map(|counters| counters.values()) + .all(|count| *count == 0)); + state.shutdown(); +} + +#[test] +fn reset_all_with_changed_keys_zeroes_every_live_counter() { + let directory = tempfile::tempdir().unwrap(); + let store = AppStore::initialize_for_test(directory.path()).unwrap(); + store + .update(|data| data.key_counter_enabled = true) + .unwrap(); + let customized = store + .commit_legacy_editor_transaction( + EditorCommitOrigin::LegacyAdapter("reset-all-test-setup".to_string()), + &[EditorField::Keys, EditorField::KeyPositions], + |data| { + data.keys.get_mut("4key").unwrap()[0] = KeySlot::from("QA RESET ALL KEY"); + Ok(()) + }, + ) + .unwrap(); + drop(customized); + let state = AppState::initialize(store).unwrap(); + let emitter = NoopCounterEmitter; + for mode in ["4key", "5key"] { + let key = state.store.snapshot().keys[mode][0].canonical(); + for expected in 1..=7 { + assert_eq!( + state.increment_key_counter_and_emit(&emitter, mode, &key), + Some(expected) + ); + } + } + + let admission = state.store.admit_editor_mutation().unwrap(); + let (transaction, key_runtime_applied) = state + .commit_legacy_editor_reset_preserving_runtime_counters( + &emitter, + EditorCommitOrigin::LegacyAdapter("keys_reset_all".to_string()), + &[ + EditorField::Keys, + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + EditorField::LayerGroups, + ], + PluginInstancesResetScope::All, + admission, + |data| { + reset_all_editor_data_with_images( + data, + default_keys(), + default_positions(), + default_stat_positions(), + directory.path(), + ); + Ok(()) + }, + ) + .unwrap(); + + assert!(key_runtime_applied); + assert!(transaction + .change + .result + .changed_fields + .contains(&EditorField::Keys)); + assert_eq!(transaction.change.document.keys, *default_keys()); + assert!(state + .snapshot_key_counters() + .values() + .flat_map(|counters| counters.values()) + .all(|count| *count == 0)); + assert!(state + .store + .snapshot() + .key_counters + .values() + .flat_map(|counters| counters.values()) + .all(|count| *count == 0)); + state.shutdown(); +} diff --git a/src-tauri/src/commands/keys/sound.rs b/src-tauri/src/commands/keys/sound.rs index 8159f5bcc..1dcc7d8a9 100644 --- a/src-tauri/src/commands/keys/sound.rs +++ b/src-tauri/src/commands/keys/sound.rs @@ -33,6 +33,21 @@ use crate::state::{ const SUPPORTED_SOUND_EXTENSIONS: [&str; 8] = ["wav", "mp3", "ogg", "flac", "m4a", "aac", "aif", "aiff"]; +mod library; +mod processed_wav; + +use library::sound_list_inner; +#[cfg(test)] +use library::{ + apply_sound_scan_to_library, sound_library_needs_reconcile, stale_sound_library_keys, +}; +#[cfg(test)] +use processed_wav::{ + backup_path_for, ensure_existing_sound_edit_target, replace_processed_wav_with, + restore_interrupted_processed_wav_backup_with, +}; +use processed_wav::{sound_save_processed_wav_inner, sound_update_processed_wav_inner}; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SoundReferenceChangeEvent { Key, @@ -203,216 +218,6 @@ pub async fn sound_list(app: tauri::AppHandle) -> CmdResult> run_blocking(app, sound_list_inner).await } -struct SoundScan { - items: Vec, - library_at_scan: std::collections::HashMap, - seen_paths: HashSet, - scan_complete: bool, -} - -fn sound_list_inner(app: &tauri::AppHandle, state: &AppState) -> CmdResult> { - let sounds_dir = ensure_sounds_dir(app)?; - let scan = { - let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - let recovery_complete = state.store.prepare_sound_listing_while_locked()?; - let library = state.store.with_state(|s| s.sound_library.clone()); - scan_sounds_dir(&sounds_dir, library, recovery_complete)? - }; - let SoundScan { - mut items, - library_at_scan, - seen_paths, - scan_complete, - } = scan; - - if sound_library_needs_reconcile(&library_at_scan, &seen_paths, scan_complete) { - let ticket = issue_mutation_ticket(app)?; - ticket.run(|| -> CmdResult<()> { - // 잠금 순서: 번호표 turn → PROCESSED_WAV 잠금 (sound_delete와 동일) - let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - state.store.update(|s| { - apply_sound_scan_to_library( - &mut s.sound_library, - &library_at_scan, - &seen_paths, - scan_complete, - &|key| Path::new(key).exists(), - ); - })?; - Ok(()) - })?; - } - - // 내장 사운드 우선, 이후 최신순 - items.sort_by(|a, b| { - let a_builtin = a.source == SoundSource::Builtin; - let b_builtin = b.source == SoundSource::Builtin; - b_builtin - .cmp(&a_builtin) - .then_with(|| { - b.modified_at_ms - .unwrap_or_default() - .cmp(&a.modified_at_ms.unwrap_or_default()) - }) - .then_with(|| a.file_name.cmp(&b.file_name)) - }); - - Ok(items) -} - -fn scan_sounds_dir( - sounds_dir: &Path, - library: std::collections::HashMap, - recovery_complete: bool, -) -> CmdResult { - let mut items = Vec::new(); - let mut seen_paths = HashSet::new(); - let mut scan_complete = recovery_complete; - - let entries = fs::read_dir(sounds_dir) - .map_err(|e| CommandError::msg(format!("사운드 디렉토리 읽기 실패: {e}")))?; - - for entry_result in entries { - let entry = match entry_result { - Ok(entry) => entry, - Err(error) => { - scan_complete = false; - log::warn!("[Sounds] 사운드 항목 열거 실패: {error}"); - continue; - } - }; - - let path = entry.path(); - let file_type = match entry.file_type() { - Ok(file_type) => file_type, - Err(error) => { - scan_complete = false; - log::warn!( - "[Sounds] 사운드 항목 형식 확인 실패 ('{}'): {error}", - path.display() - ); - continue; - } - }; - if file_type.is_dir() || !is_supported_sound_file(&path) { - continue; - } - - let metadata = match entry.metadata() { - Ok(metadata) => metadata, - Err(error) => { - scan_complete = false; - log::warn!( - "[Sounds] 사운드 메타데이터 확인 실패 ('{}'): {error}", - path.display() - ); - continue; - } - }; - if !metadata.is_file() { - continue; - } - - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default() - .to_string(); - if file_name.is_empty() { - continue; - } - - let path_key = normalize_path_string(&path); - seen_paths.insert(path_key.clone()); - - let entry_meta = library.get(&path_key).cloned().unwrap_or_default(); - - let modified_at_ms = metadata.modified().ok().and_then(|modified| { - modified - .duration_since(SystemTime::UNIX_EPOCH) - .ok() - .map(|duration| duration.as_millis() as u64) - }); - - items.push(SoundListItem { - sound_path: path_key, - file_name, - size_bytes: metadata.len(), - modified_at_ms, - hidden: entry_meta.hidden, - enabled: !entry_meta.hidden, - source: entry_meta.source, - original_path: entry_meta.original_path, - trim_start_ratio: entry_meta.trim_start_ratio, - trim_end_ratio: entry_meta.trim_end_ratio, - display_name: entry_meta.display_name, - }); - } - - Ok(SoundScan { - items, - library_at_scan: library, - seen_paths, - scan_complete, - }) -} - -// 스캔 결과가 라이브러리와 다른가 - 같으면 번호표를 받지 않는다. -// 실제 적용은 turn 안의 apply_sound_scan_to_library (디스크 재확인 포함) -fn sound_library_needs_reconcile( - library: &std::collections::HashMap, - seen_paths: &HashSet, - scan_complete: bool, -) -> bool { - seen_paths.iter().any(|key| !library.contains_key(key)) - || !stale_sound_library_keys(library, seen_paths, scan_complete).is_empty() -} - -// 라이브러리를 스캔 결과에 맞춘다. 스캔~turn 사이의 sound_delete·sound_load· -// sound_update_processed_wav를 존중해 삽입·삭제 양쪽 모두 디스크 실재를 다시 확인하고, -// 삭제 후보는 스캔 시점에 있던 키로 한정한다 -fn apply_sound_scan_to_library( - library: &mut std::collections::HashMap, - library_at_scan: &std::collections::HashMap, - seen_paths: &HashSet, - scan_complete: bool, - exists: &dyn Fn(&str) -> bool, -) { - for key in seen_paths { - if !library.contains_key(key) && exists(key) { - library.insert(key.clone(), SoundLibraryEntry::default()); - } - } - if !scan_complete { - return; - } - let stale: Vec = library - .keys() - .filter(|key| { - library_at_scan.contains_key(*key) && !seen_paths.contains(*key) && !exists(key) - }) - .cloned() - .collect(); - for key in stale { - library.remove(&key); - } -} - -fn stale_sound_library_keys( - library: &std::collections::HashMap, - seen_paths: &HashSet, - scan_complete: bool, -) -> Vec { - if !scan_complete { - return Vec::new(); - } - library - .keys() - .filter(|key| !seen_paths.contains(*key)) - .cloned() - .collect() -} - #[tauri::command] pub async fn sound_set_hidden( app: tauri::AppHandle, @@ -756,117 +561,6 @@ pub async fn sound_save_processed_wav( .await } -fn sound_save_processed_wav_inner( - app: &tauri::AppHandle, - state: &AppState, - request: SoundSaveProcessedWavRequest, -) -> CmdResult { - let encoded = request.wav_base64.trim(); - if encoded.is_empty() { - return Ok(SoundSaveProcessedWavResponse { - success: false, - error: Some("사운드 데이터가 비어 있습니다.".to_string()), - sound_path: None, - }); - } - - let wav_bytes = BASE64_STANDARD - .decode(encoded) - .map_err(|e| CommandError::msg(format!("사운드 데이터 디코딩 실패: {e}")))?; - - let is_valid_wav = wav_bytes.len() >= 12 - && wav_bytes.get(0..4) == Some(b"RIFF") - && wav_bytes.get(8..12) == Some(b"WAVE"); - if !is_valid_wav { - return Ok(SoundSaveProcessedWavResponse { - success: false, - error: Some("유효한 WAV 데이터가 아닙니다.".to_string()), - sound_path: None, - }); - } - - let sounds_dir = ensure_sounds_dir(app)?; - - let base_name = request - .file_name - .as_deref() - .map(|n| n.trim()) - .filter(|n| !n.is_empty()) - .map(|n| { - let sanitized: String = n - .chars() - .map(|c| { - if c == '/' - || c == '\\' - || c == ':' - || c == '*' - || c == '?' - || c == '"' - || c == '<' - || c == '>' - || c == '|' - { - '_' - } else { - c - } - }) - .collect(); - sanitized - }) - .unwrap_or_else(|| Uuid::new_v4().to_string()); - - let mut dest_path = sounds_dir.join(format!("{}.wav", base_name)); - if dest_path.exists() { - dest_path = sounds_dir.join(format!("{}_{}.wav", base_name, Uuid::new_v4())); - } - fs::write(&dest_path, wav_bytes) - .map_err(|e| CommandError::msg(format!("편집된 사운드 저장 실패: {e}")))?; - - // 원본 파일 저장 - let mut original_rel_path: Option = None; - if let Some(ref orig_b64) = request.original_base64 { - let trimmed_orig = orig_b64.trim(); - if !trimmed_orig.is_empty() { - let orig_bytes = BASE64_STANDARD - .decode(trimmed_orig) - .map_err(|e| CommandError::msg(format!("원본 사운드 데이터 디코딩 실패: {e}")))?; - let orig_ext = request - .original_extension - .as_deref() - .unwrap_or("wav") - .to_lowercase(); - let originals_dir = ensure_originals_dir(app)?; - let orig_filename = format!("{}.{}", Uuid::new_v4(), orig_ext); - let orig_path = originals_dir.join(&orig_filename); - fs::write(&orig_path, orig_bytes) - .map_err(|e| CommandError::msg(format!("원본 사운드 저장 실패: {e}")))?; - original_rel_path = Some(format!("originals/{}", orig_filename)); - } - } - - let dest_path_str = normalize_path_string(&dest_path); - state.store.update(|s| { - s.sound_library.insert( - dest_path_str.clone(), - SoundLibraryEntry { - hidden: false, - source: SoundSource::Local, - original_path: original_rel_path.clone(), - trim_start_ratio: request.trim_start_ratio, - trim_end_ratio: request.trim_end_ratio, - display_name: request.file_name.clone(), - }, - ); - })?; - - Ok(SoundSaveProcessedWavResponse { - success: true, - error: None, - sound_path: Some(dest_path_str), - }) -} - #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SoundLoadOriginalResponse { @@ -954,248 +648,6 @@ pub async fn sound_update_processed_wav( .await } -fn sound_update_processed_wav_inner( - app: &tauri::AppHandle, - state: &AppState, - request: SoundUpdateProcessedWavRequest, -) -> CmdResult { - let sounds_dir = ensure_sounds_dir(app)?; - let validated_path = validate_sound_path(&sounds_dir, &request.sound_path)?; - let path_key = resolve_stored_sound_path_key(state, &validated_path); - - // 내장 사운드 덮어쓰기 차단 (OBS/플러그인 경유 호출 포함) - let is_builtin = state.store.with_state(|s| { - s.sound_library - .get(&path_key) - .is_some_and(|entry| entry.source == SoundSource::Builtin) - }); - if is_builtin { - return Err(CommandError::msg("내장 사운드는 편집할 수 없습니다.")); - } - - let wav_bytes = BASE64_STANDARD - .decode(request.wav_base64.trim()) - .map_err(|e| CommandError::msg(format!("사운드 데이터 디코딩 실패: {e}")))?; - - let is_valid_wav = wav_bytes.len() >= 12 - && wav_bytes.get(0..4) == Some(b"RIFF") - && wav_bytes.get(8..12) == Some(b"WAVE"); - if !is_valid_wav { - return Ok(SoundUpdateProcessedWavResponse { - success: false, - error: Some("유효한 WAV 데이터가 아닙니다.".to_string()), - }); - } - - let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - state - .store - .recover_interrupted_processed_wav_replacements_while_locked()?; - ensure_existing_sound_edit_target(&validated_path)?; - let pending = PendingProcessedWavReplacement { - sound_path: normalize_path_string(&validated_path), - had_original: validated_path.exists(), - }; - state.store.update(|store| { - store.pending_processed_wav_replacement = Some(pending.clone()); - })?; - - let replacement_result = replace_processed_wav_with( - &validated_path, - &wav_bytes, - || { - state.store.update(|store| { - if let Some(entry) = store.sound_library.get_mut(&path_key) { - entry.trim_start_ratio = request.trim_start_ratio; - entry.trim_end_ratio = request.trim_end_ratio; - if let Some(ref name) = request.display_name { - entry.display_name = Some(name.clone()); - } - } - store.pending_processed_wav_replacement = None; - })?; - Ok(()) - }, - |path, bytes| prepare_atomic_replace(path, bytes, "processed-wav"), - PreparedAtomicReplace::commit, - |path| fs::remove_file(path), - ); - if let Err(error) = replacement_result { - // 파일 롤백 자체가 실패했을 수 있으므로 복구 표식은 다음 재시도까지 보존 - return Err(CommandError::msg(format!( - "편집된 사운드 저장 실패: {error}" - ))); - } - - // 키음 엔진 캐시에서 이전 디코딩 결과 무효화 - state.key_sound_invalidate_file_cache(&path_key); - - Ok(SoundUpdateProcessedWavResponse { - success: true, - error: None, - }) -} - -fn ensure_existing_sound_edit_target(path: &Path) -> CmdResult<()> { - match path.try_exists() { - Ok(true) if path.is_file() => Ok(()), - Ok(true) => Err(CommandError::msg("대상 사운드 경로가 파일이 아닙니다.")), - Ok(false) => Err(CommandError::msg("편집할 사운드 파일을 찾을 수 없습니다.")), - Err(error) => Err(CommandError::msg(format!( - "편집할 사운드 파일 확인 실패: {error}" - ))), - } -} - -fn replace_processed_wav_with( - target_path: &Path, - wav_bytes: &[u8], - save_metadata: Save, - prepare: Prepare, - commit: Commit, - cleanup_backup: Cleanup, -) -> CmdResult<()> -where - Save: FnOnce() -> CmdResult<()>, - Prepare: FnOnce(&Path, &[u8]) -> anyhow::Result, - Commit: FnOnce(PreparedAtomicReplace) -> anyhow::Result<()>, - Cleanup: FnOnce(&Path) -> std::io::Result<()>, -{ - let backup_path = backup_path_for(target_path)?; - restore_interrupted_processed_wav_backup(target_path, &backup_path)?; - - if target_path.exists() && !target_path.is_file() { - return Err(CommandError::msg("대상 사운드 경로가 파일이 아닙니다.")); - } - - if backup_path.exists() { - fs::remove_file(&backup_path)?; - } - - let prepared = prepare(target_path, wav_bytes)?; - let had_original = target_path.exists(); - if had_original { - fs::rename(target_path, &backup_path)?; - } - - if let Err(error) = commit(prepared) { - let rollback_result = - restore_processed_wav(target_path, had_original.then_some(&backup_path)); - return Err(with_rollback_error(error.into(), rollback_result, None)); - } - - if let Err(error) = save_metadata() { - let file_result = restore_processed_wav(target_path, had_original.then_some(&backup_path)); - return Err(with_rollback_error(error, file_result, None)); - } - - if had_original { - if let Err(error) = cleanup_backup(&backup_path) { - // 새 파일과 메타데이터는 이미 함께 커밋됨. 백업은 종료 시 격리 청소 대상 - log::warn!( - "편집된 WAV 백업 정리 지연 ({}): {}", - backup_path.display(), - error - ); - } - } - - Ok(()) -} - -fn restore_interrupted_processed_wav_backup( - target_path: &Path, - backup_path: &Path, -) -> CmdResult<()> { - restore_interrupted_processed_wav_backup_with(target_path, backup_path, |from, to| { - fs::rename(from, to) - }) -} - -fn restore_interrupted_processed_wav_backup_with( - target_path: &Path, - backup_path: &Path, - rename: Rename, -) -> CmdResult<()> -where - Rename: FnOnce(&Path, &Path) -> std::io::Result<()>, -{ - if !target_path.exists() && backup_path.exists() { - rename(backup_path, target_path).map_err(|error| { - CommandError::msg(format!( - "중단된 WAV 백업 복구 실패 ('{}' → '{}'): {error}", - backup_path.display(), - target_path.display() - )) - })?; - } - - Ok(()) -} - -fn backup_path_for(path: &Path) -> CmdResult { - let mut file_name = path - .file_name() - .ok_or_else(|| CommandError::msg("사운드 파일명이 없습니다."))? - .to_os_string(); - file_name.push(".bak"); - Ok(path.with_file_name(file_name)) -} - -fn restore_processed_wav(target_path: &Path, backup_path: Option<&Path>) -> std::io::Result<()> { - match backup_path { - Some(backup_path) => { - if !target_path.exists() { - return fs::rename(backup_path, target_path); - } - - let rollback_path = rollback_path_for(target_path); - fs::rename(target_path, &rollback_path)?; - if let Err(error) = fs::rename(backup_path, target_path) { - return match fs::rename(&rollback_path, target_path) { - Ok(()) => Err(error), - Err(recovery_error) => Err(std::io::Error::other(format!( - "{error}; 새 WAV 재배치 실패: {recovery_error}" - ))), - }; - } - fs::remove_file(rollback_path) - } - None if target_path.exists() => { - let rollback_path = rollback_path_for(target_path); - fs::rename(target_path, &rollback_path)?; - fs::remove_file(rollback_path) - } - None => Ok(()), - } -} - -fn rollback_path_for(path: &Path) -> PathBuf { - let mut file_name = path.file_name().unwrap_or_default().to_os_string(); - file_name.push(format!(".rollback-{}", Uuid::new_v4())); - path.with_file_name(file_name) -} - -fn with_rollback_error( - primary: CommandError, - file_result: std::io::Result<()>, - metadata_result: Option>, -) -> CommandError { - let mut failures = Vec::new(); - if let Err(error) = file_result { - failures.push(format!("WAV 원복 실패: {error}")); - } - if let Some(Err(error)) = metadata_result { - failures.push(format!("메타데이터 원복 실패: {error}")); - } - - if failures.is_empty() { - primary - } else { - CommandError::msg(format!("{primary}; {}", failures.join("; "))) - } -} - fn resolve_stored_sound_path_key(state: &AppState, validated_path: &Path) -> String { let mut stored_keys = state .store @@ -1351,839 +803,4 @@ fn is_supported_sound_file(path: &Path) -> bool { } #[cfg(test)] -mod tests { - use super::{ - apply_sound_scan_to_library, backup_path_for, commit_staged_sound_deletion, - contains_duplicate_path_separator, emit_sound_reference_changes_with, - ensure_existing_sound_edit_target, remove_sound_entry_and_references, - replace_processed_wav_with, resolve_sound_path_key_from_keys, - restore_interrupted_processed_wav_backup_with, sound_library_needs_reconcile, - stale_sound_library_keys, validate_sound_path, PreparedAtomicReplace, - SoundReferenceChangeEvent, - }; - use crate::{ - defaults::default_positions, - errors::{CmdResult, CommandError}, - models::{ - AppStoreData, EditorDocumentV1, EditorField, GraphPosition, GraphStatType, GraphType, - KeyPosition, KnobPosition, StatPosition, StatType, - }, - state::{ - atomic_file::prepare_atomic_replace, - store::{ - move_staged_sound_deletions_to_trash, stage_sound_files_for_deletion, - PROCESSED_WAV_TRANSACTION_LOCK, - }, - }, - }; - use std::{ - cell::{Cell, RefCell}, - path::Path, - sync::mpsc, - thread, - }; - - fn wav_test_path(label: &str) -> (std::path::PathBuf, std::path::PathBuf) { - let root = std::env::temp_dir().join(format!( - "dmnote-processed-wav-{label}-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("sound.wav"); - std::fs::write(&path, b"old-wav").unwrap(); - (root, path) - } - - fn assert_wav_rollback(path: &Path, metadata: &RefCell<&'static str>) { - assert_eq!(std::fs::read(path).unwrap(), b"old-wav"); - assert_eq!(*metadata.borrow(), "old-metadata"); - assert!(!backup_path_for(path).unwrap().exists()); - assert!(!std::fs::read_dir(path.parent().unwrap()) - .unwrap() - .any(|entry| { - entry - .ok() - .and_then(|entry| entry.file_name().into_string().ok()) - .is_some_and(|name| name.ends_with(".tmp") || name.contains(".rollback-")) - })); - } - - fn sound_delete_data(path_key: &str) -> AppStoreData { - let mut data = AppStoreData { - key_positions: default_positions().clone(), - ..Default::default() - }; - data.sound_library - .insert(path_key.to_string(), Default::default()); - let position = data - .key_positions - .get_mut("4key") - .unwrap() - .first_mut() - .unwrap(); - position.sound_path = Some(path_key.to_string()); - position.sound_enabled = Some(true); - data - } - - fn position_with_sound(path_key: &str) -> KeyPosition { - KeyPosition { - sound_path: Some(path_key.to_string()), - sound_enabled: Some(true), - ..Default::default() - } - } - - fn sound_delete_all_position_data(path_key: &str) -> AppStoreData { - let mut data = AppStoreData::default(); - data.sound_library - .insert(path_key.to_string(), Default::default()); - data.keys.insert("4key".to_string(), vec!["KeyA".into()]); - data.key_positions - .insert("4key".to_string(), vec![position_with_sound(path_key)]); - data.stat_positions.insert( - "4key".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: position_with_sound(path_key), - }], - ); - data.graph_positions.insert( - "4key".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 1, - graph_color: "#ffffff".to_string(), - show_avg_line: true, - position: position_with_sound(path_key), - }], - ); - data.knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: position_with_sound(path_key), - }], - ); - data - } - - #[test] - fn sound_library_needs_reconcile_is_false_when_scan_matches_library() { - let mut library = std::collections::HashMap::new(); - library.insert( - "a.wav".to_string(), - crate::models::SoundLibraryEntry::default(), - ); - let seen: std::collections::HashSet = ["a.wav".to_string()].into_iter().collect(); - assert!(!sound_library_needs_reconcile(&library, &seen, true)); - } - - #[test] - fn apply_sound_scan_inserts_new_files_and_drops_stale_only_when_complete() { - let mut library = std::collections::HashMap::new(); - library.insert( - "gone.wav".to_string(), - crate::models::SoundLibraryEntry::default(), - ); - let seen: std::collections::HashSet = ["new.wav".to_string()].into_iter().collect(); - let at_scan = library.clone(); - let exists = |_: &str| true; - - let mut partial = library.clone(); - assert!(sound_library_needs_reconcile(&library, &seen, false)); - apply_sound_scan_to_library(&mut partial, &at_scan, &seen, false, &exists); - assert!(partial.contains_key("new.wav")); - assert!(partial.contains_key("gone.wav")); - - let mut complete = library.clone(); - // 삭제 후보 gone.wav는 turn 시점에 디스크에도 없어야 지운다 - apply_sound_scan_to_library(&mut complete, &at_scan, &seen, true, &|key| { - key != "gone.wav" - }); - assert!(complete.contains_key("new.wav")); - assert!(!complete.contains_key("gone.wav")); - } - - #[test] - fn apply_sound_scan_keeps_entries_resurrected_between_scan_and_turn() { - // 스캔 때 없던 파일이 turn 직전에 되살아나면(update_processed_wav의 rename) - // 메타를 지우지 않는다 - let mut library = std::collections::HashMap::new(); - library.insert( - "back.wav".to_string(), - crate::models::SoundLibraryEntry::default(), - ); - let at_scan = library.clone(); - let seen: std::collections::HashSet = std::collections::HashSet::new(); - - apply_sound_scan_to_library(&mut library, &at_scan, &seen, true, &|_| true); - assert!(library.contains_key("back.wav")); - } - - #[test] - fn sound_delete_clears_all_position_references_and_reports_actual_fields() { - let path_key = "/sounds/deleted.wav"; - let mut data = sound_delete_all_position_data(path_key); - let before = EditorDocumentV1::from_store(&data); - - assert!(remove_sound_entry_and_references(&mut data, path_key)); - - let after = EditorDocumentV1::from_store(&data); - assert!(!data.sound_library.contains_key(path_key)); - assert_eq!( - before.changed_fields(&after), - vec![ - EditorField::KeyPositions, - EditorField::StatPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ] - ); - for position in [ - &data.key_positions["4key"][0], - &data.stat_positions["4key"][0].position, - &data.graph_positions["4key"][0].position, - &data.knob_positions["4key"][0].position, - ] { - assert_eq!(position.sound_path, None); - assert_eq!(position.sound_enabled, Some(false)); - } - } - - #[test] - fn incomplete_sound_scan_never_prunes_library_metadata() { - let library = std::collections::HashMap::from([ - ("/sounds/seen.wav".to_string(), Default::default()), - ("/sounds/unreadable.wav".to_string(), Default::default()), - ]); - let seen = std::collections::HashSet::from(["/sounds/seen.wav".to_string()]); - - assert!(stale_sound_library_keys(&library, &seen, false).is_empty()); - assert_eq!( - stale_sound_library_keys(&library, &seen, true), - vec!["/sounds/unreadable.wav".to_string()] - ); - } - - #[test] - fn sound_path_validation_resolves_existing_and_missing_paths() { - let root = - std::env::temp_dir().join(format!("dmnote-sound-path-test-{}", uuid::Uuid::new_v4())); - let sounds_dir = root.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let existing = sounds_dir.join("existing.wav"); - std::fs::write(&existing, b"sound").unwrap(); - - // macOS temp_dir는 /var → /private/var 심링크 — 경계 검사는 canonical로 통과하되 - // 반환은 원 경로여야 함 (store 키 일관성) - assert_eq!( - validate_sound_path(&sounds_dir, &existing.to_string_lossy()).unwrap(), - existing - ); - - #[cfg(windows)] - { - let verbatim_existing = format!("\\\\?\\{}", existing.display()); - assert_eq!( - validate_sound_path(&sounds_dir, &verbatim_existing).unwrap(), - std::path::PathBuf::from(verbatim_existing) - ); - } - - let missing = sounds_dir.join("missing.wav"); - assert_eq!( - validate_sound_path(&sounds_dir, &missing.to_string_lossy()).unwrap(), - missing - ); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn sound_path_validation_rejects_parent_directory_escape() { - let root = std::env::temp_dir().join(format!( - "dmnote-sound-path-escape-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = root.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let outside = root.join("outside.wav"); - std::fs::write(&outside, b"outside").unwrap(); - - let escaped_existing = sounds_dir.join("..").join("outside.wav"); - assert!(validate_sound_path(&sounds_dir, &escaped_existing.to_string_lossy()).is_err()); - - let escaped_missing = sounds_dir.join("..").join("missing.wav"); - assert!(validate_sound_path(&sounds_dir, &escaped_missing.to_string_lossy()).is_err()); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn sound_path_validation_rejects_relative_alias_components() { - let root = std::env::temp_dir().join(format!( - "dmnote-sound-path-alias-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = root.join("sounds"); - std::fs::create_dir_all(sounds_dir.join("nested")).unwrap(); - let existing = sounds_dir.join("existing.wav"); - std::fs::write(&existing, b"sound").unwrap(); - - let current_dir_alias = format!("{}/./existing.wav", sounds_dir.display()); - let parent_dir_alias = format!("{}/nested/../existing.wav", sounds_dir.display()); - - for alias in [current_dir_alias, parent_dir_alias] { - let error = validate_sound_path(&sounds_dir, &alias) - .unwrap_err() - .to_string(); - assert_eq!( - error, - "'.', '..' 또는 중복 경로 구분자는 허용되지 않습니다." - ); - } - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn sound_path_validation_rejects_duplicate_separators() { - let root = std::env::temp_dir().join(format!( - "dmnote-sound-path-duplicate-separator-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = root.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let existing = sounds_dir.join("existing.wav"); - std::fs::write(&existing, b"sound").unwrap(); - - let separator = std::path::MAIN_SEPARATOR; - let duplicate_separator_alias = - format!("{}{separator}{separator}existing.wav", sounds_dir.display()); - #[cfg(windows)] - let aliases = [duplicate_separator_alias]; - #[cfg(not(windows))] - let aliases = [ - duplicate_separator_alias, - format!("/{}", existing.display()), - ]; - - assert!(aliases - .iter() - .all(|alias| contains_duplicate_path_separator(alias))); - - for alias in aliases { - let error = validate_sound_path(&sounds_dir, &alias) - .unwrap_err() - .to_string(); - assert_eq!( - error, - "'.', '..' 또는 중복 경로 구분자는 허용되지 않습니다." - ); - } - - assert_eq!( - validate_sound_path(&sounds_dir, &existing.to_string_lossy()).unwrap(), - existing - ); - - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(unix)] - #[test] - fn sound_path_key_resolver_uses_canonical_match_for_reference_removal() { - use std::os::unix::fs::symlink; - - let root = std::env::temp_dir().join(format!( - "dmnote-sound-path-canonical-match-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = root.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let stored_path = sounds_dir.join("stored.wav"); - let alias_path = sounds_dir.join("alias.wav"); - std::fs::write(&stored_path, b"sound").unwrap(); - symlink(&stored_path, &alias_path).unwrap(); - - let stored_key = stored_path.to_string_lossy().to_string(); - let validated_alias = - validate_sound_path(&sounds_dir, &alias_path.to_string_lossy()).unwrap(); - let resolved_key = - resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); - let mut data = sound_delete_data(&stored_key); - - assert_eq!(resolved_key, stored_key); - assert!(remove_sound_entry_and_references(&mut data, &resolved_key)); - assert!(!data.sound_library.contains_key(&stored_key)); - assert_eq!(data.key_positions["4key"][0].sound_path, None); - assert_eq!(data.key_positions["4key"][0].sound_enabled, Some(false)); - - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(unix)] - #[test] - fn sound_path_key_resolver_matches_missing_file_via_canonical_parent() { - use std::os::unix::fs::symlink; - - let root = std::env::temp_dir().join(format!( - "dmnote-sound-path-missing-canonical-match-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = root.join("sounds"); - let stored_parent = sounds_dir.join("stored-parent"); - let alias_parent = sounds_dir.join("alias-parent"); - std::fs::create_dir_all(&stored_parent).unwrap(); - symlink(&stored_parent, &alias_parent).unwrap(); - let stored_path = stored_parent.join("missing.wav"); - let alias_path = alias_parent.join("missing.wav"); - - let stored_key = stored_path.to_string_lossy().to_string(); - let validated_alias = - validate_sound_path(&sounds_dir, &alias_path.to_string_lossy()).unwrap(); - let resolved_key = - resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); - - assert_eq!(resolved_key, stored_key); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn sound_path_key_resolver_preserves_unmatched_input_behavior() { - let root = std::env::temp_dir().join(format!( - "dmnote-sound-path-no-canonical-match-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = root.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let stored_path = sounds_dir.join("stored.wav"); - let unmatched_path = sounds_dir.join("unmatched.wav"); - std::fs::write(&stored_path, b"stored").unwrap(); - std::fs::write(&unmatched_path, b"unmatched").unwrap(); - - let stored_key = stored_path.to_string_lossy().to_string(); - let unmatched_key = unmatched_path.to_string_lossy().to_string(); - let validated_unmatched = - validate_sound_path(&sounds_dir, &unmatched_path.to_string_lossy()).unwrap(); - let resolved_key = resolve_sound_path_key_from_keys( - &validated_unmatched, - std::slice::from_ref(&stored_key), - ); - let mut data = sound_delete_data(&stored_key); - - assert_eq!(resolved_key, unmatched_key); - assert!(!remove_sound_entry_and_references(&mut data, &resolved_key)); - assert!(data.sound_library.contains_key(&stored_key)); - assert_eq!( - data.key_positions["4key"][0].sound_path.as_deref(), - Some(stored_key.as_str()) - ); - assert_eq!(data.key_positions["4key"][0].sound_enabled, Some(true)); - - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(windows)] - #[test] - fn sound_path_key_resolver_matches_case_and_separator_aliases() { - let root = std::env::temp_dir().join(format!( - "dmnote-sound-path-windows-alias-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = root.join("sounds"); - std::fs::create_dir_all(&sounds_dir).unwrap(); - let stored_path = sounds_dir.join("stored.wav"); - std::fs::write(&stored_path, b"sound").unwrap(); - - let stored_key = stored_path.to_string_lossy().to_string(); - let alias = stored_key.replace('\\', "/").to_ascii_uppercase(); - let validated_alias = validate_sound_path(&sounds_dir, &alias).unwrap(); - let resolved_key = - resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); - - assert_eq!(resolved_key, stored_key); - - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(windows)] - #[test] - fn duplicate_separator_check_preserves_windows_prefixes() { - assert!(!contains_duplicate_path_separator(r"\\server\share\x.wav")); - assert!(!contains_duplicate_path_separator(r"\\?\C:\sounds\x.wav")); - assert!(contains_duplicate_path_separator(r"C:\sounds\\x.wav")); - assert!(contains_duplicate_path_separator(r"C:\sounds/\x.wav")); - assert!(contains_duplicate_path_separator(r"\\server\share\\x.wav")); - assert!(contains_duplicate_path_separator(r"\\?\C:\sounds\\x.wav")); - assert!(contains_duplicate_path_separator(r"\\\server\share\x.wav")); - assert!(contains_duplicate_path_separator(r"///server/share/x.wav")); - } - - #[test] - fn sound_delete_store_failure_keeps_files_and_references() { - let root = std::env::temp_dir().join(format!( - "dmnote-sound-delete-store-failure-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let processed_path = root.join("sound.wav"); - let original_path = root.join("original.wav"); - std::fs::write(&processed_path, b"processed").unwrap(); - std::fs::write(&original_path, b"original").unwrap(); - let path_key = processed_path.to_string_lossy().to_string(); - let data = RefCell::new(sound_delete_data(&path_key)); - let cache_invalidated = Cell::new(false); - - let staged = - stage_sound_files_for_deletion(&[processed_path.clone(), original_path.clone()]) - .unwrap(); - let result: CmdResult<()> = commit_staged_sound_deletion(&staged, || { - let mut scratch = data.borrow().clone(); - remove_sound_entry_and_references(&mut scratch, &path_key); - Err(CommandError::msg("injected store failure")) - }); - - assert!(result.is_err()); - assert!(processed_path.exists()); - assert!(original_path.exists()); - assert!(!cache_invalidated.get()); - assert!(data.borrow().sound_library.contains_key(&path_key)); - let position = &data.borrow().key_positions["4key"][0]; - assert_eq!(position.sound_path.as_deref(), Some(path_key.as_str())); - assert_eq!(position.sound_enabled, Some(true)); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn sound_delete_stages_files_before_store_and_moves_them_to_trash_after_commit() { - let root = std::env::temp_dir().join(format!( - "dmnote-sound-delete-success-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let processed_path = root.join("sound.wav"); - let original_path = root.join("original.wav"); - std::fs::write(&processed_path, b"processed").unwrap(); - std::fs::write(&original_path, b"original").unwrap(); - let path_key = processed_path.to_string_lossy().to_string(); - let data = RefCell::new(sound_delete_data(&path_key)); - let events = RefCell::new(Vec::new()); - let trash_dir = root.join("trash"); - let staged = - stage_sound_files_for_deletion(&[processed_path.clone(), original_path.clone()]) - .unwrap(); - assert!(!processed_path.exists()); - assert!(!original_path.exists()); - - commit_staged_sound_deletion(&staged, || { - let mut scratch = data.borrow().clone(); - remove_sound_entry_and_references(&mut scratch, &path_key); - *data.borrow_mut() = scratch; - events.borrow_mut().push("store"); - Ok(()) - }) - .unwrap(); - events.borrow_mut().push("cache"); - move_staged_sound_deletions_to_trash(&staged, &trash_dir).unwrap(); - events.borrow_mut().push("trash"); - - assert_eq!(*events.borrow(), ["store", "cache", "trash"]); - assert!(!processed_path.exists()); - assert!(!original_path.exists()); - let quarantined: Vec<_> = std::fs::read_dir(&trash_dir) - .unwrap() - .flat_map(|session| std::fs::read_dir(session.unwrap().path()).unwrap()) - .flat_map(|category| std::fs::read_dir(category.unwrap().path()).unwrap()) - .map(|entry| entry.unwrap().file_name()) - .collect(); - assert!(quarantined.contains(&"sound.wav".into())); - assert!(quarantined.contains(&"original.wav".into())); - let position = &data.borrow().key_positions["4key"][0]; - assert_eq!(position.sound_path, None); - assert_eq!(position.sound_enabled, Some(false)); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn sound_delete_event_failure_does_not_stop_remaining_notifications() { - let attempted = RefCell::new(Vec::new()); - - emit_sound_reference_changes_with( - &[ - EditorField::KeyPositions, - EditorField::GraphPositions, - EditorField::KnobPositions, - ], - |event| { - attempted.borrow_mut().push(event.name()); - if event == SoundReferenceChangeEvent::Key { - Err("injected emit failure") - } else { - Ok(()) - } - }, - ); - - assert_eq!( - *attempted.borrow(), - [ - "positions:changed", - "graphPositions:changed", - "knobPositions:changed", - ] - ); - } - - #[test] - fn processed_wav_temp_failure_keeps_file_and_metadata() { - let (root, path) = wav_test_path("temp-failure"); - let metadata = RefCell::new("old-metadata"); - - let result = replace_processed_wav_with( - &path, - b"new-wav", - || { - *metadata.borrow_mut() = "new-metadata"; - Ok(()) - }, - |_, _| Err(anyhow::anyhow!("injected temp failure")), - PreparedAtomicReplace::commit, - |path| std::fs::remove_file(path), - ); - - assert!(result.is_err()); - assert_wav_rollback(&path, &metadata); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn deleted_sound_cannot_be_recreated_by_a_waiting_edit() { - let root = std::env::temp_dir().join(format!( - "dmnote-processed-wav-deleted-before-edit-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("deleted.wav"); - std::fs::write(&path, b"old-wav").unwrap(); - let delete_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - let edit_path = path.clone(); - let (waiting_tx, waiting_rx) = mpsc::channel(); - let edit = thread::spawn(move || { - waiting_tx.send(()).unwrap(); - let _edit_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); - ensure_existing_sound_edit_target(&edit_path) - }); - waiting_rx.recv().unwrap(); - - std::fs::remove_file(&path).unwrap(); - drop(delete_guard); - - let error = edit.join().unwrap().unwrap_err().to_string(); - - assert!(error.contains("찾을 수 없습니다")); - assert!(!path.exists()); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn processed_wav_rename_failure_keeps_file_and_metadata() { - let (root, path) = wav_test_path("rename-failure"); - let metadata = RefCell::new("old-metadata"); - - let result = replace_processed_wav_with( - &path, - b"new-wav", - || { - *metadata.borrow_mut() = "new-metadata"; - Ok(()) - }, - |path, bytes| prepare_atomic_replace(path, bytes, "rename-failure"), - |_| Err(anyhow::anyhow!("injected rename failure")), - |path| std::fs::remove_file(path), - ); - - assert!(result.is_err()); - assert_wav_rollback(&path, &metadata); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn processed_wav_store_failure_restores_file_and_metadata() { - let (root, path) = wav_test_path("store-failure"); - let metadata = RefCell::new("old-metadata"); - - let result = replace_processed_wav_with( - &path, - b"new-wav", - || -> CmdResult<()> { Err(CommandError::msg("injected store failure")) }, - |path, bytes| prepare_atomic_replace(path, bytes, "store-failure"), - PreparedAtomicReplace::commit, - |path| std::fs::remove_file(path), - ); - - assert!(result.is_err()); - assert_wav_rollback(&path, &metadata); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn processed_wav_success_commits_and_removes_backup() { - let (root, path) = wav_test_path("success"); - let metadata = RefCell::new("old-metadata"); - - replace_processed_wav_with( - &path, - b"new-wav", - || { - *metadata.borrow_mut() = "new-metadata"; - Ok(()) - }, - |path, bytes| prepare_atomic_replace(path, bytes, "success"), - PreparedAtomicReplace::commit, - |path| std::fs::remove_file(path), - ) - .unwrap(); - - assert_eq!(std::fs::read(&path).unwrap(), b"new-wav"); - assert_eq!(*metadata.borrow(), "new-metadata"); - assert!(!backup_path_for(&path).unwrap().exists()); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn processed_wav_backup_cleanup_failure_keeps_committed_file_and_metadata() { - let (root, path) = wav_test_path("cleanup-failure"); - let metadata = RefCell::new("old-metadata"); - - replace_processed_wav_with( - &path, - b"new-wav", - || { - *metadata.borrow_mut() = "new-metadata"; - Ok(()) - }, - |path, bytes| prepare_atomic_replace(path, bytes, "cleanup-failure"), - PreparedAtomicReplace::commit, - |_| { - Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "injected cleanup failure", - )) - }, - ) - .unwrap(); - - assert_eq!(std::fs::read(&path).unwrap(), b"new-wav"); - assert_eq!(*metadata.borrow(), "new-metadata"); - assert_eq!( - std::fs::read(backup_path_for(&path).unwrap()).unwrap(), - b"old-wav" - ); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn processed_wav_replacement_recovers_backup_before_retrying() { - let root = std::env::temp_dir().join(format!( - "dmnote-processed-wav-crash-retry-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("sound.wav"); - let backup_path = backup_path_for(&path).unwrap(); - let crashed_temp_path = root.join(format!( - ".sound.wav.processed-wav-{}.tmp", - uuid::Uuid::new_v4() - )); - std::fs::write(&backup_path, b"old-wav").unwrap(); - std::fs::write(&crashed_temp_path, b"crashed-new-wav").unwrap(); - let metadata = RefCell::new("old-metadata"); - - replace_processed_wav_with( - &path, - b"retried-new-wav", - || { - *metadata.borrow_mut() = "new-metadata"; - Ok(()) - }, - |target_path, bytes| { - assert_eq!(std::fs::read(target_path).unwrap(), b"old-wav"); - prepare_atomic_replace(target_path, bytes, "crash-retry") - }, - PreparedAtomicReplace::commit, - |path| std::fs::remove_file(path), - ) - .unwrap(); - - assert_eq!(std::fs::read(&path).unwrap(), b"retried-new-wav"); - assert_eq!(*metadata.borrow(), "new-metadata"); - assert!(!backup_path.exists()); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn processed_wav_backup_recovery_error_includes_operation_and_paths() { - let root = std::env::temp_dir().join(format!( - "dmnote-processed-wav-recovery-error-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let target_path = root.join("sound.wav"); - let backup_path = backup_path_for(&target_path).unwrap(); - std::fs::write(&backup_path, b"old-wav").unwrap(); - - let error = - restore_interrupted_processed_wav_backup_with(&target_path, &backup_path, |_, _| { - Err(std::io::Error::other("injected recovery failure")) - }) - .unwrap_err() - .to_string(); - - assert!(error.contains("중단된 WAV 백업 복구 실패")); - assert!(error.contains(&backup_path.display().to_string())); - assert!(error.contains(&target_path.display().to_string())); - assert!(error.contains("injected recovery failure")); - - let _ = std::fs::remove_dir_all(root); - } - - // 단독 실행: cargo test --lib commands::keys::sound::tests::processed_wav_atomic_write_survives_file_size_limit -- --ignored --exact - #[cfg(unix)] - #[test] - #[ignore = "RLIMIT_FSIZE는 프로세스 전역이므로 단독 실행"] - fn processed_wav_atomic_write_survives_file_size_limit() { - use crate::state::atomic_file::test_support::FileSizeLimit; - - let (root, path) = wav_test_path("rlimit"); - let metadata = RefCell::new("old-metadata"); - - { - let _limit = FileSizeLimit::set(1_024); - let oversized = vec![b'w'; 4_096]; - let result = replace_processed_wav_with( - &path, - &oversized, - || { - *metadata.borrow_mut() = "new-metadata"; - Ok(()) - }, - |path, bytes| prepare_atomic_replace(path, bytes, "rlimit"), - PreparedAtomicReplace::commit, - |path| std::fs::remove_file(path), - ); - assert!(result.is_err()); - assert_wav_rollback(&path, &metadata); - } - - let _ = std::fs::remove_dir_all(root); - } -} +mod tests; diff --git a/src-tauri/src/commands/keys/sound/library.rs b/src-tauri/src/commands/keys/sound/library.rs new file mode 100644 index 000000000..89180c488 --- /dev/null +++ b/src-tauri/src/commands/keys/sound/library.rs @@ -0,0 +1,214 @@ +use super::*; + +struct SoundScan { + items: Vec, + library_at_scan: std::collections::HashMap, + seen_paths: HashSet, + scan_complete: bool, +} + +pub(super) fn sound_list_inner( + app: &tauri::AppHandle, + state: &AppState, +) -> CmdResult> { + let sounds_dir = ensure_sounds_dir(app)?; + let scan = { + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let recovery_complete = state.store.prepare_sound_listing_while_locked()?; + let library = state.store.with_state(|s| s.sound_library.clone()); + scan_sounds_dir(&sounds_dir, library, recovery_complete)? + }; + let SoundScan { + mut items, + library_at_scan, + seen_paths, + scan_complete, + } = scan; + + if sound_library_needs_reconcile(&library_at_scan, &seen_paths, scan_complete) { + let ticket = issue_mutation_ticket(app)?; + ticket.run(|| -> CmdResult<()> { + // 잠금 순서: 번호표 turn → PROCESSED_WAV 잠금 (sound_delete와 동일) + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + state.store.update(|s| { + apply_sound_scan_to_library( + &mut s.sound_library, + &library_at_scan, + &seen_paths, + scan_complete, + &|key| Path::new(key).exists(), + ); + })?; + Ok(()) + })?; + } + + // 내장 사운드 우선, 이후 최신순 + items.sort_by(|a, b| { + let a_builtin = a.source == SoundSource::Builtin; + let b_builtin = b.source == SoundSource::Builtin; + b_builtin + .cmp(&a_builtin) + .then_with(|| { + b.modified_at_ms + .unwrap_or_default() + .cmp(&a.modified_at_ms.unwrap_or_default()) + }) + .then_with(|| a.file_name.cmp(&b.file_name)) + }); + + Ok(items) +} + +fn scan_sounds_dir( + sounds_dir: &Path, + library: std::collections::HashMap, + recovery_complete: bool, +) -> CmdResult { + let mut items = Vec::new(); + let mut seen_paths = HashSet::new(); + let mut scan_complete = recovery_complete; + + let entries = fs::read_dir(sounds_dir) + .map_err(|e| CommandError::msg(format!("사운드 디렉토리 읽기 실패: {e}")))?; + + for entry_result in entries { + let entry = match entry_result { + Ok(entry) => entry, + Err(error) => { + scan_complete = false; + log::warn!("[Sounds] 사운드 항목 열거 실패: {error}"); + continue; + } + }; + + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) => { + scan_complete = false; + log::warn!( + "[Sounds] 사운드 항목 형식 확인 실패 ('{}'): {error}", + path.display() + ); + continue; + } + }; + if file_type.is_dir() || !is_supported_sound_file(&path) { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(error) => { + scan_complete = false; + log::warn!( + "[Sounds] 사운드 메타데이터 확인 실패 ('{}'): {error}", + path.display() + ); + continue; + } + }; + if !metadata.is_file() { + continue; + } + + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_string(); + if file_name.is_empty() { + continue; + } + + let path_key = normalize_path_string(&path); + seen_paths.insert(path_key.clone()); + + let entry_meta = library.get(&path_key).cloned().unwrap_or_default(); + + let modified_at_ms = metadata.modified().ok().and_then(|modified| { + modified + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) + }); + + items.push(SoundListItem { + sound_path: path_key, + file_name, + size_bytes: metadata.len(), + modified_at_ms, + hidden: entry_meta.hidden, + enabled: !entry_meta.hidden, + source: entry_meta.source, + original_path: entry_meta.original_path, + trim_start_ratio: entry_meta.trim_start_ratio, + trim_end_ratio: entry_meta.trim_end_ratio, + display_name: entry_meta.display_name, + }); + } + + Ok(SoundScan { + items, + library_at_scan: library, + seen_paths, + scan_complete, + }) +} + +// 스캔 결과가 라이브러리와 다른가 - 같으면 번호표를 받지 않는다. +// 실제 적용은 turn 안의 apply_sound_scan_to_library (디스크 재확인 포함) +pub(super) fn sound_library_needs_reconcile( + library: &std::collections::HashMap, + seen_paths: &HashSet, + scan_complete: bool, +) -> bool { + seen_paths.iter().any(|key| !library.contains_key(key)) + || !stale_sound_library_keys(library, seen_paths, scan_complete).is_empty() +} + +// 라이브러리를 스캔 결과에 맞춘다. 스캔~turn 사이의 sound_delete·sound_load· +// sound_update_processed_wav를 존중해 삽입·삭제 양쪽 모두 디스크 실재를 다시 확인하고, +// 삭제 후보는 스캔 시점에 있던 키로 한정한다 +pub(super) fn apply_sound_scan_to_library( + library: &mut std::collections::HashMap, + library_at_scan: &std::collections::HashMap, + seen_paths: &HashSet, + scan_complete: bool, + exists: &dyn Fn(&str) -> bool, +) { + for key in seen_paths { + if !library.contains_key(key) && exists(key) { + library.insert(key.clone(), SoundLibraryEntry::default()); + } + } + if !scan_complete { + return; + } + let stale: Vec = library + .keys() + .filter(|key| { + library_at_scan.contains_key(*key) && !seen_paths.contains(*key) && !exists(key) + }) + .cloned() + .collect(); + for key in stale { + library.remove(&key); + } +} + +pub(super) fn stale_sound_library_keys( + library: &std::collections::HashMap, + seen_paths: &HashSet, + scan_complete: bool, +) -> Vec { + if !scan_complete { + return Vec::new(); + } + library + .keys() + .filter(|key| !seen_paths.contains(*key)) + .cloned() + .collect() +} diff --git a/src-tauri/src/commands/keys/sound/processed_wav.rs b/src-tauri/src/commands/keys/sound/processed_wav.rs new file mode 100644 index 000000000..51672f96b --- /dev/null +++ b/src-tauri/src/commands/keys/sound/processed_wav.rs @@ -0,0 +1,354 @@ +use super::*; + +pub(super) fn sound_save_processed_wav_inner( + app: &tauri::AppHandle, + state: &AppState, + request: SoundSaveProcessedWavRequest, +) -> CmdResult { + let encoded = request.wav_base64.trim(); + if encoded.is_empty() { + return Ok(SoundSaveProcessedWavResponse { + success: false, + error: Some("사운드 데이터가 비어 있습니다.".to_string()), + sound_path: None, + }); + } + + let wav_bytes = BASE64_STANDARD + .decode(encoded) + .map_err(|e| CommandError::msg(format!("사운드 데이터 디코딩 실패: {e}")))?; + + let is_valid_wav = wav_bytes.len() >= 12 + && wav_bytes.get(0..4) == Some(b"RIFF") + && wav_bytes.get(8..12) == Some(b"WAVE"); + if !is_valid_wav { + return Ok(SoundSaveProcessedWavResponse { + success: false, + error: Some("유효한 WAV 데이터가 아닙니다.".to_string()), + sound_path: None, + }); + } + + let sounds_dir = ensure_sounds_dir(app)?; + + let base_name = request + .file_name + .as_deref() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .map(|n| { + let sanitized: String = n + .chars() + .map(|c| { + if c == '/' + || c == '\\' + || c == ':' + || c == '*' + || c == '?' + || c == '"' + || c == '<' + || c == '>' + || c == '|' + { + '_' + } else { + c + } + }) + .collect(); + sanitized + }) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + let mut dest_path = sounds_dir.join(format!("{}.wav", base_name)); + if dest_path.exists() { + dest_path = sounds_dir.join(format!("{}_{}.wav", base_name, Uuid::new_v4())); + } + fs::write(&dest_path, wav_bytes) + .map_err(|e| CommandError::msg(format!("편집된 사운드 저장 실패: {e}")))?; + + // 원본 파일 저장 + let mut original_rel_path: Option = None; + if let Some(ref orig_b64) = request.original_base64 { + let trimmed_orig = orig_b64.trim(); + if !trimmed_orig.is_empty() { + let orig_bytes = BASE64_STANDARD + .decode(trimmed_orig) + .map_err(|e| CommandError::msg(format!("원본 사운드 데이터 디코딩 실패: {e}")))?; + let orig_ext = request + .original_extension + .as_deref() + .unwrap_or("wav") + .to_lowercase(); + let originals_dir = ensure_originals_dir(app)?; + let orig_filename = format!("{}.{}", Uuid::new_v4(), orig_ext); + let orig_path = originals_dir.join(&orig_filename); + fs::write(&orig_path, orig_bytes) + .map_err(|e| CommandError::msg(format!("원본 사운드 저장 실패: {e}")))?; + original_rel_path = Some(format!("originals/{}", orig_filename)); + } + } + + let dest_path_str = normalize_path_string(&dest_path); + state.store.update(|s| { + s.sound_library.insert( + dest_path_str.clone(), + SoundLibraryEntry { + hidden: false, + source: SoundSource::Local, + original_path: original_rel_path.clone(), + trim_start_ratio: request.trim_start_ratio, + trim_end_ratio: request.trim_end_ratio, + display_name: request.file_name.clone(), + }, + ); + })?; + + Ok(SoundSaveProcessedWavResponse { + success: true, + error: None, + sound_path: Some(dest_path_str), + }) +} + +pub(super) fn sound_update_processed_wav_inner( + app: &tauri::AppHandle, + state: &AppState, + request: SoundUpdateProcessedWavRequest, +) -> CmdResult { + let sounds_dir = ensure_sounds_dir(app)?; + let validated_path = validate_sound_path(&sounds_dir, &request.sound_path)?; + let path_key = resolve_stored_sound_path_key(state, &validated_path); + + // 내장 사운드 덮어쓰기 차단 (OBS/플러그인 경유 호출 포함) + let is_builtin = state.store.with_state(|s| { + s.sound_library + .get(&path_key) + .is_some_and(|entry| entry.source == SoundSource::Builtin) + }); + if is_builtin { + return Err(CommandError::msg("내장 사운드는 편집할 수 없습니다.")); + } + + let wav_bytes = BASE64_STANDARD + .decode(request.wav_base64.trim()) + .map_err(|e| CommandError::msg(format!("사운드 데이터 디코딩 실패: {e}")))?; + + let is_valid_wav = wav_bytes.len() >= 12 + && wav_bytes.get(0..4) == Some(b"RIFF") + && wav_bytes.get(8..12) == Some(b"WAVE"); + if !is_valid_wav { + return Ok(SoundUpdateProcessedWavResponse { + success: false, + error: Some("유효한 WAV 데이터가 아닙니다.".to_string()), + }); + } + + let _transaction_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + state + .store + .recover_interrupted_processed_wav_replacements_while_locked()?; + ensure_existing_sound_edit_target(&validated_path)?; + let pending = PendingProcessedWavReplacement { + sound_path: normalize_path_string(&validated_path), + had_original: validated_path.exists(), + }; + state.store.update(|store| { + store.pending_processed_wav_replacement = Some(pending.clone()); + })?; + + let replacement_result = replace_processed_wav_with( + &validated_path, + &wav_bytes, + || { + state.store.update(|store| { + if let Some(entry) = store.sound_library.get_mut(&path_key) { + entry.trim_start_ratio = request.trim_start_ratio; + entry.trim_end_ratio = request.trim_end_ratio; + if let Some(ref name) = request.display_name { + entry.display_name = Some(name.clone()); + } + } + store.pending_processed_wav_replacement = None; + })?; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "processed-wav"), + PreparedAtomicReplace::commit, + |path| fs::remove_file(path), + ); + if let Err(error) = replacement_result { + // 파일 롤백 자체가 실패했을 수 있으므로 복구 표식은 다음 재시도까지 보존 + return Err(CommandError::msg(format!( + "편집된 사운드 저장 실패: {error}" + ))); + } + + // 키음 엔진 캐시에서 이전 디코딩 결과 무효화 + state.key_sound_invalidate_file_cache(&path_key); + + Ok(SoundUpdateProcessedWavResponse { + success: true, + error: None, + }) +} + +pub(super) fn ensure_existing_sound_edit_target(path: &Path) -> CmdResult<()> { + match path.try_exists() { + Ok(true) if path.is_file() => Ok(()), + Ok(true) => Err(CommandError::msg("대상 사운드 경로가 파일이 아닙니다.")), + Ok(false) => Err(CommandError::msg("편집할 사운드 파일을 찾을 수 없습니다.")), + Err(error) => Err(CommandError::msg(format!( + "편집할 사운드 파일 확인 실패: {error}" + ))), + } +} + +pub(super) fn replace_processed_wav_with( + target_path: &Path, + wav_bytes: &[u8], + save_metadata: Save, + prepare: Prepare, + commit: Commit, + cleanup_backup: Cleanup, +) -> CmdResult<()> +where + Save: FnOnce() -> CmdResult<()>, + Prepare: FnOnce(&Path, &[u8]) -> anyhow::Result, + Commit: FnOnce(PreparedAtomicReplace) -> anyhow::Result<()>, + Cleanup: FnOnce(&Path) -> std::io::Result<()>, +{ + let backup_path = backup_path_for(target_path)?; + restore_interrupted_processed_wav_backup(target_path, &backup_path)?; + + if target_path.exists() && !target_path.is_file() { + return Err(CommandError::msg("대상 사운드 경로가 파일이 아닙니다.")); + } + + if backup_path.exists() { + fs::remove_file(&backup_path)?; + } + + let prepared = prepare(target_path, wav_bytes)?; + let had_original = target_path.exists(); + if had_original { + fs::rename(target_path, &backup_path)?; + } + + if let Err(error) = commit(prepared) { + let rollback_result = + restore_processed_wav(target_path, had_original.then_some(&backup_path)); + return Err(with_rollback_error(error.into(), rollback_result, None)); + } + + if let Err(error) = save_metadata() { + let file_result = restore_processed_wav(target_path, had_original.then_some(&backup_path)); + return Err(with_rollback_error(error, file_result, None)); + } + + if had_original { + if let Err(error) = cleanup_backup(&backup_path) { + // 새 파일과 메타데이터는 이미 함께 커밋됨. 백업은 종료 시 격리 청소 대상 + log::warn!( + "편집된 WAV 백업 정리 지연 ({}): {}", + backup_path.display(), + error + ); + } + } + + Ok(()) +} + +fn restore_interrupted_processed_wav_backup( + target_path: &Path, + backup_path: &Path, +) -> CmdResult<()> { + restore_interrupted_processed_wav_backup_with(target_path, backup_path, |from, to| { + fs::rename(from, to) + }) +} + +pub(super) fn restore_interrupted_processed_wav_backup_with( + target_path: &Path, + backup_path: &Path, + rename: Rename, +) -> CmdResult<()> +where + Rename: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + if !target_path.exists() && backup_path.exists() { + rename(backup_path, target_path).map_err(|error| { + CommandError::msg(format!( + "중단된 WAV 백업 복구 실패 ('{}' → '{}'): {error}", + backup_path.display(), + target_path.display() + )) + })?; + } + + Ok(()) +} + +pub(super) fn backup_path_for(path: &Path) -> CmdResult { + let mut file_name = path + .file_name() + .ok_or_else(|| CommandError::msg("사운드 파일명이 없습니다."))? + .to_os_string(); + file_name.push(".bak"); + Ok(path.with_file_name(file_name)) +} + +fn restore_processed_wav(target_path: &Path, backup_path: Option<&Path>) -> std::io::Result<()> { + match backup_path { + Some(backup_path) => { + if !target_path.exists() { + return fs::rename(backup_path, target_path); + } + + let rollback_path = rollback_path_for(target_path); + fs::rename(target_path, &rollback_path)?; + if let Err(error) = fs::rename(backup_path, target_path) { + return match fs::rename(&rollback_path, target_path) { + Ok(()) => Err(error), + Err(recovery_error) => Err(std::io::Error::other(format!( + "{error}; 새 WAV 재배치 실패: {recovery_error}" + ))), + }; + } + fs::remove_file(rollback_path) + } + None if target_path.exists() => { + let rollback_path = rollback_path_for(target_path); + fs::rename(target_path, &rollback_path)?; + fs::remove_file(rollback_path) + } + None => Ok(()), + } +} + +fn rollback_path_for(path: &Path) -> PathBuf { + let mut file_name = path.file_name().unwrap_or_default().to_os_string(); + file_name.push(format!(".rollback-{}", Uuid::new_v4())); + path.with_file_name(file_name) +} + +fn with_rollback_error( + primary: CommandError, + file_result: std::io::Result<()>, + metadata_result: Option>, +) -> CommandError { + let mut failures = Vec::new(); + if let Err(error) = file_result { + failures.push(format!("WAV 원복 실패: {error}")); + } + if let Some(Err(error)) = metadata_result { + failures.push(format!("메타데이터 원복 실패: {error}")); + } + + if failures.is_empty() { + primary + } else { + CommandError::msg(format!("{primary}; {}", failures.join("; "))) + } +} diff --git a/src-tauri/src/commands/keys/sound/tests.rs b/src-tauri/src/commands/keys/sound/tests.rs new file mode 100644 index 000000000..a550b8661 --- /dev/null +++ b/src-tauri/src/commands/keys/sound/tests.rs @@ -0,0 +1,828 @@ +use super::{ + apply_sound_scan_to_library, backup_path_for, commit_staged_sound_deletion, + contains_duplicate_path_separator, emit_sound_reference_changes_with, + ensure_existing_sound_edit_target, remove_sound_entry_and_references, + replace_processed_wav_with, resolve_sound_path_key_from_keys, + restore_interrupted_processed_wav_backup_with, sound_library_needs_reconcile, + stale_sound_library_keys, validate_sound_path, PreparedAtomicReplace, + SoundReferenceChangeEvent, +}; +use crate::{ + defaults::default_positions, + errors::{CmdResult, CommandError}, + models::{ + AppStoreData, EditorDocumentV1, EditorField, GraphPosition, GraphStatType, GraphType, + KeyPosition, KnobPosition, StatPosition, StatType, + }, + state::{ + atomic_file::prepare_atomic_replace, + store::{ + move_staged_sound_deletions_to_trash, stage_sound_files_for_deletion, + PROCESSED_WAV_TRANSACTION_LOCK, + }, + }, +}; +use std::{ + cell::{Cell, RefCell}, + path::Path, + sync::mpsc, + thread, +}; + +fn wav_test_path(label: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-{label}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("sound.wav"); + std::fs::write(&path, b"old-wav").unwrap(); + (root, path) +} + +fn assert_wav_rollback(path: &Path, metadata: &RefCell<&'static str>) { + assert_eq!(std::fs::read(path).unwrap(), b"old-wav"); + assert_eq!(*metadata.borrow(), "old-metadata"); + assert!(!backup_path_for(path).unwrap().exists()); + assert!(!std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .any(|entry| { + entry + .ok() + .and_then(|entry| entry.file_name().into_string().ok()) + .is_some_and(|name| name.ends_with(".tmp") || name.contains(".rollback-")) + })); +} + +fn sound_delete_data(path_key: &str) -> AppStoreData { + let mut data = AppStoreData { + key_positions: default_positions().clone(), + ..Default::default() + }; + data.sound_library + .insert(path_key.to_string(), Default::default()); + let position = data + .key_positions + .get_mut("4key") + .unwrap() + .first_mut() + .unwrap(); + position.sound_path = Some(path_key.to_string()); + position.sound_enabled = Some(true); + data +} + +fn position_with_sound(path_key: &str) -> KeyPosition { + KeyPosition { + sound_path: Some(path_key.to_string()), + sound_enabled: Some(true), + ..Default::default() + } +} + +fn sound_delete_all_position_data(path_key: &str) -> AppStoreData { + let mut data = AppStoreData::default(); + data.sound_library + .insert(path_key.to_string(), Default::default()); + data.keys.insert("4key".to_string(), vec!["KeyA".into()]); + data.key_positions + .insert("4key".to_string(), vec![position_with_sound(path_key)]); + data.stat_positions.insert( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: position_with_sound(path_key), + }], + ); + data.graph_positions.insert( + "4key".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 1, + graph_color: "#ffffff".to_string(), + show_avg_line: true, + position: position_with_sound(path_key), + }], + ); + data.knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: position_with_sound(path_key), + }], + ); + data +} + +#[test] +fn sound_library_needs_reconcile_is_false_when_scan_matches_library() { + let mut library = std::collections::HashMap::new(); + library.insert( + "a.wav".to_string(), + crate::models::SoundLibraryEntry::default(), + ); + let seen: std::collections::HashSet = ["a.wav".to_string()].into_iter().collect(); + assert!(!sound_library_needs_reconcile(&library, &seen, true)); +} + +#[test] +fn apply_sound_scan_inserts_new_files_and_drops_stale_only_when_complete() { + let mut library = std::collections::HashMap::new(); + library.insert( + "gone.wav".to_string(), + crate::models::SoundLibraryEntry::default(), + ); + let seen: std::collections::HashSet = ["new.wav".to_string()].into_iter().collect(); + let at_scan = library.clone(); + let exists = |_: &str| true; + + let mut partial = library.clone(); + assert!(sound_library_needs_reconcile(&library, &seen, false)); + apply_sound_scan_to_library(&mut partial, &at_scan, &seen, false, &exists); + assert!(partial.contains_key("new.wav")); + assert!(partial.contains_key("gone.wav")); + + let mut complete = library.clone(); + // 삭제 후보 gone.wav는 turn 시점에 디스크에도 없어야 지운다 + apply_sound_scan_to_library(&mut complete, &at_scan, &seen, true, &|key| { + key != "gone.wav" + }); + assert!(complete.contains_key("new.wav")); + assert!(!complete.contains_key("gone.wav")); +} + +#[test] +fn apply_sound_scan_keeps_entries_resurrected_between_scan_and_turn() { + // 스캔 때 없던 파일이 turn 직전에 되살아나면(update_processed_wav의 rename) + // 메타를 지우지 않는다 + let mut library = std::collections::HashMap::new(); + library.insert( + "back.wav".to_string(), + crate::models::SoundLibraryEntry::default(), + ); + let at_scan = library.clone(); + let seen: std::collections::HashSet = std::collections::HashSet::new(); + + apply_sound_scan_to_library(&mut library, &at_scan, &seen, true, &|_| true); + assert!(library.contains_key("back.wav")); +} + +#[test] +fn sound_delete_clears_all_position_references_and_reports_actual_fields() { + let path_key = "/sounds/deleted.wav"; + let mut data = sound_delete_all_position_data(path_key); + let before = EditorDocumentV1::from_store(&data); + + assert!(remove_sound_entry_and_references(&mut data, path_key)); + + let after = EditorDocumentV1::from_store(&data); + assert!(!data.sound_library.contains_key(path_key)); + assert_eq!( + before.changed_fields(&after), + vec![ + EditorField::KeyPositions, + EditorField::StatPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ] + ); + for position in [ + &data.key_positions["4key"][0], + &data.stat_positions["4key"][0].position, + &data.graph_positions["4key"][0].position, + &data.knob_positions["4key"][0].position, + ] { + assert_eq!(position.sound_path, None); + assert_eq!(position.sound_enabled, Some(false)); + } +} + +#[test] +fn incomplete_sound_scan_never_prunes_library_metadata() { + let library = std::collections::HashMap::from([ + ("/sounds/seen.wav".to_string(), Default::default()), + ("/sounds/unreadable.wav".to_string(), Default::default()), + ]); + let seen = std::collections::HashSet::from(["/sounds/seen.wav".to_string()]); + + assert!(stale_sound_library_keys(&library, &seen, false).is_empty()); + assert_eq!( + stale_sound_library_keys(&library, &seen, true), + vec!["/sounds/unreadable.wav".to_string()] + ); +} + +#[test] +fn sound_path_validation_resolves_existing_and_missing_paths() { + let root = + std::env::temp_dir().join(format!("dmnote-sound-path-test-{}", uuid::Uuid::new_v4())); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let existing = sounds_dir.join("existing.wav"); + std::fs::write(&existing, b"sound").unwrap(); + + // macOS temp_dir는 /var → /private/var 심링크 — 경계 검사는 canonical로 통과하되 + // 반환은 원 경로여야 함 (store 키 일관성) + assert_eq!( + validate_sound_path(&sounds_dir, &existing.to_string_lossy()).unwrap(), + existing + ); + + #[cfg(windows)] + { + let verbatim_existing = format!("\\\\?\\{}", existing.display()); + assert_eq!( + validate_sound_path(&sounds_dir, &verbatim_existing).unwrap(), + std::path::PathBuf::from(verbatim_existing) + ); + } + + let missing = sounds_dir.join("missing.wav"); + assert_eq!( + validate_sound_path(&sounds_dir, &missing.to_string_lossy()).unwrap(), + missing + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn sound_path_validation_rejects_parent_directory_escape() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-escape-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let outside = root.join("outside.wav"); + std::fs::write(&outside, b"outside").unwrap(); + + let escaped_existing = sounds_dir.join("..").join("outside.wav"); + assert!(validate_sound_path(&sounds_dir, &escaped_existing.to_string_lossy()).is_err()); + + let escaped_missing = sounds_dir.join("..").join("missing.wav"); + assert!(validate_sound_path(&sounds_dir, &escaped_missing.to_string_lossy()).is_err()); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn sound_path_validation_rejects_relative_alias_components() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-alias-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(sounds_dir.join("nested")).unwrap(); + let existing = sounds_dir.join("existing.wav"); + std::fs::write(&existing, b"sound").unwrap(); + + let current_dir_alias = format!("{}/./existing.wav", sounds_dir.display()); + let parent_dir_alias = format!("{}/nested/../existing.wav", sounds_dir.display()); + + for alias in [current_dir_alias, parent_dir_alias] { + let error = validate_sound_path(&sounds_dir, &alias) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "'.', '..' 또는 중복 경로 구분자는 허용되지 않습니다." + ); + } + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn sound_path_validation_rejects_duplicate_separators() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-duplicate-separator-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let existing = sounds_dir.join("existing.wav"); + std::fs::write(&existing, b"sound").unwrap(); + + let separator = std::path::MAIN_SEPARATOR; + let duplicate_separator_alias = + format!("{}{separator}{separator}existing.wav", sounds_dir.display()); + #[cfg(windows)] + let aliases = [duplicate_separator_alias]; + #[cfg(not(windows))] + let aliases = [ + duplicate_separator_alias, + format!("/{}", existing.display()), + ]; + + assert!(aliases + .iter() + .all(|alias| contains_duplicate_path_separator(alias))); + + for alias in aliases { + let error = validate_sound_path(&sounds_dir, &alias) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "'.', '..' 또는 중복 경로 구분자는 허용되지 않습니다." + ); + } + + assert_eq!( + validate_sound_path(&sounds_dir, &existing.to_string_lossy()).unwrap(), + existing + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn sound_path_key_resolver_uses_canonical_match_for_reference_removal() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-canonical-match-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let stored_path = sounds_dir.join("stored.wav"); + let alias_path = sounds_dir.join("alias.wav"); + std::fs::write(&stored_path, b"sound").unwrap(); + symlink(&stored_path, &alias_path).unwrap(); + + let stored_key = stored_path.to_string_lossy().to_string(); + let validated_alias = validate_sound_path(&sounds_dir, &alias_path.to_string_lossy()).unwrap(); + let resolved_key = + resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); + let mut data = sound_delete_data(&stored_key); + + assert_eq!(resolved_key, stored_key); + assert!(remove_sound_entry_and_references(&mut data, &resolved_key)); + assert!(!data.sound_library.contains_key(&stored_key)); + assert_eq!(data.key_positions["4key"][0].sound_path, None); + assert_eq!(data.key_positions["4key"][0].sound_enabled, Some(false)); + + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn sound_path_key_resolver_matches_missing_file_via_canonical_parent() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-missing-canonical-match-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + let stored_parent = sounds_dir.join("stored-parent"); + let alias_parent = sounds_dir.join("alias-parent"); + std::fs::create_dir_all(&stored_parent).unwrap(); + symlink(&stored_parent, &alias_parent).unwrap(); + let stored_path = stored_parent.join("missing.wav"); + let alias_path = alias_parent.join("missing.wav"); + + let stored_key = stored_path.to_string_lossy().to_string(); + let validated_alias = validate_sound_path(&sounds_dir, &alias_path.to_string_lossy()).unwrap(); + let resolved_key = + resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); + + assert_eq!(resolved_key, stored_key); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn sound_path_key_resolver_preserves_unmatched_input_behavior() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-no-canonical-match-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let stored_path = sounds_dir.join("stored.wav"); + let unmatched_path = sounds_dir.join("unmatched.wav"); + std::fs::write(&stored_path, b"stored").unwrap(); + std::fs::write(&unmatched_path, b"unmatched").unwrap(); + + let stored_key = stored_path.to_string_lossy().to_string(); + let unmatched_key = unmatched_path.to_string_lossy().to_string(); + let validated_unmatched = + validate_sound_path(&sounds_dir, &unmatched_path.to_string_lossy()).unwrap(); + let resolved_key = + resolve_sound_path_key_from_keys(&validated_unmatched, std::slice::from_ref(&stored_key)); + let mut data = sound_delete_data(&stored_key); + + assert_eq!(resolved_key, unmatched_key); + assert!(!remove_sound_entry_and_references(&mut data, &resolved_key)); + assert!(data.sound_library.contains_key(&stored_key)); + assert_eq!( + data.key_positions["4key"][0].sound_path.as_deref(), + Some(stored_key.as_str()) + ); + assert_eq!(data.key_positions["4key"][0].sound_enabled, Some(true)); + + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(windows)] +#[test] +fn sound_path_key_resolver_matches_case_and_separator_aliases() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-path-windows-alias-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = root.join("sounds"); + std::fs::create_dir_all(&sounds_dir).unwrap(); + let stored_path = sounds_dir.join("stored.wav"); + std::fs::write(&stored_path, b"sound").unwrap(); + + let stored_key = stored_path.to_string_lossy().to_string(); + let alias = stored_key.replace('\\', "/").to_ascii_uppercase(); + let validated_alias = validate_sound_path(&sounds_dir, &alias).unwrap(); + let resolved_key = + resolve_sound_path_key_from_keys(&validated_alias, std::slice::from_ref(&stored_key)); + + assert_eq!(resolved_key, stored_key); + + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(windows)] +#[test] +fn duplicate_separator_check_preserves_windows_prefixes() { + assert!(!contains_duplicate_path_separator(r"\\server\share\x.wav")); + assert!(!contains_duplicate_path_separator(r"\\?\C:\sounds\x.wav")); + assert!(contains_duplicate_path_separator(r"C:\sounds\\x.wav")); + assert!(contains_duplicate_path_separator(r"C:\sounds/\x.wav")); + assert!(contains_duplicate_path_separator(r"\\server\share\\x.wav")); + assert!(contains_duplicate_path_separator(r"\\?\C:\sounds\\x.wav")); + assert!(contains_duplicate_path_separator(r"\\\server\share\x.wav")); + assert!(contains_duplicate_path_separator(r"///server/share/x.wav")); +} + +#[test] +fn sound_delete_store_failure_keeps_files_and_references() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-delete-store-failure-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let processed_path = root.join("sound.wav"); + let original_path = root.join("original.wav"); + std::fs::write(&processed_path, b"processed").unwrap(); + std::fs::write(&original_path, b"original").unwrap(); + let path_key = processed_path.to_string_lossy().to_string(); + let data = RefCell::new(sound_delete_data(&path_key)); + let cache_invalidated = Cell::new(false); + + let staged = + stage_sound_files_for_deletion(&[processed_path.clone(), original_path.clone()]).unwrap(); + let result: CmdResult<()> = commit_staged_sound_deletion(&staged, || { + let mut scratch = data.borrow().clone(); + remove_sound_entry_and_references(&mut scratch, &path_key); + Err(CommandError::msg("injected store failure")) + }); + + assert!(result.is_err()); + assert!(processed_path.exists()); + assert!(original_path.exists()); + assert!(!cache_invalidated.get()); + assert!(data.borrow().sound_library.contains_key(&path_key)); + let position = &data.borrow().key_positions["4key"][0]; + assert_eq!(position.sound_path.as_deref(), Some(path_key.as_str())); + assert_eq!(position.sound_enabled, Some(true)); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn sound_delete_stages_files_before_store_and_moves_them_to_trash_after_commit() { + let root = std::env::temp_dir().join(format!( + "dmnote-sound-delete-success-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let processed_path = root.join("sound.wav"); + let original_path = root.join("original.wav"); + std::fs::write(&processed_path, b"processed").unwrap(); + std::fs::write(&original_path, b"original").unwrap(); + let path_key = processed_path.to_string_lossy().to_string(); + let data = RefCell::new(sound_delete_data(&path_key)); + let events = RefCell::new(Vec::new()); + let trash_dir = root.join("trash"); + let staged = + stage_sound_files_for_deletion(&[processed_path.clone(), original_path.clone()]).unwrap(); + assert!(!processed_path.exists()); + assert!(!original_path.exists()); + + commit_staged_sound_deletion(&staged, || { + let mut scratch = data.borrow().clone(); + remove_sound_entry_and_references(&mut scratch, &path_key); + *data.borrow_mut() = scratch; + events.borrow_mut().push("store"); + Ok(()) + }) + .unwrap(); + events.borrow_mut().push("cache"); + move_staged_sound_deletions_to_trash(&staged, &trash_dir).unwrap(); + events.borrow_mut().push("trash"); + + assert_eq!(*events.borrow(), ["store", "cache", "trash"]); + assert!(!processed_path.exists()); + assert!(!original_path.exists()); + let quarantined: Vec<_> = std::fs::read_dir(&trash_dir) + .unwrap() + .flat_map(|session| std::fs::read_dir(session.unwrap().path()).unwrap()) + .flat_map(|category| std::fs::read_dir(category.unwrap().path()).unwrap()) + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert!(quarantined.contains(&"sound.wav".into())); + assert!(quarantined.contains(&"original.wav".into())); + let position = &data.borrow().key_positions["4key"][0]; + assert_eq!(position.sound_path, None); + assert_eq!(position.sound_enabled, Some(false)); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn sound_delete_event_failure_does_not_stop_remaining_notifications() { + let attempted = RefCell::new(Vec::new()); + + emit_sound_reference_changes_with( + &[ + EditorField::KeyPositions, + EditorField::GraphPositions, + EditorField::KnobPositions, + ], + |event| { + attempted.borrow_mut().push(event.name()); + if event == SoundReferenceChangeEvent::Key { + Err("injected emit failure") + } else { + Ok(()) + } + }, + ); + + assert_eq!( + *attempted.borrow(), + [ + "positions:changed", + "graphPositions:changed", + "knobPositions:changed", + ] + ); +} + +#[test] +fn processed_wav_temp_failure_keeps_file_and_metadata() { + let (root, path) = wav_test_path("temp-failure"); + let metadata = RefCell::new("old-metadata"); + + let result = replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |_, _| Err(anyhow::anyhow!("injected temp failure")), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ); + + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn deleted_sound_cannot_be_recreated_by_a_waiting_edit() { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-deleted-before-edit-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("deleted.wav"); + std::fs::write(&path, b"old-wav").unwrap(); + let delete_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + let edit_path = path.clone(); + let (waiting_tx, waiting_rx) = mpsc::channel(); + let edit = thread::spawn(move || { + waiting_tx.send(()).unwrap(); + let _edit_guard = PROCESSED_WAV_TRANSACTION_LOCK.lock(); + ensure_existing_sound_edit_target(&edit_path) + }); + waiting_rx.recv().unwrap(); + + std::fs::remove_file(&path).unwrap(); + drop(delete_guard); + + let error = edit.join().unwrap().unwrap_err().to_string(); + + assert!(error.contains("찾을 수 없습니다")); + assert!(!path.exists()); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn processed_wav_rename_failure_keeps_file_and_metadata() { + let (root, path) = wav_test_path("rename-failure"); + let metadata = RefCell::new("old-metadata"); + + let result = replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "rename-failure"), + |_| Err(anyhow::anyhow!("injected rename failure")), + |path| std::fs::remove_file(path), + ); + + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn processed_wav_store_failure_restores_file_and_metadata() { + let (root, path) = wav_test_path("store-failure"); + let metadata = RefCell::new("old-metadata"); + + let result = replace_processed_wav_with( + &path, + b"new-wav", + || -> CmdResult<()> { Err(CommandError::msg("injected store failure")) }, + |path, bytes| prepare_atomic_replace(path, bytes, "store-failure"), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ); + + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn processed_wav_success_commits_and_removes_backup() { + let (root, path) = wav_test_path("success"); + let metadata = RefCell::new("old-metadata"); + + replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "success"), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ) + .unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"new-wav"); + assert_eq!(*metadata.borrow(), "new-metadata"); + assert!(!backup_path_for(&path).unwrap().exists()); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn processed_wav_backup_cleanup_failure_keeps_committed_file_and_metadata() { + let (root, path) = wav_test_path("cleanup-failure"); + let metadata = RefCell::new("old-metadata"); + + replace_processed_wav_with( + &path, + b"new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "cleanup-failure"), + PreparedAtomicReplace::commit, + |_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected cleanup failure", + )) + }, + ) + .unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"new-wav"); + assert_eq!(*metadata.borrow(), "new-metadata"); + assert_eq!( + std::fs::read(backup_path_for(&path).unwrap()).unwrap(), + b"old-wav" + ); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn processed_wav_replacement_recovers_backup_before_retrying() { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-crash-retry-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("sound.wav"); + let backup_path = backup_path_for(&path).unwrap(); + let crashed_temp_path = root.join(format!( + ".sound.wav.processed-wav-{}.tmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&backup_path, b"old-wav").unwrap(); + std::fs::write(&crashed_temp_path, b"crashed-new-wav").unwrap(); + let metadata = RefCell::new("old-metadata"); + + replace_processed_wav_with( + &path, + b"retried-new-wav", + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |target_path, bytes| { + assert_eq!(std::fs::read(target_path).unwrap(), b"old-wav"); + prepare_atomic_replace(target_path, bytes, "crash-retry") + }, + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ) + .unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"retried-new-wav"); + assert_eq!(*metadata.borrow(), "new-metadata"); + assert!(!backup_path.exists()); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn processed_wav_backup_recovery_error_includes_operation_and_paths() { + let root = std::env::temp_dir().join(format!( + "dmnote-processed-wav-recovery-error-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let target_path = root.join("sound.wav"); + let backup_path = backup_path_for(&target_path).unwrap(); + std::fs::write(&backup_path, b"old-wav").unwrap(); + + let error = + restore_interrupted_processed_wav_backup_with(&target_path, &backup_path, |_, _| { + Err(std::io::Error::other("injected recovery failure")) + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("중단된 WAV 백업 복구 실패")); + assert!(error.contains(&backup_path.display().to_string())); + assert!(error.contains(&target_path.display().to_string())); + assert!(error.contains("injected recovery failure")); + + let _ = std::fs::remove_dir_all(root); +} + +// 단독 실행: cargo test --lib commands::keys::sound::tests::processed_wav_atomic_write_survives_file_size_limit -- --ignored --exact +#[cfg(unix)] +#[test] +#[ignore = "RLIMIT_FSIZE는 프로세스 전역이므로 단독 실행"] +fn processed_wav_atomic_write_survives_file_size_limit() { + use crate::state::atomic_file::test_support::FileSizeLimit; + + let (root, path) = wav_test_path("rlimit"); + let metadata = RefCell::new("old-metadata"); + + { + let _limit = FileSizeLimit::set(1_024); + let oversized = vec![b'w'; 4_096]; + let result = replace_processed_wav_with( + &path, + &oversized, + || { + *metadata.borrow_mut() = "new-metadata"; + Ok(()) + }, + |path, bytes| prepare_atomic_replace(path, bytes, "rlimit"), + PreparedAtomicReplace::commit, + |path| std::fs::remove_file(path), + ); + assert!(result.is_err()); + assert_wav_rollback(&path, &metadata); + } + + let _ = std::fs::remove_dir_all(root); +} diff --git a/src-tauri/src/commands/media/image.rs b/src-tauri/src/commands/media/image.rs index 08e711efa..28d5cc3c6 100644 --- a/src-tauri/src/commands/media/image.rs +++ b/src-tauri/src/commands/media/image.rs @@ -162,7 +162,9 @@ fn transcode_utf16(file: File, little_endian: bool) -> Option<(Vec, bool)> { } let units = body - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| { let bytes = [pair[0], pair[1]]; if little_endian { diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index f756638e0..1fac0244e 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -46,252 +46,23 @@ use super::{ PRESET_LOCAL_SOUND_PREFIX, }; -fn read_preset_file(path: &Path) -> CmdResult { - let content = fs::read_to_string(path)?; - let mut value: serde_json::Value = - serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset"))?; - scrub_removed_text_outline_fields(&mut value); - if let Some(detail) = invalid_position_style_detail(&value) { - return Err(CommandError::msg(format!("invalid-preset: {detail}"))); - } - default_preset_note_gradient_multipliers(&mut value); - serde_json::from_value(value).map_err(|_| CommandError::msg("invalid-preset")) -} - -fn default_preset_note_gradient_multipliers(value: &mut serde_json::Value) { - for collection in POSITION_COLLECTION_FIELDS { - let Some(modes) = value - .get_mut(collection) - .and_then(serde_json::Value::as_object_mut) - else { - continue; - }; - for position in modes - .values_mut() - .filter_map(serde_json::Value::as_array_mut) - .flatten() - { - default_missing_note_gradient_multipliers(position); - } - } -} - -fn invalid_position_style_detail(preset: &serde_json::Value) -> Option { - const ELEMENT_FIELDS: [&str; 9] = [ - "backgroundGradient", - "activeBackgroundGradient", - "borderGradient", - "activeBorderGradient", - "fontGradient", - "activeFontGradient", - "noteBorderGradient", - "noteGradient", - "noteGlowGradient", - ]; - const COUNTER_FIELDS: [&str; 2] = ["fillIdleGradient", "fillActiveGradient"]; - const SHADOW_FIELDS: [&str; 2] = ["shadow", "activeShadow"]; - const IMAGE_TRANSFORM_FIELDS: [&str; 2] = ["idleImageTransform", "activeImageTransform"]; - - for collection_name in POSITION_COLLECTION_FIELDS { - let Some(modes) = preset - .get(collection_name) - .and_then(serde_json::Value::as_object) - else { - continue; - }; - for (mode, entries) in modes { - let Some(entries) = entries.as_array() else { - continue; - }; - for (index, entry) in entries.iter().enumerate() { - let Some(entry) = entry.as_object() else { - continue; - }; - for field in ELEMENT_FIELDS { - let error = match field { - "noteBorderGradient" => invalid_note_gradient_error( - entry.get(field), - "unsupported note border color", - ), - "noteGradient" | "noteGlowGradient" => invalid_note_gradient_error( - entry.get(field), - "unsupported note gradient color", - ), - _ => invalid_gradient_error(entry.get(field)), - }; - if let Some(error) = error { - return Some(format!( - "{collection_name}[{mode:?}][{index}].{field}: {error}" - )); - } - } - for field in SHADOW_FIELDS { - // null은 Option 역직렬화와 동일하게 "값 없음" 취급 - let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { - continue; - }; - if let Some((suffix, error)) = invalid_shadow_error(value) { - return Some(format!( - "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" - )); - } - } - for field in IMAGE_TRANSFORM_FIELDS { - let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { - continue; - }; - if let Some((suffix, error)) = invalid_image_transform_error(value) { - return Some(format!( - "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" - )); - } - } - let Some(counter) = entry.get("counter").and_then(serde_json::Value::as_object) - else { - continue; - }; - for field in COUNTER_FIELDS { - if let Some(error) = invalid_gradient_error(counter.get(field)) { - return Some(format!( - "{collection_name}[{mode:?}][{index}].counter.{field}: {error}" - )); - } - } - } - } - } - None -} - -fn invalid_gradient_error(value: Option<&serde_json::Value>) -> Option { - let value = value?; - let gradient = match serde_json::from_value::>(value.clone()) { - Ok(gradient) => gradient, - Err(error) => return Some(error.to_string()), - }?; - // 공백 stop 색은 로드 복구가 수렴하지 않는 손상 값이라 문에서 거부 - gradient - .stops - .iter() - .position(|stop| stop.color.trim().is_empty()) - .map(|index| format!("stops[{index}].color must not be blank")) -} - -fn invalid_note_gradient_error( - value: Option<&serde_json::Value>, - color_error: &str, -) -> Option { - let value = value?; - let gradient = match serde_json::from_value::>(value.clone()) { - Ok(gradient) => gradient, - Err(error) => return Some(error.to_string()), - }?; - gradient - .note_border_invalid_stop_index() - .map(|index| format!("stops[{index}].color contains an {color_error}")) -} - -fn invalid_shadow_error(value: &serde_json::Value) -> Option<(&'static str, &'static str)> { - let Some(shadow) = value.as_object() else { - return Some(("", "must be an object")); - }; - if !shadow - .get("enabled") - .is_some_and(serde_json::Value::is_boolean) - { - return Some((".enabled", "must be a boolean")); - } - if shadow - .get("color") - .and_then(serde_json::Value::as_str) - .is_none_or(str::is_empty) - { - return Some((".color", "must be a non-empty string")); - } - for field in ["offsetX", "offsetY"] { - if !shadow - .get(field) - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| { - value.is_finite() && (SHADOW_OFFSET_MIN..=SHADOW_OFFSET_MAX).contains(&value) - }) - { - let suffix = if field == "offsetX" { - ".offsetX" - } else { - ".offsetY" - }; - return Some((suffix, "must be a finite number between -100 and 100")); - } - } - if !shadow - .get("blur") - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| { - value.is_finite() && (SHADOW_BLUR_MIN..=SHADOW_BLUR_MAX).contains(&value) - }) - { - return Some((".blur", "must be a finite number between 0 and 100")); - } - None -} - -// 이미지 변환은 그림자와 같은 정책으로 문에서 거부한다 - 문서 검증(editor.rs)과 같은 범위 -fn invalid_image_transform_error( - value: &serde_json::Value, -) -> Option<(&'static str, &'static str)> { - use crate::models::{ - IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, - IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, - }; - let Some(transform) = value.as_object() else { - return Some(("", "must be an object")); - }; - for (field, suffix, min, max, error) in [ - ( - "offsetX", - ".offsetX", - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - "must be a finite number between -500 and 500", - ), - ( - "offsetY", - ".offsetY", - IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_OFFSET_MAX, - "must be a finite number between -500 and 500", - ), - ( - "rotation", - ".rotation", - IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_ROTATION_MAX, - "must be a finite number between -180 and 180", - ), - ( - "scale", - ".scale", - IMAGE_TRANSFORM_SCALE_MIN, - IMAGE_TRANSFORM_SCALE_MAX, - "must be a finite number between 0.1 and 10", - ), - ] { - if !transform - .get(field) - .and_then(serde_json::Value::as_f64) - .is_some_and(|value| value.is_finite() && (min..=max).contains(&value)) - { - return Some((suffix, error)); - } - } - None -} +mod imported_assets; +mod validation; +use imported_assets::{ + merge_prepared_tab_preset_fonts, migrate_imported_font_weights, prepare_tab_preset_fonts, + restore_preset_local_fonts, restore_preset_local_images, restore_preset_local_sounds, +}; #[cfg(test)] -pub(crate) fn read_preset_file_for_simulation(path: &Path) -> CmdResult { - read_preset_file(path) -} +use imported_assets::{ + merge_tab_preset_fonts, restore_position_image_reference, restore_preset_local_fonts_in_dir, + restore_preset_local_sounds_in_dir, +}; +#[cfg(test)] +use validation::invalid_position_style_detail; +use validation::read_preset_file; +#[cfg(test)] +pub(crate) use validation::read_preset_file_for_simulation; struct ResolvedFullPresetSettings { background_color: String, @@ -1084,26 +855,6 @@ fn align_imported_key_collections(keys: &mut KeyMappings, positions: &mut KeyPos } } -fn migrate_imported_font_weights( - key_positions: &mut KeyPositions, - stat_positions: &mut StatPositions, - graph_positions: &mut GraphPositions, - knob_positions: &mut KnobPositions, -) { - for position in key_positions.values_mut().flatten() { - position.migrate_legacy_font_weight(); - } - for position in stat_positions.values_mut().flatten() { - position.position.migrate_legacy_font_weight(); - } - for position in graph_positions.values_mut().flatten() { - position.position.migrate_legacy_font_weight(); - } - for position in knob_positions.values_mut().flatten() { - position.position.migrate_legacy_font_weight(); - } -} - fn rekey_full_preset_elements(store: &mut AppStoreData) { crate::state::native_element_id::rekey_store_element_ids(store); } @@ -1156,593 +907,6 @@ fn merge_tab_preset_key_pair( .insert(current_tab_id.to_string(), positions); } -#[cfg(test)] -fn merge_tab_preset_fonts( - existing_font_settings: &FontSettings, - imported_font_settings: FontSettings, - restore_fonts: impl FnOnce(&mut FontSettings) -> CmdResult<()>, -) -> CmdResult> { - let Some(prepared) = prepare_tab_preset_fonts( - existing_font_settings, - imported_font_settings, - restore_fonts, - )? - else { - return Ok(None); - }; - Ok(merge_prepared_tab_preset_fonts( - existing_font_settings, - prepared, - )) -} - -fn prepare_tab_preset_fonts( - existing_font_settings: &FontSettings, - mut imported_font_settings: FontSettings, - restore_fonts: impl FnOnce(&mut FontSettings) -> CmdResult<()>, -) -> CmdResult> { - let existing_names: HashSet = existing_font_settings - .custom_fonts - .iter() - .map(|font| font.name.clone()) - .collect(); - - // 같은 이름은 기존 정의 유지. 같은 family의 다른 페이스(파일)는 개별 자산이라 - // 이름으로 묶지 않고, 프리셋 내부 중복은 id 기준으로만 방어 - let mut seen_ids: HashSet = HashSet::new(); - imported_font_settings.custom_fonts.retain(|font| { - !existing_names.contains(&font.name) - && (font.id.is_empty() || seen_ids.insert(font.id.clone())) - }); - if imported_font_settings.custom_fonts.is_empty() { - return Ok(None); - } - - // 이름 필터 후 파일 복원 — 제외할 로컬 폰트의 고아 파일 생성 방지 - restore_fonts(&mut imported_font_settings)?; - - let mut existing_ids: HashSet = existing_font_settings - .custom_fonts - .iter() - .map(|font| font.id.clone()) - .collect(); - for font in imported_font_settings.custom_fonts.iter_mut() { - if existing_ids.contains(&font.id) { - font.id = Uuid::new_v4().to_string(); - } - existing_ids.insert(font.id.clone()); - } - - Ok(Some(imported_font_settings)) -} - -fn merge_prepared_tab_preset_fonts( - existing_font_settings: &FontSettings, - mut prepared: FontSettings, -) -> Option { - let existing_names = existing_font_settings - .custom_fonts - .iter() - .map(|font| font.name.clone()) - .collect::>(); - let importable_names = prepared - .custom_fonts - .iter() - .filter(|font| !existing_names.contains(&font.name)) - .map(|font| font.name.clone()) - .collect::>(); - prepared - .custom_fonts - .retain(|font| importable_names.contains(&font.name)); - if prepared.custom_fonts.is_empty() { - return None; - } - - let mut existing_ids = existing_font_settings - .custom_fonts - .iter() - .map(|font| font.id.clone()) - .collect::>(); - for font in &mut prepared.custom_fonts { - if existing_ids.contains(&font.id) { - font.id = Uuid::new_v4().to_string(); - } - existing_ids.insert(font.id.clone()); - } - - let mut merged = existing_font_settings.clone(); - merged.custom_fonts.extend(prepared.custom_fonts); - Some(merged) -} - -fn restore_preset_local_fonts( - app: &AppHandle, - font_settings: &mut FontSettings, - embedded_local_fonts: Option<&[EmbeddedLocalFont]>, -) -> CmdResult<()> { - let has_local_fonts = font_settings - .custom_fonts - .iter() - .any(|font| font.font_type == FontType::Local); - if !has_local_fonts { - return Ok(()); - } - - let app_data_dir = app.path().app_data_dir()?; - let fonts_dir = app_data_dir.join("fonts"); - - restore_preset_local_fonts_in_dir(&fonts_dir, font_settings, embedded_local_fonts) -} - -fn restore_preset_local_fonts_in_dir( - fonts_dir: &Path, - font_settings: &mut FontSettings, - embedded_local_fonts: Option<&[EmbeddedLocalFont]>, -) -> CmdResult<()> { - let has_local_fonts = font_settings - .custom_fonts - .iter() - .any(|font| font.font_type == FontType::Local); - if !has_local_fonts { - return Ok(()); - } - - let embedded_map: HashMap<&str, &EmbeddedLocalFont> = embedded_local_fonts - .unwrap_or(&[]) - .iter() - .map(|font| (font.font_id.as_str(), font)) - .collect(); - - fs::create_dir_all(fonts_dir)?; - - for font in font_settings.custom_fonts.iter_mut() { - if font.font_type != FontType::Local { - continue; - } - - // 로컬 폰트는 항상 복사된 파일 경로로 제공 - font.css_content = None; - - if let Some(embedded) = embedded_map.get(font.id.as_str()) { - let bytes = match BASE64_STANDARD.decode(embedded.data_base64.as_bytes()) { - Ok(bytes) => bytes, - Err(err) => { - log::warn!( - "[Preset] Failed to decode embedded local font '{}': {err}", - font.display_name - ); - font.local_path = None; - font.enabled = false; - continue; - } - }; - - let extension = normalize_font_extension(embedded.extension.as_deref()); - let dest_path = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), extension)); - if let Err(err) = fs::write(&dest_path, bytes) { - log::warn!( - "[Preset] Failed to restore local font file for '{}': {err}", - font.display_name - ); - font.local_path = None; - font.enabled = false; - continue; - } - font.local_path = Some(dest_path.to_string_lossy().to_string()); - continue; - } - - // 하위 호환: 기존 절대 경로가 유효하면 유지 - let has_existing_valid_path = font - .local_path - .as_ref() - .map(|path| !path.trim().is_empty() && Path::new(path).exists()) - .unwrap_or(false); - - if !has_existing_valid_path { - log::warn!( - "[Preset] Disabling font '{}' — no embedded payload and its file is missing on this machine", - font.name - ); - font.local_path = None; - font.enabled = false; - } - } - - Ok(()) -} - -fn restore_preset_local_images( - app: &AppHandle, - key_positions: &mut KeyPositions, - stat_positions: &mut StatPositions, - graph_positions: &mut GraphPositions, - knob_positions: &mut KnobPositions, - embedded_local_images: Option<&[EmbeddedLocalImage]>, -) -> CmdResult<()> { - let has_any_images = key_positions.values().any(|positions| { - positions.iter().any(|position| { - option_has_non_empty_text(&position.active_image) - || option_has_non_empty_text(&position.inactive_image) - }) - }) || stat_positions.values().any(|positions| { - positions.iter().any(|stat_position| { - option_has_non_empty_text(&stat_position.position.active_image) - || option_has_non_empty_text(&stat_position.position.inactive_image) - }) - }) || graph_positions.values().any(|positions| { - positions.iter().any(|graph_position| { - option_has_non_empty_text(&graph_position.position.active_image) - || option_has_non_empty_text(&graph_position.position.inactive_image) - }) - }) || knob_positions.values().any(|positions| { - positions.iter().any(|knob_position| { - option_has_non_empty_text(&knob_position.position.active_image) - || option_has_non_empty_text(&knob_position.position.inactive_image) - }) - }); - - if !has_any_images { - return Ok(()); - } - - let embedded_map: HashMap<&str, &EmbeddedLocalImage> = embedded_local_images - .unwrap_or(&[]) - .iter() - .map(|image| (image.image_id.as_str(), image)) - .collect(); - let mut restored_path_cache: HashMap = HashMap::new(); - - let app_data_dir = app.path().app_data_dir()?; - let images_dir = app_data_dir.join("images"); - fs::create_dir_all(&images_dir)?; - - for positions in key_positions.values_mut() { - for position in positions.iter_mut() { - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut position.active_image, - )?; - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut position.inactive_image, - )?; - } - } - - for positions in stat_positions.values_mut() { - for stat_position in positions.iter_mut() { - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut stat_position.position.active_image, - )?; - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut stat_position.position.inactive_image, - )?; - } - } - - for positions in graph_positions.values_mut() { - for graph_position in positions.iter_mut() { - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut graph_position.position.active_image, - )?; - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut graph_position.position.inactive_image, - )?; - } - } - - for positions in knob_positions.values_mut() { - for knob_position in positions.iter_mut() { - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut knob_position.position.active_image, - )?; - restore_position_image_reference( - &images_dir, - &embedded_map, - &mut restored_path_cache, - &mut knob_position.position.inactive_image, - )?; - } - } - - Ok(()) -} - -fn restore_position_image_reference( - images_dir: &Path, - embedded_map: &HashMap<&str, &EmbeddedLocalImage>, - restored_path_cache: &mut HashMap, - image_ref: &mut Option, -) -> CmdResult<()> { - let Some(current_value) = image_ref.clone() else { - return Ok(()); - }; - let trimmed = current_value.trim(); - if trimmed.is_empty() { - return Ok(()); - } - - if let Some(image_id) = trimmed.strip_prefix(PRESET_LOCAL_IMAGE_PREFIX) { - if let Some(restored_path) = restored_path_cache.get(image_id) { - *image_ref = Some(restored_path.clone()); - return Ok(()); - } - let Some(embedded) = embedded_map.get(image_id) else { - log::warn!( - "[Preset] Missing embedded image payload for id '{}'; clearing image reference", - image_id - ); - *image_ref = None; - return Ok(()); - }; - - let bytes = match BASE64_STANDARD.decode(embedded.data_base64.as_bytes()) { - Ok(bytes) => bytes, - Err(err) => { - log::warn!( - "[Preset] Failed to decode embedded image '{}': {err}", - image_id - ); - *image_ref = None; - return Ok(()); - } - }; - let extension = normalize_image_extension(embedded.extension.as_deref()); - let imported = match import_image_bytes(&bytes, images_dir, &extension) { - Ok(imported) => imported, - Err(err) => { - log::warn!( - "[Preset] Failed to restore embedded image '{}': {err}", - image_id - ); - *image_ref = None; - return Ok(()); - } - }; - let restored = imported.path.to_string_lossy().to_string(); - restored_path_cache.insert(image_id.to_string(), restored.clone()); - *image_ref = Some(restored); - return Ok(()); - } - - // 레거시 Preset 호환: data URL 이미지를 appdata 파일 경로로 변환 - if let Some((bytes, extension)) = decode_image_data_url(trimmed) { - let imported = import_image_bytes(&bytes, images_dir, &extension)?; - *image_ref = Some(imported.path.to_string_lossy().to_string()); - return Ok(()); - } - - // 레거시 호환: 로컬 절대 경로를 appdata/images로 복사 - if let Some(source_path) = super::local_source_path_from_image_ref(trimmed) { - if source_path.exists() { - if source_path.starts_with(images_dir) { - *image_ref = Some(source_path.to_string_lossy().to_string()); - return Ok(()); - } - let extension = - normalize_image_extension(source_path.extension().and_then(|ext| ext.to_str())); - let imported = match import_image_file(&source_path, images_dir, &extension) { - Ok(imported) => imported, - Err(err) => { - log::warn!( - "[Preset] Failed to copy local image from '{}': {err}", - source_path.display() - ); - *image_ref = None; - return Ok(()); - } - }; - *image_ref = Some(imported.path.to_string_lossy().to_string()); - return Ok(()); - } - - // 다른 기기에서 import된 Preset: 해석 불가한 절대 경로는 정상 fallback 처리 - log::warn!( - "[Preset] Clearing image reference to a file missing on this machine: {trimmed}" - ); - *image_ref = None; - return Ok(()); - } - - Ok(()) -} - -fn restore_preset_local_sounds( - app: &AppHandle, - key_positions: &mut KeyPositions, - stat_positions: &mut StatPositions, - graph_positions: &mut GraphPositions, - knob_positions: &mut KnobPositions, - embedded_local_sounds: Option<&[EmbeddedLocalSound]>, -) -> CmdResult<()> { - let has_any_sounds = key_positions.values().any(|positions| { - positions - .iter() - .any(|position| option_has_non_empty_text(&position.sound_path)) - }) || stat_positions.values().any(|positions| { - positions - .iter() - .any(|stat_position| option_has_non_empty_text(&stat_position.position.sound_path)) - }) || graph_positions.values().any(|positions| { - positions - .iter() - .any(|graph_position| option_has_non_empty_text(&graph_position.position.sound_path)) - }) || knob_positions.values().any(|positions| { - positions - .iter() - .any(|knob_position| option_has_non_empty_text(&knob_position.position.sound_path)) - }); - - if !has_any_sounds { - return Ok(()); - } - - let app_data_dir = app.path().app_data_dir()?; - let sounds_dir = app_data_dir.join("sounds"); - - restore_preset_local_sounds_in_dir( - &sounds_dir, - key_positions, - stat_positions, - graph_positions, - knob_positions, - embedded_local_sounds, - ) -} - -fn restore_preset_local_sounds_in_dir( - sounds_dir: &Path, - key_positions: &mut KeyPositions, - stat_positions: &mut StatPositions, - graph_positions: &mut GraphPositions, - knob_positions: &mut KnobPositions, - embedded_local_sounds: Option<&[EmbeddedLocalSound]>, -) -> CmdResult<()> { - fs::create_dir_all(sounds_dir)?; - - let embedded_map: HashMap<&str, &EmbeddedLocalSound> = embedded_local_sounds - .unwrap_or(&[]) - .iter() - .map(|sound| (sound.sound_id.as_str(), sound)) - .collect(); - - let mut restored_path_cache: HashMap = HashMap::new(); - - for positions in key_positions.values_mut() { - for position in positions.iter_mut() { - restore_position_sound_reference( - sounds_dir, - &embedded_map, - &mut restored_path_cache, - &mut position.sound_path, - )?; - } - } - - for positions in stat_positions.values_mut() { - for stat_position in positions.iter_mut() { - restore_position_sound_reference( - sounds_dir, - &embedded_map, - &mut restored_path_cache, - &mut stat_position.position.sound_path, - )?; - } - } - - for positions in graph_positions.values_mut() { - for graph_position in positions.iter_mut() { - restore_position_sound_reference( - sounds_dir, - &embedded_map, - &mut restored_path_cache, - &mut graph_position.position.sound_path, - )?; - } - } - - for positions in knob_positions.values_mut() { - for knob_position in positions.iter_mut() { - restore_position_sound_reference( - sounds_dir, - &embedded_map, - &mut restored_path_cache, - &mut knob_position.position.sound_path, - )?; - } - } - - Ok(()) -} - -fn restore_position_sound_reference( - sounds_dir: &Path, - embedded_map: &HashMap<&str, &EmbeddedLocalSound>, - restored_path_cache: &mut HashMap, - sound_ref: &mut Option, -) -> CmdResult<()> { - let Some(current_value) = sound_ref.clone() else { - return Ok(()); - }; - let trimmed = current_value.trim(); - if trimmed.is_empty() { - return Ok(()); - } - - if let Some(sound_id) = trimmed.strip_prefix(PRESET_LOCAL_SOUND_PREFIX) { - if let Some(restored_path) = restored_path_cache.get(sound_id) { - *sound_ref = Some(restored_path.clone()); - return Ok(()); - } - let Some(embedded) = embedded_map.get(sound_id) else { - log::warn!( - "[Preset] Missing embedded sound payload for id '{}'; clearing sound reference", - sound_id - ); - *sound_ref = None; - return Ok(()); - }; - - let bytes = match BASE64_STANDARD.decode(embedded.data_base64.as_bytes()) { - Ok(bytes) => bytes, - Err(err) => { - log::warn!( - "[Preset] Failed to decode embedded sound '{}': {err}", - sound_id - ); - *sound_ref = None; - return Ok(()); - } - }; - - let extension = normalize_sound_extension(embedded.extension.as_deref()); - let dest_path = sounds_dir.join(format!("{}.{}", Uuid::new_v4(), extension)); - if let Err(err) = fs::write(&dest_path, bytes) { - log::warn!( - "[Preset] Failed to restore embedded sound '{}': {err}", - sound_id - ); - *sound_ref = None; - return Ok(()); - } - let restored = dest_path.to_string_lossy().to_string(); - restored_path_cache.insert(sound_id.to_string(), restored.clone()); - *sound_ref = Some(restored); - return Ok(()); - } - - // 레거시 호환: 절대 경로가 현재 기기에서 유효하면 그대로 유지. - let path = std::path::PathBuf::from(trimmed); - if path.is_absolute() && path.exists() { - return Ok(()); - } - - // 다른 기기에서 임포트된 프리셋: 경로를 해석할 수 없으면 초기화. - log::warn!("[Preset] Clearing sound reference to a file missing on this machine: {trimmed}"); - *sound_ref = None; - Ok(()) -} - fn synthesize_custom_tabs(keys: &KeyMappings) -> Vec { let default_modes = default_keys(); let mut custom_ids = keys @@ -1808,1353 +972,4 @@ fn resolve_full_preset_layer_groups( } #[cfg(test)] -mod tests { - use super::*; - use crate::{ - defaults::{default_keys, default_positions}, - models::{ - CustomCssHistoryEntry, CustomFont, FontWeightRange, GraphPosition, GraphStatType, - GraphType, JsPlugin, KnobPosition, StatPosition, StatType, - }, - }; - - #[test] - fn full_preset_settings_patch_preserves_custom_css_history() { - let history = vec![CustomCssHistoryEntry { - path: "/tmp/preserved.css".to_string(), - loaded_at: 123, - last_used_at: 123, - }]; - let mut store = AppStoreData { - custom_css_history: history.clone(), - ..AppStoreData::default() - }; - let mut preset = PresetFile { - use_custom_css: Some(true), - custom_css: Some(CustomCss { - path: Some("/tmp/preset.css".to_string()), - content: "body {}".to_string(), - }), - ..PresetFile::default() - }; - let resolved = resolve_full_preset_settings(&mut preset, &store); - let patch = SettingsPatchInput { - use_custom_css: Some(resolved.use_custom_css), - custom_css: Some(CustomCssPatch { - path: Some(resolved.custom_css.path), - content: Some(resolved.custom_css.content), - }), - ..SettingsPatchInput::default() - }; - - apply_patch_to_store(&mut store, &patch); - - assert_eq!(store.custom_css_history, history); - } - - #[test] - fn synthesized_custom_tabs_are_deterministic_without_preset_metadata() { - let keys = KeyMappings::from([ - ("custom-z".to_string(), Vec::new()), - ("custom-a".to_string(), Vec::new()), - ("4key".to_string(), Vec::new()), - ]); - - let tabs = synthesize_custom_tabs(&keys); - - assert_eq!( - tabs.iter().map(|tab| tab.id.as_str()).collect::>(), - ["custom-a", "custom-z"] - ); - assert_eq!( - tabs.iter().map(|tab| tab.name.as_str()).collect::>(), - ["Custom 1", "Custom 2"] - ); - } - - #[test] - fn committed_preset_css_paths_exclude_unrelated_store_paths() { - let mut committed = AppStoreData { - custom_css: CustomCss { - path: Some("/tmp/unrelated-global.css".to_string()), - content: String::new(), - }, - ..AppStoreData::default() - }; - committed.tab_css_overrides.insert( - "4key".to_string(), - TabCss { - path: Some("/tmp/imported-tab.css".to_string()), - content: String::new(), - enabled: true, - }, - ); - committed.tab_css_overrides.insert( - "7key".to_string(), - TabCss { - path: Some("/tmp/unrelated.css".to_string()), - content: String::new(), - enabled: true, - }, - ); - let imported = ImportedCssPaths { - global: None, - tabs: vec![ - "/tmp/imported-tab.css".to_string(), - "/tmp/not-committed.css".to_string(), - ], - }; - - assert_eq!( - committed_preset_css_paths(&committed, &imported), - vec!["/tmp/imported-tab.css".to_string()] - ); - - let imported_with_global = ImportedCssPaths { - global: Some("/tmp/unrelated-global.css".to_string()), - tabs: imported.tabs, - }; - assert_eq!( - committed_preset_css_paths(&committed, &imported_with_global), - vec![ - "/tmp/imported-tab.css".to_string(), - "/tmp/unrelated-global.css".to_string(), - ] - ); - } - - #[test] - fn preset_source_bytes_remain_unchanged_on_success_and_parse_failure() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-read-only-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - let source_path = temp_dir.join("source.json"); - - let valid_source = br#"{ - "keys": { "custom": ["Q"] }, - "keyPositions": { - "custom": [{ "dx": 12.5, "dy": -4.0, "width": 61.0, "count": 3 }] - }, - "customJS": { - "path": null, - "content": "globalThis.oldPreset = true", - "plugins": [{ - "id": "plugin-source", - "name": "Source plugin", - "path": null, - "content": "void 0", - "enabled": true - }] - }, - "embeddedLocalImages": [{ - "imageId": "image-source", - "extension": "png", - "dataBase64": "AA==" - }] -}"#; - std::fs::write(&source_path, valid_source).unwrap(); - let parsed = read_preset_file(&source_path).unwrap(); - assert_eq!( - parsed.keys.as_ref().unwrap()["custom"], - [KeySlot::from("Q")] - ); - assert_eq!(parsed.key_positions.as_ref().unwrap()["custom"][0].dx, 12.5); - assert_eq!( - parsed.custom_js.as_ref().unwrap().plugins[0].id, - "plugin-source" - ); - assert_eq!( - parsed.embedded_local_images.as_ref().unwrap()[0].image_id, - "image-source" - ); - assert_eq!(std::fs::read(&source_path).unwrap(), valid_source); - - let invalid_source = b"{ invalid preset"; - std::fs::write(&source_path, invalid_source).unwrap(); - assert!(read_preset_file(&source_path).is_err()); - assert_eq!(std::fs::read(&source_path).unwrap(), invalid_source); - - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn preset_missing_note_gradient_multipliers_default_to_one_hundred() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-note-gradient-default-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - let source_path = temp_dir.join("source.json"); - let source = serde_json::json!({ - "keyPositions": { - "custom": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteGradient": { - "angle": 45, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }, - "noteGlowGradient": { - "angle": 135, - "stops": [ - { "color": "#778899", "pos": 0 }, - { "color": "#AABBCC", "pos": 1 } - ] - } - }] - } - }); - std::fs::write(&source_path, serde_json::to_vec_pretty(&source).unwrap()).unwrap(); - - let parsed = read_preset_file(&source_path).unwrap(); - let positions = parsed.key_positions.unwrap(); - let position = &positions["custom"][0]; - assert_eq!(position.note_opacity, 100); - assert_eq!(position.note_glow_opacity, 100); - - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn preset_removed_outline_fields_scrub_and_block_legacy_default_collision() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-removed-outline-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - let source_path = temp_dir.join("source.json"); - let source = serde_json::json!({ - "keyPositions": { - "custom": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "fontStrokeColor": "#112233", - "activeFontStrokeColor": "#445566", - "counter": { - "enabled": true, - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { "idle": "#FFFFFF", "active": "#000000" }, - "stroke": { "idle": "#000000", "active": "#FFFFFF" }, - "strokeIdleGradient": { - "angle": 90, - "stops": [ - { "color": "#000000", "pos": 0 }, - { "color": "#FFFFFF", "pos": 1 } - ] - }, - "gap": 6, - "fontSize": 16, - "fontWeight": 400, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - } - }] - } - }); - std::fs::write(&source_path, serde_json::to_vec_pretty(&source).unwrap()).unwrap(); - - let parsed = read_preset_file(&source_path).unwrap(); - let position = &parsed.key_positions.unwrap()["custom"][0]; - let mut counter = position.counter.clone(); - assert_eq!(counter.fill.idle, "rgba(255,255,255,1)"); - assert_eq!(counter.align, crate::models::KeyCounterAlign::Top); - - let serialized = serde_json::to_value(&counter).unwrap(); - assert!(serialized.get("stroke").is_none()); - assert!(serialized.get("strokeIdleGradient").is_none()); - assert!(!counter.migrate_legacy_defaults()); - assert_eq!(counter.align, crate::models::KeyCounterAlign::Top); - - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn damaged_gradient_preset_reports_element_and_field_with_existing_error_code() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-gradient-error-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - let source_path = temp_dir.join("source.json"); - let damaged_gradient = serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#FFFFFF", "pos": 0 }] - }); - - for (collection, field, counter_field) in [ - ("keyPositions", "backgroundGradient", false), - ("statPositions", "activeBackgroundGradient", false), - ("graphPositions", "borderGradient", false), - ("knobPositions", "activeBorderGradient", false), - ("keyPositions", "fontGradient", false), - ("statPositions", "activeFontGradient", false), - ("keyPositions", "noteBorderGradient", false), - ("keyPositions", "fillIdleGradient", true), - ("statPositions", "fillActiveGradient", true), - ] { - let mut gradient_fields = serde_json::Map::new(); - gradient_fields.insert(field.to_string(), damaged_gradient.clone()); - let damaged_entry = if counter_field { - let mut entry = serde_json::Map::new(); - entry.insert( - "counter".to_string(), - serde_json::Value::Object(gradient_fields), - ); - serde_json::Value::Object(entry) - } else { - serde_json::Value::Object(gradient_fields) - }; - let mut modes = serde_json::Map::new(); - modes.insert( - "custom mode".to_string(), - serde_json::json!([{}, damaged_entry]), - ); - let mut preset = serde_json::Map::new(); - preset.insert(collection.to_string(), serde_json::Value::Object(modes)); - std::fs::write( - &source_path, - serde_json::to_vec(&serde_json::Value::Object(preset)).unwrap(), - ) - .unwrap(); - - let error = read_preset_file(&source_path) - .err() - .expect("damaged gradient preset must be rejected") - .to_string(); - let field_path = if counter_field { - format!("counter.{field}") - } else { - field.to_string() - }; - let expected_prefix = - format!("invalid-preset: {collection}[\"custom mode\"][1].{field_path}: "); - assert!( - error.starts_with(&expected_prefix), - "unexpected gradient error: {error}" - ); - assert!(error.contains("gradient must contain at least two stops")); - } - - std::fs::write(&source_path, b"{ invalid preset").unwrap(); - assert_eq!( - read_preset_file(&source_path) - .err() - .expect("invalid JSON preset must be rejected") - .to_string(), - "invalid-preset" - ); - - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn preset_rejects_out_of_range_image_transform_with_path() { - let preset = serde_json::json!({ - "keyPositions": { - "custom mode": [{ - "idleImageTransform": { - "offsetX": 0, "offsetY": 0, "rotation": 0, "scale": 0.05 - } - }] - } - }); - assert_eq!( - invalid_position_style_detail(&preset).as_deref(), - Some( - "keyPositions[\"custom mode\"][0].idleImageTransform.scale: must be a finite number between 0.1 and 10" - ) - ); - - let valid = serde_json::json!({ - "keyPositions": { - "custom mode": [{ - "idleImageTransform": null, - "activeImageTransform": { - "offsetX": -500, "offsetY": 500, "rotation": 180, "scale": 10 - } - }] - } - }); - assert_eq!(invalid_position_style_detail(&valid), None); - } - - #[test] - fn general_gradient_preset_rejects_blank_stop_color_with_index() { - // 공백 stop은 로드 복구가 수렴하지 않는 손상 값 - 문에서 거부 - for field in ["fontGradient", "backgroundGradient", "borderGradient"] { - let preset = serde_json::json!({ - "keyPositions": { - "custom mode": [{ - (field): { - "angle": 45, - "stops": [ - { "color": " ", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - } - }] - } - }); - - assert_eq!( - invalid_position_style_detail(&preset).as_deref(), - Some( - format!( - "keyPositions[\"custom mode\"][0].{field}: stops[0].color must not be blank" - ) - .as_str() - ) - ); - } - - let valid = serde_json::json!({ - "keyPositions": { - "custom mode": [{ - "fontGradient": { - "angle": 45, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - } - }] - } - }); - assert_eq!(invalid_position_style_detail(&valid), None); - } - - #[test] - fn note_border_gradient_preset_rejects_invalid_stop_color_with_index() { - for collection in POSITION_COLLECTION_FIELDS { - let preset = serde_json::json!({ - (collection): { - "custom mode": [{ - "noteBorderGradient": { - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "transparent", "pos": 1 } - ] - } - }] - } - }); - - let error = invalid_position_style_detail(&preset) - .expect("unsupported note border stop color must be rejected"); - assert_eq!( - error, - format!( - "{collection}[\"custom mode\"][0].noteBorderGradient: stops[1].color contains an unsupported note border color" - ) - ); - } - - for collection in POSITION_COLLECTION_FIELDS { - for field in ["noteGradient", "noteGlowGradient"] { - let preset = serde_json::json!({ - (collection): { - "custom mode": [{ - (field): { - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "transparent", "pos": 1 } - ] - } - }] - } - }); - - assert_eq!( - invalid_position_style_detail(&preset).as_deref(), - Some( - format!( - "{collection}[\"custom mode\"][0].{field}: stops[1].color contains an unsupported note gradient color" - ) - .as_str() - ) - ); - } - } - - let valid = serde_json::json!({ - "keyPositions": { - "custom mode": [{ - "noteGradient": { - "angle": 45, - "stops": [ - { "color": "#1238", "pos": 0 }, - { "color": "rgb(4, 5, 6)", "pos": 1 } - ] - }, - "noteGlowGradient": { - "angle": 135, - "stops": [ - { "color": "rgba(7, 8, 9, .25)", "pos": 0 }, - { "color": "#ABC", "pos": 1 } - ] - }, - "noteBorderGradient": { - "angle": 90, - "stops": [ - { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, - { "color": "#ABC8", "pos": 1 } - ] - } - }] - } - }); - assert_eq!(invalid_position_style_detail(&valid), None); - - let invalid_discarded_stop = serde_json::json!({ - "keyPositions": { - "custom mode": [{ - "noteBorderGradient": { - "angle": 90, - "stops": [ - { "color": "#000000", "pos": 0.0 }, - { "color": "#111111", "pos": 0.1 }, - { "color": "#222222", "pos": 0.2 }, - { "color": "#333333", "pos": 0.3 }, - { "color": "#444444", "pos": 0.4 }, - { "color": "#555555", "pos": 0.5 }, - { "color": "#666666", "pos": 0.6 }, - { "color": "#777777", "pos": 0.7 }, - { "color": "invalid-discarded-stop", "pos": 1.0 } - ] - } - }] - } - }); - assert_eq!( - invalid_position_style_detail(&invalid_discarded_stop).as_deref(), - Some( - "keyPositions[\"custom mode\"][0].noteBorderGradient: stops[8].color contains an unsupported note border color" - ) - ); - } - - #[test] - fn invalid_shadow_preset_reports_exact_element_paths() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-shadow-error-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - let source_path = temp_dir.join("source.json"); - for (collection, entry, expected_path, expected_reason) in [ - ( - "keyPositions", - serde_json::json!({ - "shadow": { - "enabled": true, - "color": "#123456", - "offsetX": 0, - "offsetY": 0, - "blur": 100.1 - } - }), - "shadow.blur", - "must be a finite number between 0 and 100", - ), - ( - "statPositions", - serde_json::json!({ - "activeShadow": { - "enabled": true, - "color": "#123456", - "offsetX": -100.1, - "offsetY": 0, - "blur": 12 - } - }), - "activeShadow.offsetX", - "must be a finite number between -100 and 100", - ), - ( - "graphPositions", - serde_json::json!({ - "shadow": { - "enabled": true, - "color": "#123456", - "offsetX": 0, - "offsetY": 100.1, - "blur": 12 - } - }), - "shadow.offsetY", - "must be a finite number between -100 and 100", - ), - ( - "knobPositions", - serde_json::json!({ "activeShadow": [] }), - "activeShadow", - "must be an object", - ), - ( - "keyPositions", - serde_json::json!({ - "shadow": { - "enabled": true, - "color": "", - "offsetX": 0, - "offsetY": 0, - "blur": 12 - } - }), - "shadow.color", - "must be a non-empty string", - ), - ] { - let preset = serde_json::json!({ - (collection): { - "custom mode": [{}, entry] - } - }); - std::fs::write(&source_path, serde_json::to_vec(&preset).unwrap()).unwrap(); - - let error = read_preset_file(&source_path) - .err() - .expect("invalid shadow preset must be rejected") - .to_string(); - assert_eq!( - error, - format!( - "invalid-preset: {collection}[\"custom mode\"][1].{expected_path}: {expected_reason}" - ) - ); - } - - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn null_shadow_fields_are_treated_as_absent() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-shadow-null-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - let source_path = temp_dir.join("source.json"); - - // 외부 생성·수동 편집 프리셋의 명시적 null은 Option 역직렬화처럼 값 없음 - let preset = serde_json::json!({ - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "shadow": null, - "activeShadow": null - }] - } - }); - std::fs::write(&source_path, serde_json::to_vec(&preset).unwrap()).unwrap(); - - let parsed = read_preset_file(&source_path).expect("null fields must parse as absent"); - let position = &parsed.key_positions.as_ref().unwrap()["4key"][0]; - assert!(position.shadow.is_none()); - assert!(position.active_shadow.is_none()); - - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn legacy_and_bounded_shadow_presets_still_parse() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-shadow-compatibility-test-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - let source_path = temp_dir.join("source.json"); - - let legacy = serde_json::json!({ - "keyPositions": { - "4key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }] - } - }); - std::fs::write(&source_path, serde_json::to_vec(&legacy).unwrap()).unwrap(); - let parsed_legacy = read_preset_file(&source_path).unwrap(); - let legacy_position = &parsed_legacy.key_positions.unwrap()["4key"][0]; - assert!(legacy_position.shadow.is_none()); - assert!(legacy_position.active_shadow.is_none()); - - let position = serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "shadow": { - "enabled": true, - "color": "#123456", - "offsetX": -100, - "offsetY": 100, - "blur": 100 - }, - "activeShadow": { - "enabled": false, - "color": "rgba(0, 0, 0, 0)", - "offsetX": 100, - "offsetY": -100, - "blur": 0 - } - }); - let mut stat = position.clone(); - stat.as_object_mut() - .unwrap() - .insert("statType".to_string(), serde_json::json!("kps")); - let mut graph = position.clone(); - graph - .as_object_mut() - .unwrap() - .extend(serde_json::Map::from_iter([ - ("statType".to_string(), serde_json::json!("kps")), - ("graphType".to_string(), serde_json::json!("line")), - ("graphSpeed".to_string(), serde_json::json!(100)), - ("graphColor".to_string(), serde_json::json!("#123456")), - ])); - let bounded = serde_json::json!({ - "keyPositions": { "4key": [position.clone()] }, - "statPositions": { "4key": [stat] }, - "graphPositions": { "4key": [graph] }, - "knobPositions": { "4key": [position] } - }); - std::fs::write(&source_path, serde_json::to_vec(&bounded).unwrap()).unwrap(); - let parsed = read_preset_file(&source_path).unwrap(); - assert_eq!( - parsed.key_positions.as_ref().unwrap()["4key"][0] - .shadow - .as_ref() - .unwrap() - .blur, - 100.0 - ); - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn missing_legacy_global_fields_preserve_current_plugin_and_style_settings() { - let current = AppStoreData { - background_color: "#123456".to_string(), - note_settings: NoteSettings { - speed: 777, - ..NoteSettings::default() - }, - note_effect: true, - laboratory_enabled: true, - use_custom_css: true, - custom_css: CustomCss { - path: Some("/current/style.css".to_string()), - content: ".current {}".to_string(), - }, - use_custom_js: true, - custom_js: CustomJs { - path: None, - content: "globalThis.current = true".to_string(), - plugins: vec![JsPlugin { - id: "current-plugin".to_string(), - name: "Current plugin".to_string(), - path: None, - content: "void 0".to_string(), - enabled: true, - }], - }, - ..AppStoreData::default() - }; - let mut legacy = PresetFile::default(); - - let resolved = resolve_full_preset_settings(&mut legacy, ¤t); - - assert_eq!(resolved.background_color, current.background_color); - assert_eq!(resolved.note_settings, current.note_settings); - assert_eq!(resolved.note_effect, current.note_effect); - assert_eq!(resolved.laboratory_enabled, current.laboratory_enabled); - assert_eq!(resolved.use_custom_css, current.use_custom_css); - assert_eq!(resolved.custom_css, current.custom_css); - assert_eq!(resolved.use_custom_js, current.use_custom_js); - assert_eq!(resolved.custom_js, current.custom_js); - } - - #[test] - fn tauri_130_literal_without_js_fields_preserves_installed_plugins() { - let current = AppStoreData { - use_custom_js: true, - custom_js: CustomJs { - path: None, - content: "globalThis.current = true".to_string(), - plugins: vec![JsPlugin { - id: "installed-plugin".to_string(), - name: "Installed plugin".to_string(), - path: None, - content: "globalThis.installed = true".to_string(), - enabled: true, - }], - }, - ..AppStoreData::default() - }; - let mut preset: PresetFile = serde_json::from_value(serde_json::json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": { "4key": [{ "dx": 1, "dy": 2, "width": 60, "count": 0 }] }, - "backgroundColor": "transparent" - })) - .unwrap(); - - let resolved = resolve_full_preset_settings(&mut preset, ¤t); - - assert!(resolved.use_custom_js); - assert_eq!(resolved.custom_js, current.custom_js); - } - - #[test] - fn legacy_and_multi_key_preset_slots_share_the_normalization_path() { - let preset: PresetFile = serde_json::from_value(serde_json::json!({ - "keys": { - "4key": [ - "Q", - { "keys": ["A", "B"], "match": "any" }, - { "keys": ["Z"], "match": "all" } - ] - } - })) - .unwrap(); - let keys = preset.keys.unwrap(); - - assert_eq!(keys["4key"][0], KeySlot::Single("Q".to_string())); - assert_eq!( - keys["4key"][1], - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: crate::models::SlotMatch::Any, - } - ); - assert_eq!(keys["4key"][2], KeySlot::Single("Z".to_string())); - assert_eq!( - serde_json::to_value(keys).unwrap()["4key"], - serde_json::json!([ - "Q", - { "keys": ["A", "B"], "match": "any" }, - "Z" - ]) - ); - } - - #[test] - fn tauri_161_literal_imports_its_plugin_list_exactly() { - let mut current = AppStoreData::default(); - current.custom_js.plugins.push(JsPlugin { - id: "current-plugin".to_string(), - name: "Current plugin".to_string(), - path: None, - content: "void 0".to_string(), - enabled: true, - }); - let mut preset: PresetFile = serde_json::from_value(serde_json::json!({ - "useCustomJS": true, - "customJS": { - "path": null, - "content": "globalThis.legacy = true", - "plugins": [{ - "id": "plugin-161", - "name": "Legacy plugin", - "path": null, - "content": "globalThis.plugin161 = true", - "enabled": true - }] - } - })) - .unwrap(); - - let resolved = resolve_full_preset_settings(&mut preset, ¤t); - - assert!(resolved.use_custom_js); - assert_eq!(resolved.custom_js.plugins.len(), 1); - assert_eq!(resolved.custom_js.plugins[0].id, "plugin-161"); - assert!(!resolved - .custom_js - .plugins - .iter() - .any(|plugin| plugin.id == "current-plugin")); - } - - #[test] - fn explicit_empty_plugin_settings_still_clear_current_plugins() { - let current = AppStoreData { - use_custom_js: true, - custom_js: CustomJs { - plugins: vec![JsPlugin { - id: "current-plugin".to_string(), - name: "Current plugin".to_string(), - path: None, - content: "void 0".to_string(), - enabled: true, - }], - ..CustomJs::default() - }, - ..AppStoreData::default() - }; - let mut preset = PresetFile { - use_custom_js: Some(false), - custom_js: Some(CustomJs::default()), - ..PresetFile::default() - }; - - let resolved = resolve_full_preset_settings(&mut preset, ¤t); - - assert!(!resolved.use_custom_js); - assert_eq!(resolved.custom_js, CustomJs::default()); - } - - #[test] - fn legacy_tab_preset_without_note_override_preserves_current_override() { - let mut store = AppStoreData::default(); - let current = TabNoteSettings { - speed: Some(654), - ..TabNoteSettings::default() - }; - store - .tab_note_overrides - .insert("4key".to_string(), current.clone()); - - apply_tab_note_override(&mut store, "4key", false, None); - - assert_eq!(store.tab_note_overrides["4key"], current); - } - - #[test] - fn explicit_empty_tab_note_override_removes_current_override() { - let mut store = AppStoreData::default(); - store.tab_note_overrides.insert( - "4key".to_string(), - TabNoteSettings { - speed: Some(654), - ..TabNoteSettings::default() - }, - ); - - apply_tab_note_override(&mut store, "4key", true, None); - - assert!(!store.tab_note_overrides.contains_key("4key")); - } - - #[test] - fn historical_full_preset_without_groups_starts_each_imported_mode_ungrouped() { - let keys = KeyMappings::from([ - ("4key".to_string(), vec![KeySlot::from("A")]), - ("custom-old".to_string(), vec![KeySlot::from("B")]), - ]); - - let groups = resolve_full_preset_layer_groups(None, &keys); - - assert_eq!(groups.len(), 2); - assert!(groups["4key"].is_empty()); - assert!(groups["custom-old"].is_empty()); - } - - #[test] - fn tab_preset_duplicate_font_does_not_create_embedded_file() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-tab-preset-font-load-test-{}", - uuid::Uuid::new_v4() - )); - let fonts_dir = temp_dir.join("fonts"); - std::fs::create_dir_all(&fonts_dir).unwrap(); - let existing_path = fonts_dir.join("existing.ttf"); - std::fs::write(&existing_path, b"existing-font").unwrap(); - - let existing_fonts = FontSettings { - custom_fonts: vec![CustomFont { - id: "existing-id".to_string(), - font_type: FontType::Local, - name: "SharedFont".to_string(), - display_name: "Existing Font".to_string(), - enabled: true, - local_path: Some(existing_path.to_string_lossy().to_string()), - css_content: None, - weight_ranges: Vec::new(), - }], - }; - let imported_font_id = "imported-id".to_string(); - let imported_fonts = FontSettings { - custom_fonts: vec![CustomFont { - id: imported_font_id.clone(), - font_type: FontType::Local, - name: "SharedFont".to_string(), - display_name: "Imported Font".to_string(), - enabled: true, - local_path: None, - css_content: None, - weight_ranges: Vec::new(), - }], - }; - let embedded_fonts = vec![EmbeddedLocalFont { - font_id: imported_font_id, - extension: Some("ttf".to_string()), - data_base64: BASE64_STANDARD.encode(b"imported-font"), - }]; - let file_count_before = std::fs::read_dir(&fonts_dir).unwrap().count(); - - let merged = merge_tab_preset_fonts(&existing_fonts, imported_fonts, |filtered_fonts| { - restore_preset_local_fonts_in_dir(&fonts_dir, filtered_fonts, Some(&embedded_fonts)) - }) - .unwrap(); - - assert!(merged.is_none()); - assert_eq!( - std::fs::read_dir(&fonts_dir).unwrap().count(), - file_count_before - ); - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn tab_preset_keeps_all_local_faces_for_a_new_family() { - let face = |id: &str, weight: u16| CustomFont { - id: id.to_string(), - font_type: FontType::Local, - name: "Family".to_string(), - display_name: "Family".to_string(), - enabled: true, - local_path: Some(format!("/{id}.ttf")), - css_content: None, - weight_ranges: vec![FontWeightRange { - min: weight, - max: weight, - }], - }; - let imported = FontSettings { - custom_fonts: vec![ - face("regular", 400), - face("bold", 700), - // 프리셋 내부 중복(id 동일)은 한 번만 수용 - face("bold", 700), - ], - }; - - // 실제 탭 로드 경로(prepare → restore → merge) 전체를 통과시킨다 - let merged = merge_tab_preset_fonts(&FontSettings::default(), imported, |_| Ok(())) - .unwrap() - .unwrap(); - - assert_eq!(merged.custom_fonts.len(), 2); - assert_eq!(merged.custom_fonts[0].weight_ranges[0].min, 400); - assert_eq!(merged.custom_fonts[1].weight_ranges[0].min, 700); - - // 기존에 같은 이름이 있으면 그 family 전체를 기존 정의로 유지 - let existing = FontSettings { - custom_fonts: vec![face("existing", 400)], - }; - let imported = FontSettings { - custom_fonts: vec![face("regular", 400), face("bold", 700)], - }; - assert!(merge_tab_preset_fonts(&existing, imported, |_| Ok(())) - .unwrap() - .is_none()); - } - - #[test] - fn tab_preset_key_pair_merge_preserves_other_modes_and_latest_target_positions() { - let mut store = AppStoreData { - keys: default_keys().clone(), - key_positions: default_positions().clone(), - ..AppStoreData::default() - }; - store.key_positions.get_mut("4key").unwrap()[0].dx = 777.0; - let untouched_keys = store.keys["5key"].clone(); - let untouched_positions = store.key_positions["5key"].clone(); - - merge_tab_preset_key_pair(&mut store, "4key", vec![KeySlot::from("Imported")], None); - - assert_eq!(store.keys["5key"], untouched_keys); - assert_eq!(store.key_positions["5key"], untouched_positions); - assert_eq!(store.key_positions["4key"][0].dx, 777.0); - assert_eq!(store.keys["4key"][0], KeySlot::from("Imported")); - assert_eq!(store.keys["4key"].len(), store.key_positions["4key"].len()); - } - - fn old_preset_store() -> AppStoreData { - AppStoreData { - key_positions: KeyPositions::from([ - ("target".to_string(), vec![KeyPosition::default()]), - ("untouched".to_string(), vec![KeyPosition::default()]), - ]), - stat_positions: StatPositions::from([( - "target".to_string(), - vec![StatPosition { - stat_type: StatType::Kps, - position: KeyPosition::default(), - }], - )]), - graph_positions: GraphPositions::from([( - "target".to_string(), - vec![GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: KeyPosition::default(), - }], - )]), - knob_positions: KnobPositions::from([( - "target".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position: KeyPosition::default(), - }], - )]), - ..AppStoreData::default() - } - } - - fn target_preset_ids(store: &AppStoreData) -> Vec { - vec![ - store.key_positions["target"][0].id.clone(), - store.stat_positions["target"][0].position.id.clone(), - store.graph_positions["target"][0].position.id.clone(), - store.knob_positions["target"][0].position.id.clone(), - ] - } - - #[test] - fn old_full_preset_rekeys_every_application() { - let mut first = old_preset_store(); - rekey_full_preset_elements(&mut first); - let first_ids = target_preset_ids(&first); - let mut second = old_preset_store(); - rekey_full_preset_elements(&mut second); - let second_ids = target_preset_ids(&second); - - assert!(first_ids - .iter() - .all(|id| crate::state::native_element_id::is_valid_element_id(id))); - assert!(first_ids.iter().all(|id| !second_ids.contains(id))); - } - - #[test] - fn old_tab_preset_rekeys_only_written_collections_on_every_application() { - let mut store = old_preset_store(); - crate::state::native_element_id::backfill_store_element_ids(&mut store); - let untouched_id = store.key_positions["untouched"][0].id.clone(); - let original_ids = target_preset_ids(&store); - - rekey_tab_preset_elements(&mut store, "target", true, true, true, false); - let first_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, true, false); - let second_ids = target_preset_ids(&store); - - assert!(original_ids[..3] - .iter() - .zip(&first_ids[..3]) - .all(|(before, after)| before != after)); - assert!(first_ids[..3] - .iter() - .zip(&second_ids[..3]) - .all(|(before, after)| before != after)); - assert_eq!(first_ids[3], original_ids[3]); - assert_eq!(second_ids[3], original_ids[3]); - assert_eq!(store.key_positions["untouched"][0].id, untouched_id); - crate::state::native_element_id::validate_document_element_ids( - &crate::models::EditorDocumentV1::from_store(&store), - ) - .unwrap(); - } - - #[test] - fn tab_preset_without_key_positions_keeps_existing_key_ids() { - let mut store = old_preset_store(); - crate::state::native_element_id::backfill_store_element_ids(&mut store); - let original_ids = target_preset_ids(&store); - // keys만 담긴 탭 프리셋: merge가 기존 위치를 값 그대로 되삽입하고 - // 슬롯 정렬이 빈 위치 하나를 덧붙인 상태 - store - .key_positions - .get_mut("target") - .unwrap() - .push(KeyPosition::default()); - - rekey_tab_preset_elements(&mut store, "target", false, false, false, false); - - // 값이 그대로인 기존 키는 신원을 지키고, 덧붙은 슬롯만 새 id를 받는다 - assert_eq!(store.key_positions["target"][0].id, original_ids[0]); - let appended = &store.key_positions["target"][1].id; - assert!(crate::state::native_element_id::is_valid_element_id( - appended - )); - assert_ne!(appended, &original_ids[0]); - assert_eq!(target_preset_ids(&store)[1..], original_ids[1..]); - crate::state::native_element_id::validate_document_element_ids( - &crate::models::EditorDocumentV1::from_store(&store), - ) - .unwrap(); - } - - #[test] - fn preset_import_alignment_repairs_each_mode_without_dropping_values() { - let mut keys = KeyMappings::from([ - ("keys-only".to_string(), vec![KeySlot::from("A")]), - ("positions-long".to_string(), vec![KeySlot::from("B")]), - ]); - let mut positions = KeyPositions::from([ - ( - "positions-only".to_string(), - vec![KeyPosition { - dx: 123.0, - ..KeyPosition::default() - }], - ), - ( - "positions-long".to_string(), - vec![KeyPosition::default(), KeyPosition::default()], - ), - ]); - - align_imported_key_collections(&mut keys, &mut positions); - - assert_eq!(keys["keys-only"], vec![KeySlot::from("A")]); - assert_eq!(positions["keys-only"], vec![KeyPosition::default()]); - assert_eq!(keys["positions-only"], vec![KeySlot::default()]); - assert_eq!(positions["positions-only"][0].dx, 123.0); - assert_eq!( - keys["positions-long"], - vec![KeySlot::from("B"), KeySlot::default()] - ); - assert_eq!( - keys["positions-long"].len(), - positions["positions-long"].len() - ); - } - - #[test] - fn tab_preset_font_restore_failure_keeps_existing_settings_unchanged() { - let existing = FontSettings { - custom_fonts: vec![CustomFont { - id: "existing-id".to_string(), - font_type: FontType::Local, - name: "ExistingFont".to_string(), - display_name: "Existing Font".to_string(), - enabled: true, - local_path: Some("/existing/font.ttf".to_string()), - css_content: None, - weight_ranges: Vec::new(), - }], - }; - let before = existing.clone(); - let imported = FontSettings { - custom_fonts: vec![CustomFont { - id: "imported-id".to_string(), - font_type: FontType::Local, - name: "ImportedFont".to_string(), - display_name: "Imported Font".to_string(), - enabled: true, - local_path: None, - css_content: None, - weight_ranges: Vec::new(), - }], - }; - - let result = prepare_tab_preset_fonts(&existing, imported, |fonts| { - fonts.custom_fonts[0].local_path = Some("/staged/font.ttf".to_string()); - Err(CommandError::msg("restore-failed")) - }); - - assert!(result.is_err()); - assert_eq!(existing, before); - } - - #[test] - fn legacy_percent_encoded_file_url_is_copied_on_import() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-image-url-load-test-{}", - uuid::Uuid::new_v4() - )); - let source_dir = temp_dir.join("source folder"); - let images_dir = temp_dir.join("restored-images"); - std::fs::create_dir_all(&source_dir).unwrap(); - std::fs::create_dir_all(&images_dir).unwrap(); - let source_path = source_dir.join("image with space.png"); - std::fs::write(&source_path, b"legacy-image").unwrap(); - let mut image_ref = Some(url::Url::from_file_path(&source_path).unwrap().to_string()); - - restore_position_image_reference( - &images_dir, - &HashMap::new(), - &mut HashMap::new(), - &mut image_ref, - ) - .unwrap(); - - let restored_path = Path::new(image_ref.as_deref().unwrap()); - assert!(restored_path.starts_with(&images_dir)); - assert_eq!(std::fs::read(restored_path).unwrap(), b"legacy-image"); - assert_eq!(std::fs::read(&source_path).unwrap(), b"legacy-image"); - let _ = std::fs::remove_dir_all(temp_dir); - } - - #[test] - fn sound_restore_restores_knob_sound() { - let temp_dir = std::env::temp_dir().join(format!( - "dmnote-preset-knob-load-test-{}", - uuid::Uuid::new_v4() - )); - let sounds_dir = temp_dir.join("sounds"); - let sound_id = "knob-sound"; - let embedded = vec![EmbeddedLocalSound { - sound_id: sound_id.to_string(), - extension: Some("wav".to_string()), - data_base64: BASE64_STANDARD.encode(b"restored-knob-sound"), - }]; - - let mut position = default_positions()["4key"][0].clone(); - position.sound_path = Some(format!("{PRESET_LOCAL_SOUND_PREFIX}{sound_id}")); - let mut knob_positions = KnobPositions::new(); - knob_positions.insert( - "4key".to_string(), - vec![KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position, - }], - ); - - restore_preset_local_sounds_in_dir( - &sounds_dir, - &mut KeyPositions::new(), - &mut StatPositions::new(), - &mut GraphPositions::new(), - &mut knob_positions, - Some(&embedded), - ) - .unwrap(); - - let restored_path = Path::new( - knob_positions["4key"][0] - .position - .sound_path - .as_deref() - .unwrap(), - ); - assert!(restored_path.starts_with(&sounds_dir)); - assert_eq!( - std::fs::read(restored_path).unwrap(), - b"restored-knob-sound" - ); - let _ = std::fs::remove_dir_all(temp_dir); - } -} +mod tests; diff --git a/src-tauri/src/commands/preset/load/imported_assets.rs b/src-tauri/src/commands/preset/load/imported_assets.rs new file mode 100644 index 000000000..9117eb19e --- /dev/null +++ b/src-tauri/src/commands/preset/load/imported_assets.rs @@ -0,0 +1,608 @@ +use super::*; + +pub(super) fn migrate_imported_font_weights( + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, +) { + for position in key_positions.values_mut().flatten() { + position.migrate_legacy_font_weight(); + } + for position in stat_positions.values_mut().flatten() { + position.position.migrate_legacy_font_weight(); + } + for position in graph_positions.values_mut().flatten() { + position.position.migrate_legacy_font_weight(); + } + for position in knob_positions.values_mut().flatten() { + position.position.migrate_legacy_font_weight(); + } +} + +#[cfg(test)] +pub(super) fn merge_tab_preset_fonts( + existing_font_settings: &FontSettings, + imported_font_settings: FontSettings, + restore_fonts: impl FnOnce(&mut FontSettings) -> CmdResult<()>, +) -> CmdResult> { + let Some(prepared) = prepare_tab_preset_fonts( + existing_font_settings, + imported_font_settings, + restore_fonts, + )? + else { + return Ok(None); + }; + Ok(merge_prepared_tab_preset_fonts( + existing_font_settings, + prepared, + )) +} + +pub(super) fn prepare_tab_preset_fonts( + existing_font_settings: &FontSettings, + mut imported_font_settings: FontSettings, + restore_fonts: impl FnOnce(&mut FontSettings) -> CmdResult<()>, +) -> CmdResult> { + let existing_names: HashSet = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.name.clone()) + .collect(); + + // 같은 이름은 기존 정의 유지. 같은 family의 다른 페이스(파일)는 개별 자산이라 + // 이름으로 묶지 않고, 프리셋 내부 중복은 id 기준으로만 방어 + let mut seen_ids: HashSet = HashSet::new(); + imported_font_settings.custom_fonts.retain(|font| { + !existing_names.contains(&font.name) + && (font.id.is_empty() || seen_ids.insert(font.id.clone())) + }); + if imported_font_settings.custom_fonts.is_empty() { + return Ok(None); + } + + // 이름 필터 후 파일 복원 — 제외할 로컬 폰트의 고아 파일 생성 방지 + restore_fonts(&mut imported_font_settings)?; + + let mut existing_ids: HashSet = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.id.clone()) + .collect(); + for font in imported_font_settings.custom_fonts.iter_mut() { + if existing_ids.contains(&font.id) { + font.id = Uuid::new_v4().to_string(); + } + existing_ids.insert(font.id.clone()); + } + + Ok(Some(imported_font_settings)) +} + +pub(super) fn merge_prepared_tab_preset_fonts( + existing_font_settings: &FontSettings, + mut prepared: FontSettings, +) -> Option { + let existing_names = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.name.clone()) + .collect::>(); + let importable_names = prepared + .custom_fonts + .iter() + .filter(|font| !existing_names.contains(&font.name)) + .map(|font| font.name.clone()) + .collect::>(); + prepared + .custom_fonts + .retain(|font| importable_names.contains(&font.name)); + if prepared.custom_fonts.is_empty() { + return None; + } + + let mut existing_ids = existing_font_settings + .custom_fonts + .iter() + .map(|font| font.id.clone()) + .collect::>(); + for font in &mut prepared.custom_fonts { + if existing_ids.contains(&font.id) { + font.id = Uuid::new_v4().to_string(); + } + existing_ids.insert(font.id.clone()); + } + + let mut merged = existing_font_settings.clone(); + merged.custom_fonts.extend(prepared.custom_fonts); + Some(merged) +} + +pub(super) fn restore_preset_local_fonts( + app: &AppHandle, + font_settings: &mut FontSettings, + embedded_local_fonts: Option<&[EmbeddedLocalFont]>, +) -> CmdResult<()> { + let has_local_fonts = font_settings + .custom_fonts + .iter() + .any(|font| font.font_type == FontType::Local); + if !has_local_fonts { + return Ok(()); + } + + let app_data_dir = app.path().app_data_dir()?; + let fonts_dir = app_data_dir.join("fonts"); + + restore_preset_local_fonts_in_dir(&fonts_dir, font_settings, embedded_local_fonts) +} + +pub(super) fn restore_preset_local_fonts_in_dir( + fonts_dir: &Path, + font_settings: &mut FontSettings, + embedded_local_fonts: Option<&[EmbeddedLocalFont]>, +) -> CmdResult<()> { + let has_local_fonts = font_settings + .custom_fonts + .iter() + .any(|font| font.font_type == FontType::Local); + if !has_local_fonts { + return Ok(()); + } + + let embedded_map: HashMap<&str, &EmbeddedLocalFont> = embedded_local_fonts + .unwrap_or(&[]) + .iter() + .map(|font| (font.font_id.as_str(), font)) + .collect(); + + fs::create_dir_all(fonts_dir)?; + + for font in font_settings.custom_fonts.iter_mut() { + if font.font_type != FontType::Local { + continue; + } + + // 로컬 폰트는 항상 복사된 파일 경로로 제공 + font.css_content = None; + + if let Some(embedded) = embedded_map.get(font.id.as_str()) { + let bytes = match BASE64_STANDARD.decode(embedded.data_base64.as_bytes()) { + Ok(bytes) => bytes, + Err(err) => { + log::warn!( + "[Preset] Failed to decode embedded local font '{}': {err}", + font.display_name + ); + font.local_path = None; + font.enabled = false; + continue; + } + }; + + let extension = normalize_font_extension(embedded.extension.as_deref()); + let dest_path = fonts_dir.join(format!("{}.{}", Uuid::new_v4(), extension)); + if let Err(err) = fs::write(&dest_path, bytes) { + log::warn!( + "[Preset] Failed to restore local font file for '{}': {err}", + font.display_name + ); + font.local_path = None; + font.enabled = false; + continue; + } + font.local_path = Some(dest_path.to_string_lossy().to_string()); + continue; + } + + // 하위 호환: 기존 절대 경로가 유효하면 유지 + let has_existing_valid_path = font + .local_path + .as_ref() + .map(|path| !path.trim().is_empty() && Path::new(path).exists()) + .unwrap_or(false); + + if !has_existing_valid_path { + log::warn!( + "[Preset] Disabling font '{}' — no embedded payload and its file is missing on this machine", + font.name + ); + font.local_path = None; + font.enabled = false; + } + } + + Ok(()) +} + +pub(super) fn restore_preset_local_images( + app: &AppHandle, + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + embedded_local_images: Option<&[EmbeddedLocalImage]>, +) -> CmdResult<()> { + let has_any_images = key_positions.values().any(|positions| { + positions.iter().any(|position| { + option_has_non_empty_text(&position.active_image) + || option_has_non_empty_text(&position.inactive_image) + }) + }) || stat_positions.values().any(|positions| { + positions.iter().any(|stat_position| { + option_has_non_empty_text(&stat_position.position.active_image) + || option_has_non_empty_text(&stat_position.position.inactive_image) + }) + }) || graph_positions.values().any(|positions| { + positions.iter().any(|graph_position| { + option_has_non_empty_text(&graph_position.position.active_image) + || option_has_non_empty_text(&graph_position.position.inactive_image) + }) + }) || knob_positions.values().any(|positions| { + positions.iter().any(|knob_position| { + option_has_non_empty_text(&knob_position.position.active_image) + || option_has_non_empty_text(&knob_position.position.inactive_image) + }) + }); + + if !has_any_images { + return Ok(()); + } + + let embedded_map: HashMap<&str, &EmbeddedLocalImage> = embedded_local_images + .unwrap_or(&[]) + .iter() + .map(|image| (image.image_id.as_str(), image)) + .collect(); + let mut restored_path_cache: HashMap = HashMap::new(); + + let app_data_dir = app.path().app_data_dir()?; + let images_dir = app_data_dir.join("images"); + fs::create_dir_all(&images_dir)?; + + for positions in key_positions.values_mut() { + for position in positions.iter_mut() { + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut position.active_image, + )?; + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut position.inactive_image, + )?; + } + } + + for positions in stat_positions.values_mut() { + for stat_position in positions.iter_mut() { + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut stat_position.position.active_image, + )?; + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut stat_position.position.inactive_image, + )?; + } + } + + for positions in graph_positions.values_mut() { + for graph_position in positions.iter_mut() { + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut graph_position.position.active_image, + )?; + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut graph_position.position.inactive_image, + )?; + } + } + + for positions in knob_positions.values_mut() { + for knob_position in positions.iter_mut() { + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut knob_position.position.active_image, + )?; + restore_position_image_reference( + &images_dir, + &embedded_map, + &mut restored_path_cache, + &mut knob_position.position.inactive_image, + )?; + } + } + + Ok(()) +} + +pub(super) fn restore_position_image_reference( + images_dir: &Path, + embedded_map: &HashMap<&str, &EmbeddedLocalImage>, + restored_path_cache: &mut HashMap, + image_ref: &mut Option, +) -> CmdResult<()> { + let Some(current_value) = image_ref.clone() else { + return Ok(()); + }; + let trimmed = current_value.trim(); + if trimmed.is_empty() { + return Ok(()); + } + + if let Some(image_id) = trimmed.strip_prefix(PRESET_LOCAL_IMAGE_PREFIX) { + if let Some(restored_path) = restored_path_cache.get(image_id) { + *image_ref = Some(restored_path.clone()); + return Ok(()); + } + let Some(embedded) = embedded_map.get(image_id) else { + log::warn!( + "[Preset] Missing embedded image payload for id '{}'; clearing image reference", + image_id + ); + *image_ref = None; + return Ok(()); + }; + + let bytes = match BASE64_STANDARD.decode(embedded.data_base64.as_bytes()) { + Ok(bytes) => bytes, + Err(err) => { + log::warn!( + "[Preset] Failed to decode embedded image '{}': {err}", + image_id + ); + *image_ref = None; + return Ok(()); + } + }; + let extension = normalize_image_extension(embedded.extension.as_deref()); + let imported = match import_image_bytes(&bytes, images_dir, &extension) { + Ok(imported) => imported, + Err(err) => { + log::warn!( + "[Preset] Failed to restore embedded image '{}': {err}", + image_id + ); + *image_ref = None; + return Ok(()); + } + }; + let restored = imported.path.to_string_lossy().to_string(); + restored_path_cache.insert(image_id.to_string(), restored.clone()); + *image_ref = Some(restored); + return Ok(()); + } + + // 레거시 Preset 호환: data URL 이미지를 appdata 파일 경로로 변환 + if let Some((bytes, extension)) = decode_image_data_url(trimmed) { + let imported = import_image_bytes(&bytes, images_dir, &extension)?; + *image_ref = Some(imported.path.to_string_lossy().to_string()); + return Ok(()); + } + + // 레거시 호환: 로컬 절대 경로를 appdata/images로 복사 + if let Some(source_path) = super::super::local_source_path_from_image_ref(trimmed) { + if source_path.exists() { + if source_path.starts_with(images_dir) { + *image_ref = Some(source_path.to_string_lossy().to_string()); + return Ok(()); + } + let extension = + normalize_image_extension(source_path.extension().and_then(|ext| ext.to_str())); + let imported = match import_image_file(&source_path, images_dir, &extension) { + Ok(imported) => imported, + Err(err) => { + log::warn!( + "[Preset] Failed to copy local image from '{}': {err}", + source_path.display() + ); + *image_ref = None; + return Ok(()); + } + }; + *image_ref = Some(imported.path.to_string_lossy().to_string()); + return Ok(()); + } + + // 다른 기기에서 import된 Preset: 해석 불가한 절대 경로는 정상 fallback 처리 + log::warn!( + "[Preset] Clearing image reference to a file missing on this machine: {trimmed}" + ); + *image_ref = None; + return Ok(()); + } + + Ok(()) +} + +pub(super) fn restore_preset_local_sounds( + app: &AppHandle, + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + embedded_local_sounds: Option<&[EmbeddedLocalSound]>, +) -> CmdResult<()> { + let has_any_sounds = key_positions.values().any(|positions| { + positions + .iter() + .any(|position| option_has_non_empty_text(&position.sound_path)) + }) || stat_positions.values().any(|positions| { + positions + .iter() + .any(|stat_position| option_has_non_empty_text(&stat_position.position.sound_path)) + }) || graph_positions.values().any(|positions| { + positions + .iter() + .any(|graph_position| option_has_non_empty_text(&graph_position.position.sound_path)) + }) || knob_positions.values().any(|positions| { + positions + .iter() + .any(|knob_position| option_has_non_empty_text(&knob_position.position.sound_path)) + }); + + if !has_any_sounds { + return Ok(()); + } + + let app_data_dir = app.path().app_data_dir()?; + let sounds_dir = app_data_dir.join("sounds"); + + restore_preset_local_sounds_in_dir( + &sounds_dir, + key_positions, + stat_positions, + graph_positions, + knob_positions, + embedded_local_sounds, + ) +} + +pub(super) fn restore_preset_local_sounds_in_dir( + sounds_dir: &Path, + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + embedded_local_sounds: Option<&[EmbeddedLocalSound]>, +) -> CmdResult<()> { + fs::create_dir_all(sounds_dir)?; + + let embedded_map: HashMap<&str, &EmbeddedLocalSound> = embedded_local_sounds + .unwrap_or(&[]) + .iter() + .map(|sound| (sound.sound_id.as_str(), sound)) + .collect(); + + let mut restored_path_cache: HashMap = HashMap::new(); + + for positions in key_positions.values_mut() { + for position in positions.iter_mut() { + restore_position_sound_reference( + sounds_dir, + &embedded_map, + &mut restored_path_cache, + &mut position.sound_path, + )?; + } + } + + for positions in stat_positions.values_mut() { + for stat_position in positions.iter_mut() { + restore_position_sound_reference( + sounds_dir, + &embedded_map, + &mut restored_path_cache, + &mut stat_position.position.sound_path, + )?; + } + } + + for positions in graph_positions.values_mut() { + for graph_position in positions.iter_mut() { + restore_position_sound_reference( + sounds_dir, + &embedded_map, + &mut restored_path_cache, + &mut graph_position.position.sound_path, + )?; + } + } + + for positions in knob_positions.values_mut() { + for knob_position in positions.iter_mut() { + restore_position_sound_reference( + sounds_dir, + &embedded_map, + &mut restored_path_cache, + &mut knob_position.position.sound_path, + )?; + } + } + + Ok(()) +} + +pub(super) fn restore_position_sound_reference( + sounds_dir: &Path, + embedded_map: &HashMap<&str, &EmbeddedLocalSound>, + restored_path_cache: &mut HashMap, + sound_ref: &mut Option, +) -> CmdResult<()> { + let Some(current_value) = sound_ref.clone() else { + return Ok(()); + }; + let trimmed = current_value.trim(); + if trimmed.is_empty() { + return Ok(()); + } + + if let Some(sound_id) = trimmed.strip_prefix(PRESET_LOCAL_SOUND_PREFIX) { + if let Some(restored_path) = restored_path_cache.get(sound_id) { + *sound_ref = Some(restored_path.clone()); + return Ok(()); + } + let Some(embedded) = embedded_map.get(sound_id) else { + log::warn!( + "[Preset] Missing embedded sound payload for id '{}'; clearing sound reference", + sound_id + ); + *sound_ref = None; + return Ok(()); + }; + + let bytes = match BASE64_STANDARD.decode(embedded.data_base64.as_bytes()) { + Ok(bytes) => bytes, + Err(err) => { + log::warn!( + "[Preset] Failed to decode embedded sound '{}': {err}", + sound_id + ); + *sound_ref = None; + return Ok(()); + } + }; + + let extension = normalize_sound_extension(embedded.extension.as_deref()); + let dest_path = sounds_dir.join(format!("{}.{}", Uuid::new_v4(), extension)); + if let Err(err) = fs::write(&dest_path, bytes) { + log::warn!( + "[Preset] Failed to restore embedded sound '{}': {err}", + sound_id + ); + *sound_ref = None; + return Ok(()); + } + let restored = dest_path.to_string_lossy().to_string(); + restored_path_cache.insert(sound_id.to_string(), restored.clone()); + *sound_ref = Some(restored); + return Ok(()); + } + + // 레거시 호환: 절대 경로가 현재 기기에서 유효하면 그대로 유지. + let path = std::path::PathBuf::from(trimmed); + if path.is_absolute() && path.exists() { + return Ok(()); + } + + // 다른 기기에서 임포트된 프리셋: 경로를 해석할 수 없으면 초기화. + log::warn!("[Preset] Clearing sound reference to a file missing on this machine: {trimmed}"); + *sound_ref = None; + Ok(()) +} diff --git a/src-tauri/src/commands/preset/load/tests.rs b/src-tauri/src/commands/preset/load/tests.rs new file mode 100644 index 000000000..baf117d49 --- /dev/null +++ b/src-tauri/src/commands/preset/load/tests.rs @@ -0,0 +1,1411 @@ +use super::*; +use crate::{ + defaults::{default_keys, default_positions}, + models::{ + CustomCssHistoryEntry, CustomFont, FontWeightRange, GraphPosition, GraphStatType, + GraphType, JsPlugin, KnobPosition, StatPosition, StatType, + }, +}; + +#[test] +fn full_preset_settings_patch_preserves_custom_css_history() { + let history = vec![CustomCssHistoryEntry { + path: "/tmp/preserved.css".to_string(), + loaded_at: 123, + last_used_at: 123, + }]; + let mut store = AppStoreData { + custom_css_history: history.clone(), + ..AppStoreData::default() + }; + let mut preset = PresetFile { + use_custom_css: Some(true), + custom_css: Some(CustomCss { + path: Some("/tmp/preset.css".to_string()), + content: "body {}".to_string(), + }), + ..PresetFile::default() + }; + let resolved = resolve_full_preset_settings(&mut preset, &store); + let patch = SettingsPatchInput { + use_custom_css: Some(resolved.use_custom_css), + custom_css: Some(CustomCssPatch { + path: Some(resolved.custom_css.path), + content: Some(resolved.custom_css.content), + }), + ..SettingsPatchInput::default() + }; + + apply_patch_to_store(&mut store, &patch); + + assert_eq!(store.custom_css_history, history); +} + +#[test] +fn synthesized_custom_tabs_are_deterministic_without_preset_metadata() { + let keys = KeyMappings::from([ + ("custom-z".to_string(), Vec::new()), + ("custom-a".to_string(), Vec::new()), + ("4key".to_string(), Vec::new()), + ]); + + let tabs = synthesize_custom_tabs(&keys); + + assert_eq!( + tabs.iter().map(|tab| tab.id.as_str()).collect::>(), + ["custom-a", "custom-z"] + ); + assert_eq!( + tabs.iter().map(|tab| tab.name.as_str()).collect::>(), + ["Custom 1", "Custom 2"] + ); +} + +#[test] +fn committed_preset_css_paths_exclude_unrelated_store_paths() { + let mut committed = AppStoreData { + custom_css: CustomCss { + path: Some("/tmp/unrelated-global.css".to_string()), + content: String::new(), + }, + ..AppStoreData::default() + }; + committed.tab_css_overrides.insert( + "4key".to_string(), + TabCss { + path: Some("/tmp/imported-tab.css".to_string()), + content: String::new(), + enabled: true, + }, + ); + committed.tab_css_overrides.insert( + "7key".to_string(), + TabCss { + path: Some("/tmp/unrelated.css".to_string()), + content: String::new(), + enabled: true, + }, + ); + let imported = ImportedCssPaths { + global: None, + tabs: vec![ + "/tmp/imported-tab.css".to_string(), + "/tmp/not-committed.css".to_string(), + ], + }; + + assert_eq!( + committed_preset_css_paths(&committed, &imported), + vec!["/tmp/imported-tab.css".to_string()] + ); + + let imported_with_global = ImportedCssPaths { + global: Some("/tmp/unrelated-global.css".to_string()), + tabs: imported.tabs, + }; + assert_eq!( + committed_preset_css_paths(&committed, &imported_with_global), + vec![ + "/tmp/imported-tab.css".to_string(), + "/tmp/unrelated-global.css".to_string(), + ] + ); +} + +#[test] +fn preset_source_bytes_remain_unchanged_on_success_and_parse_failure() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-read-only-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + + let valid_source = br#"{ + "keys": { "custom": ["Q"] }, + "keyPositions": { + "custom": [{ "dx": 12.5, "dy": -4.0, "width": 61.0, "count": 3 }] + }, + "customJS": { + "path": null, + "content": "globalThis.oldPreset = true", + "plugins": [{ + "id": "plugin-source", + "name": "Source plugin", + "path": null, + "content": "void 0", + "enabled": true + }] + }, + "embeddedLocalImages": [{ + "imageId": "image-source", + "extension": "png", + "dataBase64": "AA==" + }] +}"#; + std::fs::write(&source_path, valid_source).unwrap(); + let parsed = read_preset_file(&source_path).unwrap(); + assert_eq!( + parsed.keys.as_ref().unwrap()["custom"], + [KeySlot::from("Q")] + ); + assert_eq!(parsed.key_positions.as_ref().unwrap()["custom"][0].dx, 12.5); + assert_eq!( + parsed.custom_js.as_ref().unwrap().plugins[0].id, + "plugin-source" + ); + assert_eq!( + parsed.embedded_local_images.as_ref().unwrap()[0].image_id, + "image-source" + ); + assert_eq!(std::fs::read(&source_path).unwrap(), valid_source); + + let invalid_source = b"{ invalid preset"; + std::fs::write(&source_path, invalid_source).unwrap(); + assert!(read_preset_file(&source_path).is_err()); + assert_eq!(std::fs::read(&source_path).unwrap(), invalid_source); + + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn preset_missing_note_gradient_multipliers_default_to_one_hundred() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-note-gradient-default-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + let source = serde_json::json!({ + "keyPositions": { + "custom": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteGradient": { + "angle": 45, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }, + "noteGlowGradient": { + "angle": 135, + "stops": [ + { "color": "#778899", "pos": 0 }, + { "color": "#AABBCC", "pos": 1 } + ] + } + }] + } + }); + std::fs::write(&source_path, serde_json::to_vec_pretty(&source).unwrap()).unwrap(); + + let parsed = read_preset_file(&source_path).unwrap(); + let positions = parsed.key_positions.unwrap(); + let position = &positions["custom"][0]; + assert_eq!(position.note_opacity, 100); + assert_eq!(position.note_glow_opacity, 100); + + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn preset_removed_outline_fields_scrub_and_block_legacy_default_collision() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-removed-outline-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + let source = serde_json::json!({ + "keyPositions": { + "custom": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "fontStrokeColor": "#112233", + "activeFontStrokeColor": "#445566", + "counter": { + "enabled": true, + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { "idle": "#FFFFFF", "active": "#000000" }, + "stroke": { "idle": "#000000", "active": "#FFFFFF" }, + "strokeIdleGradient": { + "angle": 90, + "stops": [ + { "color": "#000000", "pos": 0 }, + { "color": "#FFFFFF", "pos": 1 } + ] + }, + "gap": 6, + "fontSize": 16, + "fontWeight": 400, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + } + }] + } + }); + std::fs::write(&source_path, serde_json::to_vec_pretty(&source).unwrap()).unwrap(); + + let parsed = read_preset_file(&source_path).unwrap(); + let position = &parsed.key_positions.unwrap()["custom"][0]; + let mut counter = position.counter.clone(); + assert_eq!(counter.fill.idle, "rgba(255,255,255,1)"); + assert_eq!(counter.align, crate::models::KeyCounterAlign::Top); + + let serialized = serde_json::to_value(&counter).unwrap(); + assert!(serialized.get("stroke").is_none()); + assert!(serialized.get("strokeIdleGradient").is_none()); + assert!(!counter.migrate_legacy_defaults()); + assert_eq!(counter.align, crate::models::KeyCounterAlign::Top); + + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn damaged_gradient_preset_reports_element_and_field_with_existing_error_code() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-gradient-error-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + let damaged_gradient = serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + }); + + for (collection, field, counter_field) in [ + ("keyPositions", "backgroundGradient", false), + ("statPositions", "activeBackgroundGradient", false), + ("graphPositions", "borderGradient", false), + ("knobPositions", "activeBorderGradient", false), + ("keyPositions", "fontGradient", false), + ("statPositions", "activeFontGradient", false), + ("keyPositions", "noteBorderGradient", false), + ("keyPositions", "fillIdleGradient", true), + ("statPositions", "fillActiveGradient", true), + ] { + let mut gradient_fields = serde_json::Map::new(); + gradient_fields.insert(field.to_string(), damaged_gradient.clone()); + let damaged_entry = if counter_field { + let mut entry = serde_json::Map::new(); + entry.insert( + "counter".to_string(), + serde_json::Value::Object(gradient_fields), + ); + serde_json::Value::Object(entry) + } else { + serde_json::Value::Object(gradient_fields) + }; + let mut modes = serde_json::Map::new(); + modes.insert( + "custom mode".to_string(), + serde_json::json!([{}, damaged_entry]), + ); + let mut preset = serde_json::Map::new(); + preset.insert(collection.to_string(), serde_json::Value::Object(modes)); + std::fs::write( + &source_path, + serde_json::to_vec(&serde_json::Value::Object(preset)).unwrap(), + ) + .unwrap(); + + let error = read_preset_file(&source_path) + .err() + .expect("damaged gradient preset must be rejected") + .to_string(); + let field_path = if counter_field { + format!("counter.{field}") + } else { + field.to_string() + }; + let expected_prefix = + format!("invalid-preset: {collection}[\"custom mode\"][1].{field_path}: "); + assert!( + error.starts_with(&expected_prefix), + "unexpected gradient error: {error}" + ); + assert!(error.contains("gradient must contain at least two stops")); + } + + std::fs::write(&source_path, b"{ invalid preset").unwrap(); + assert_eq!( + read_preset_file(&source_path) + .err() + .expect("invalid JSON preset must be rejected") + .to_string(), + "invalid-preset" + ); + + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn preset_rejects_out_of_range_image_transform_with_path() { + let preset = serde_json::json!({ + "keyPositions": { + "custom mode": [{ + "idleImageTransform": { + "offsetX": 0, "offsetY": 0, "rotation": 0, "scale": 0.05 + } + }] + } + }); + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some( + "keyPositions[\"custom mode\"][0].idleImageTransform.scale: must be a finite number between 0.1 and 10" + ) + ); + + let valid = serde_json::json!({ + "keyPositions": { + "custom mode": [{ + "idleImageTransform": null, + "activeImageTransform": { + "offsetX": -500, "offsetY": 500, "rotation": 180, "scale": 10 + } + }] + } + }); + assert_eq!(invalid_position_style_detail(&valid), None); +} + +#[test] +fn position_style_validation_first_error_order_is_stable() { + let damaged_gradient = serde_json::json!({ + "angle": 45, + "stops": [ + { "color": " ", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }); + let mut preset = serde_json::json!({ + "keyPositions": { + "later mode": [{ + "backgroundGradient": damaged_gradient.clone(), + "fontGradient": damaged_gradient.clone(), + "shadow": { + "enabled": "yes", + "color": "", + "offsetX": 101, + "offsetY": 101, + "blur": 101 + }, + "idleImageTransform": { + "offsetX": 501, + "offsetY": 501, + "rotation": 181, + "scale": 0 + } + }] + }, + "statPositions": { + "earlier mode": [{ "backgroundGradient": damaged_gradient }] + } + }); + + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some( + "keyPositions[\"later mode\"][0].backgroundGradient: stops[0].color must not be blank" + ) + ); + + let entry = preset["keyPositions"]["later mode"][0] + .as_object_mut() + .unwrap(); + entry.remove("backgroundGradient"); + entry.remove("fontGradient"); + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some("keyPositions[\"later mode\"][0].shadow.enabled: must be a boolean") + ); + + preset["keyPositions"]["later mode"][0] + .as_object_mut() + .unwrap() + .remove("shadow"); + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some( + "keyPositions[\"later mode\"][0].idleImageTransform.offsetX: must be a finite number between -500 and 500" + ) + ); +} + +#[test] +fn general_gradient_preset_rejects_blank_stop_color_with_index() { + // 공백 stop은 로드 복구가 수렴하지 않는 손상 값 - 문에서 거부 + for field in ["fontGradient", "backgroundGradient", "borderGradient"] { + let preset = serde_json::json!({ + "keyPositions": { + "custom mode": [{ + (field): { + "angle": 45, + "stops": [ + { "color": " ", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + } + }] + } + }); + + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some( + format!( + "keyPositions[\"custom mode\"][0].{field}: stops[0].color must not be blank" + ) + .as_str() + ) + ); + } + + let valid = serde_json::json!({ + "keyPositions": { + "custom mode": [{ + "fontGradient": { + "angle": 45, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + } + }] + } + }); + assert_eq!(invalid_position_style_detail(&valid), None); +} + +#[test] +fn note_border_gradient_preset_rejects_invalid_stop_color_with_index() { + for collection in POSITION_COLLECTION_FIELDS { + let preset = serde_json::json!({ + (collection): { + "custom mode": [{ + "noteBorderGradient": { + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "transparent", "pos": 1 } + ] + } + }] + } + }); + + let error = invalid_position_style_detail(&preset) + .expect("unsupported note border stop color must be rejected"); + assert_eq!( + error, + format!( + "{collection}[\"custom mode\"][0].noteBorderGradient: stops[1].color contains an unsupported note border color" + ) + ); + } + + for collection in POSITION_COLLECTION_FIELDS { + for field in ["noteGradient", "noteGlowGradient"] { + let preset = serde_json::json!({ + (collection): { + "custom mode": [{ + (field): { + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "transparent", "pos": 1 } + ] + } + }] + } + }); + + assert_eq!( + invalid_position_style_detail(&preset).as_deref(), + Some( + format!( + "{collection}[\"custom mode\"][0].{field}: stops[1].color contains an unsupported note gradient color" + ) + .as_str() + ) + ); + } + } + + let valid = serde_json::json!({ + "keyPositions": { + "custom mode": [{ + "noteGradient": { + "angle": 45, + "stops": [ + { "color": "#1238", "pos": 0 }, + { "color": "rgb(4, 5, 6)", "pos": 1 } + ] + }, + "noteGlowGradient": { + "angle": 135, + "stops": [ + { "color": "rgba(7, 8, 9, .25)", "pos": 0 }, + { "color": "#ABC", "pos": 1 } + ] + }, + "noteBorderGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, + { "color": "#ABC8", "pos": 1 } + ] + } + }] + } + }); + assert_eq!(invalid_position_style_detail(&valid), None); + + let invalid_discarded_stop = serde_json::json!({ + "keyPositions": { + "custom mode": [{ + "noteBorderGradient": { + "angle": 90, + "stops": [ + { "color": "#000000", "pos": 0.0 }, + { "color": "#111111", "pos": 0.1 }, + { "color": "#222222", "pos": 0.2 }, + { "color": "#333333", "pos": 0.3 }, + { "color": "#444444", "pos": 0.4 }, + { "color": "#555555", "pos": 0.5 }, + { "color": "#666666", "pos": 0.6 }, + { "color": "#777777", "pos": 0.7 }, + { "color": "invalid-discarded-stop", "pos": 1.0 } + ] + } + }] + } + }); + assert_eq!( + invalid_position_style_detail(&invalid_discarded_stop).as_deref(), + Some( + "keyPositions[\"custom mode\"][0].noteBorderGradient: stops[8].color contains an unsupported note border color" + ) + ); +} + +#[test] +fn invalid_shadow_preset_reports_exact_element_paths() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-shadow-error-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + for (collection, entry, expected_path, expected_reason) in [ + ( + "keyPositions", + serde_json::json!({ + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": 0, + "offsetY": 0, + "blur": 100.1 + } + }), + "shadow.blur", + "must be a finite number between 0 and 100", + ), + ( + "statPositions", + serde_json::json!({ + "activeShadow": { + "enabled": true, + "color": "#123456", + "offsetX": -100.1, + "offsetY": 0, + "blur": 12 + } + }), + "activeShadow.offsetX", + "must be a finite number between -100 and 100", + ), + ( + "graphPositions", + serde_json::json!({ + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": 0, + "offsetY": 100.1, + "blur": 12 + } + }), + "shadow.offsetY", + "must be a finite number between -100 and 100", + ), + ( + "knobPositions", + serde_json::json!({ "activeShadow": [] }), + "activeShadow", + "must be an object", + ), + ( + "keyPositions", + serde_json::json!({ + "shadow": { + "enabled": true, + "color": "", + "offsetX": 0, + "offsetY": 0, + "blur": 12 + } + }), + "shadow.color", + "must be a non-empty string", + ), + ] { + let preset = serde_json::json!({ + (collection): { + "custom mode": [{}, entry] + } + }); + std::fs::write(&source_path, serde_json::to_vec(&preset).unwrap()).unwrap(); + + let error = read_preset_file(&source_path) + .err() + .expect("invalid shadow preset must be rejected") + .to_string(); + assert_eq!( + error, + format!( + "invalid-preset: {collection}[\"custom mode\"][1].{expected_path}: {expected_reason}" + ) + ); + } + + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn null_shadow_fields_are_treated_as_absent() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-shadow-null-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + + // 외부 생성·수동 편집 프리셋의 명시적 null은 Option 역직렬화처럼 값 없음 + let preset = serde_json::json!({ + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": null, + "activeShadow": null + }] + } + }); + std::fs::write(&source_path, serde_json::to_vec(&preset).unwrap()).unwrap(); + + let parsed = read_preset_file(&source_path).expect("null fields must parse as absent"); + let position = &parsed.key_positions.as_ref().unwrap()["4key"][0]; + assert!(position.shadow.is_none()); + assert!(position.active_shadow.is_none()); + + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn legacy_and_bounded_shadow_presets_still_parse() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-shadow-compatibility-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let source_path = temp_dir.join("source.json"); + + let legacy = serde_json::json!({ + "keyPositions": { + "4key": [{ "dx": 0, "dy": 0, "width": 60, "count": 0 }] + } + }); + std::fs::write(&source_path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + let parsed_legacy = read_preset_file(&source_path).unwrap(); + let legacy_position = &parsed_legacy.key_positions.unwrap()["4key"][0]; + assert!(legacy_position.shadow.is_none()); + assert!(legacy_position.active_shadow.is_none()); + + let position = serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": { + "enabled": true, + "color": "#123456", + "offsetX": -100, + "offsetY": 100, + "blur": 100 + }, + "activeShadow": { + "enabled": false, + "color": "rgba(0, 0, 0, 0)", + "offsetX": 100, + "offsetY": -100, + "blur": 0 + } + }); + let mut stat = position.clone(); + stat.as_object_mut() + .unwrap() + .insert("statType".to_string(), serde_json::json!("kps")); + let mut graph = position.clone(); + graph + .as_object_mut() + .unwrap() + .extend(serde_json::Map::from_iter([ + ("statType".to_string(), serde_json::json!("kps")), + ("graphType".to_string(), serde_json::json!("line")), + ("graphSpeed".to_string(), serde_json::json!(100)), + ("graphColor".to_string(), serde_json::json!("#123456")), + ])); + let bounded = serde_json::json!({ + "keyPositions": { "4key": [position.clone()] }, + "statPositions": { "4key": [stat] }, + "graphPositions": { "4key": [graph] }, + "knobPositions": { "4key": [position] } + }); + std::fs::write(&source_path, serde_json::to_vec(&bounded).unwrap()).unwrap(); + let parsed = read_preset_file(&source_path).unwrap(); + assert_eq!( + parsed.key_positions.as_ref().unwrap()["4key"][0] + .shadow + .as_ref() + .unwrap() + .blur, + 100.0 + ); + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn missing_legacy_global_fields_preserve_current_plugin_and_style_settings() { + let current = AppStoreData { + background_color: "#123456".to_string(), + note_settings: NoteSettings { + speed: 777, + ..NoteSettings::default() + }, + note_effect: true, + laboratory_enabled: true, + use_custom_css: true, + custom_css: CustomCss { + path: Some("/current/style.css".to_string()), + content: ".current {}".to_string(), + }, + use_custom_js: true, + custom_js: CustomJs { + path: None, + content: "globalThis.current = true".to_string(), + plugins: vec![JsPlugin { + id: "current-plugin".to_string(), + name: "Current plugin".to_string(), + path: None, + content: "void 0".to_string(), + enabled: true, + }], + }, + ..AppStoreData::default() + }; + let mut legacy = PresetFile::default(); + + let resolved = resolve_full_preset_settings(&mut legacy, ¤t); + + assert_eq!(resolved.background_color, current.background_color); + assert_eq!(resolved.note_settings, current.note_settings); + assert_eq!(resolved.note_effect, current.note_effect); + assert_eq!(resolved.laboratory_enabled, current.laboratory_enabled); + assert_eq!(resolved.use_custom_css, current.use_custom_css); + assert_eq!(resolved.custom_css, current.custom_css); + assert_eq!(resolved.use_custom_js, current.use_custom_js); + assert_eq!(resolved.custom_js, current.custom_js); +} + +#[test] +fn tauri_130_literal_without_js_fields_preserves_installed_plugins() { + let current = AppStoreData { + use_custom_js: true, + custom_js: CustomJs { + path: None, + content: "globalThis.current = true".to_string(), + plugins: vec![JsPlugin { + id: "installed-plugin".to_string(), + name: "Installed plugin".to_string(), + path: None, + content: "globalThis.installed = true".to_string(), + enabled: true, + }], + }, + ..AppStoreData::default() + }; + let mut preset: PresetFile = serde_json::from_value(serde_json::json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { "4key": [{ "dx": 1, "dy": 2, "width": 60, "count": 0 }] }, + "backgroundColor": "transparent" + })) + .unwrap(); + + let resolved = resolve_full_preset_settings(&mut preset, ¤t); + + assert!(resolved.use_custom_js); + assert_eq!(resolved.custom_js, current.custom_js); +} + +#[test] +fn legacy_and_multi_key_preset_slots_share_the_normalization_path() { + let preset: PresetFile = serde_json::from_value(serde_json::json!({ + "keys": { + "4key": [ + "Q", + { "keys": ["A", "B"], "match": "any" }, + { "keys": ["Z"], "match": "all" } + ] + } + })) + .unwrap(); + let keys = preset.keys.unwrap(); + + assert_eq!(keys["4key"][0], KeySlot::Single("Q".to_string())); + assert_eq!( + keys["4key"][1], + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: crate::models::SlotMatch::Any, + } + ); + assert_eq!(keys["4key"][2], KeySlot::Single("Z".to_string())); + assert_eq!( + serde_json::to_value(keys).unwrap()["4key"], + serde_json::json!([ + "Q", + { "keys": ["A", "B"], "match": "any" }, + "Z" + ]) + ); +} + +#[test] +fn tauri_161_literal_imports_its_plugin_list_exactly() { + let mut current = AppStoreData::default(); + current.custom_js.plugins.push(JsPlugin { + id: "current-plugin".to_string(), + name: "Current plugin".to_string(), + path: None, + content: "void 0".to_string(), + enabled: true, + }); + let mut preset: PresetFile = serde_json::from_value(serde_json::json!({ + "useCustomJS": true, + "customJS": { + "path": null, + "content": "globalThis.legacy = true", + "plugins": [{ + "id": "plugin-161", + "name": "Legacy plugin", + "path": null, + "content": "globalThis.plugin161 = true", + "enabled": true + }] + } + })) + .unwrap(); + + let resolved = resolve_full_preset_settings(&mut preset, ¤t); + + assert!(resolved.use_custom_js); + assert_eq!(resolved.custom_js.plugins.len(), 1); + assert_eq!(resolved.custom_js.plugins[0].id, "plugin-161"); + assert!(!resolved + .custom_js + .plugins + .iter() + .any(|plugin| plugin.id == "current-plugin")); +} + +#[test] +fn explicit_empty_plugin_settings_still_clear_current_plugins() { + let current = AppStoreData { + use_custom_js: true, + custom_js: CustomJs { + plugins: vec![JsPlugin { + id: "current-plugin".to_string(), + name: "Current plugin".to_string(), + path: None, + content: "void 0".to_string(), + enabled: true, + }], + ..CustomJs::default() + }, + ..AppStoreData::default() + }; + let mut preset = PresetFile { + use_custom_js: Some(false), + custom_js: Some(CustomJs::default()), + ..PresetFile::default() + }; + + let resolved = resolve_full_preset_settings(&mut preset, ¤t); + + assert!(!resolved.use_custom_js); + assert_eq!(resolved.custom_js, CustomJs::default()); +} + +#[test] +fn legacy_tab_preset_without_note_override_preserves_current_override() { + let mut store = AppStoreData::default(); + let current = TabNoteSettings { + speed: Some(654), + ..TabNoteSettings::default() + }; + store + .tab_note_overrides + .insert("4key".to_string(), current.clone()); + + apply_tab_note_override(&mut store, "4key", false, None); + + assert_eq!(store.tab_note_overrides["4key"], current); +} + +#[test] +fn explicit_empty_tab_note_override_removes_current_override() { + let mut store = AppStoreData::default(); + store.tab_note_overrides.insert( + "4key".to_string(), + TabNoteSettings { + speed: Some(654), + ..TabNoteSettings::default() + }, + ); + + apply_tab_note_override(&mut store, "4key", true, None); + + assert!(!store.tab_note_overrides.contains_key("4key")); +} + +#[test] +fn historical_full_preset_without_groups_starts_each_imported_mode_ungrouped() { + let keys = KeyMappings::from([ + ("4key".to_string(), vec![KeySlot::from("A")]), + ("custom-old".to_string(), vec![KeySlot::from("B")]), + ]); + + let groups = resolve_full_preset_layer_groups(None, &keys); + + assert_eq!(groups.len(), 2); + assert!(groups["4key"].is_empty()); + assert!(groups["custom-old"].is_empty()); +} + +#[test] +fn tab_preset_duplicate_font_does_not_create_embedded_file() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-tab-preset-font-load-test-{}", + uuid::Uuid::new_v4() + )); + let fonts_dir = temp_dir.join("fonts"); + std::fs::create_dir_all(&fonts_dir).unwrap(); + let existing_path = fonts_dir.join("existing.ttf"); + std::fs::write(&existing_path, b"existing-font").unwrap(); + + let existing_fonts = FontSettings { + custom_fonts: vec![CustomFont { + id: "existing-id".to_string(), + font_type: FontType::Local, + name: "SharedFont".to_string(), + display_name: "Existing Font".to_string(), + enabled: true, + local_path: Some(existing_path.to_string_lossy().to_string()), + css_content: None, + weight_ranges: Vec::new(), + }], + }; + let imported_font_id = "imported-id".to_string(); + let imported_fonts = FontSettings { + custom_fonts: vec![CustomFont { + id: imported_font_id.clone(), + font_type: FontType::Local, + name: "SharedFont".to_string(), + display_name: "Imported Font".to_string(), + enabled: true, + local_path: None, + css_content: None, + weight_ranges: Vec::new(), + }], + }; + let embedded_fonts = vec![EmbeddedLocalFont { + font_id: imported_font_id, + extension: Some("ttf".to_string()), + data_base64: BASE64_STANDARD.encode(b"imported-font"), + }]; + let file_count_before = std::fs::read_dir(&fonts_dir).unwrap().count(); + + let merged = merge_tab_preset_fonts(&existing_fonts, imported_fonts, |filtered_fonts| { + restore_preset_local_fonts_in_dir(&fonts_dir, filtered_fonts, Some(&embedded_fonts)) + }) + .unwrap(); + + assert!(merged.is_none()); + assert_eq!( + std::fs::read_dir(&fonts_dir).unwrap().count(), + file_count_before + ); + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn tab_preset_keeps_all_local_faces_for_a_new_family() { + let face = |id: &str, weight: u16| CustomFont { + id: id.to_string(), + font_type: FontType::Local, + name: "Family".to_string(), + display_name: "Family".to_string(), + enabled: true, + local_path: Some(format!("/{id}.ttf")), + css_content: None, + weight_ranges: vec![FontWeightRange { + min: weight, + max: weight, + }], + }; + let imported = FontSettings { + custom_fonts: vec![ + face("regular", 400), + face("bold", 700), + // 프리셋 내부 중복(id 동일)은 한 번만 수용 + face("bold", 700), + ], + }; + + // 실제 탭 로드 경로(prepare → restore → merge) 전체를 통과시킨다 + let merged = merge_tab_preset_fonts(&FontSettings::default(), imported, |_| Ok(())) + .unwrap() + .unwrap(); + + assert_eq!(merged.custom_fonts.len(), 2); + assert_eq!(merged.custom_fonts[0].weight_ranges[0].min, 400); + assert_eq!(merged.custom_fonts[1].weight_ranges[0].min, 700); + + // 기존에 같은 이름이 있으면 그 family 전체를 기존 정의로 유지 + let existing = FontSettings { + custom_fonts: vec![face("existing", 400)], + }; + let imported = FontSettings { + custom_fonts: vec![face("regular", 400), face("bold", 700)], + }; + assert!(merge_tab_preset_fonts(&existing, imported, |_| Ok(())) + .unwrap() + .is_none()); +} + +#[test] +fn tab_preset_key_pair_merge_preserves_other_modes_and_latest_target_positions() { + let mut store = AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..AppStoreData::default() + }; + store.key_positions.get_mut("4key").unwrap()[0].dx = 777.0; + let untouched_keys = store.keys["5key"].clone(); + let untouched_positions = store.key_positions["5key"].clone(); + + merge_tab_preset_key_pair(&mut store, "4key", vec![KeySlot::from("Imported")], None); + + assert_eq!(store.keys["5key"], untouched_keys); + assert_eq!(store.key_positions["5key"], untouched_positions); + assert_eq!(store.key_positions["4key"][0].dx, 777.0); + assert_eq!(store.keys["4key"][0], KeySlot::from("Imported")); + assert_eq!(store.keys["4key"].len(), store.key_positions["4key"].len()); +} + +fn old_preset_store() -> AppStoreData { + AppStoreData { + key_positions: KeyPositions::from([ + ("target".to_string(), vec![KeyPosition::default()]), + ("untouched".to_string(), vec![KeyPosition::default()]), + ]), + stat_positions: StatPositions::from([( + "target".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: KeyPosition::default(), + }], + )]), + graph_positions: GraphPositions::from([( + "target".to_string(), + vec![GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: KeyPosition::default(), + }], + )]), + knob_positions: KnobPositions::from([( + "target".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: KeyPosition::default(), + }], + )]), + ..AppStoreData::default() + } +} + +fn target_preset_ids(store: &AppStoreData) -> Vec { + vec![ + store.key_positions["target"][0].id.clone(), + store.stat_positions["target"][0].position.id.clone(), + store.graph_positions["target"][0].position.id.clone(), + store.knob_positions["target"][0].position.id.clone(), + ] +} + +#[test] +fn old_full_preset_rekeys_every_application() { + let mut first = old_preset_store(); + rekey_full_preset_elements(&mut first); + let first_ids = target_preset_ids(&first); + let mut second = old_preset_store(); + rekey_full_preset_elements(&mut second); + let second_ids = target_preset_ids(&second); + + assert!(first_ids + .iter() + .all(|id| crate::state::native_element_id::is_valid_element_id(id))); + assert!(first_ids.iter().all(|id| !second_ids.contains(id))); +} + +#[test] +fn old_tab_preset_rekeys_only_written_collections_on_every_application() { + let mut store = old_preset_store(); + crate::state::native_element_id::backfill_store_element_ids(&mut store); + let untouched_id = store.key_positions["untouched"][0].id.clone(); + let original_ids = target_preset_ids(&store); + + rekey_tab_preset_elements(&mut store, "target", true, true, true, false); + let first_ids = target_preset_ids(&store); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false); + let second_ids = target_preset_ids(&store); + + assert!(original_ids[..3] + .iter() + .zip(&first_ids[..3]) + .all(|(before, after)| before != after)); + assert!(first_ids[..3] + .iter() + .zip(&second_ids[..3]) + .all(|(before, after)| before != after)); + assert_eq!(first_ids[3], original_ids[3]); + assert_eq!(second_ids[3], original_ids[3]); + assert_eq!(store.key_positions["untouched"][0].id, untouched_id); + crate::state::native_element_id::validate_document_element_ids( + &crate::models::EditorDocumentV1::from_store(&store), + ) + .unwrap(); +} + +#[test] +fn tab_preset_without_key_positions_keeps_existing_key_ids() { + let mut store = old_preset_store(); + crate::state::native_element_id::backfill_store_element_ids(&mut store); + let original_ids = target_preset_ids(&store); + // keys만 담긴 탭 프리셋: merge가 기존 위치를 값 그대로 되삽입하고 + // 슬롯 정렬이 빈 위치 하나를 덧붙인 상태 + store + .key_positions + .get_mut("target") + .unwrap() + .push(KeyPosition::default()); + + rekey_tab_preset_elements(&mut store, "target", false, false, false, false); + + // 값이 그대로인 기존 키는 신원을 지키고, 덧붙은 슬롯만 새 id를 받는다 + assert_eq!(store.key_positions["target"][0].id, original_ids[0]); + let appended = &store.key_positions["target"][1].id; + assert!(crate::state::native_element_id::is_valid_element_id( + appended + )); + assert_ne!(appended, &original_ids[0]); + assert_eq!(target_preset_ids(&store)[1..], original_ids[1..]); + crate::state::native_element_id::validate_document_element_ids( + &crate::models::EditorDocumentV1::from_store(&store), + ) + .unwrap(); +} + +#[test] +fn preset_import_alignment_repairs_each_mode_without_dropping_values() { + let mut keys = KeyMappings::from([ + ("keys-only".to_string(), vec![KeySlot::from("A")]), + ("positions-long".to_string(), vec![KeySlot::from("B")]), + ]); + let mut positions = KeyPositions::from([ + ( + "positions-only".to_string(), + vec![KeyPosition { + dx: 123.0, + ..KeyPosition::default() + }], + ), + ( + "positions-long".to_string(), + vec![KeyPosition::default(), KeyPosition::default()], + ), + ]); + + align_imported_key_collections(&mut keys, &mut positions); + + assert_eq!(keys["keys-only"], vec![KeySlot::from("A")]); + assert_eq!(positions["keys-only"], vec![KeyPosition::default()]); + assert_eq!(keys["positions-only"], vec![KeySlot::default()]); + assert_eq!(positions["positions-only"][0].dx, 123.0); + assert_eq!( + keys["positions-long"], + vec![KeySlot::from("B"), KeySlot::default()] + ); + assert_eq!( + keys["positions-long"].len(), + positions["positions-long"].len() + ); +} + +#[test] +fn tab_preset_font_restore_failure_keeps_existing_settings_unchanged() { + let existing = FontSettings { + custom_fonts: vec![CustomFont { + id: "existing-id".to_string(), + font_type: FontType::Local, + name: "ExistingFont".to_string(), + display_name: "Existing Font".to_string(), + enabled: true, + local_path: Some("/existing/font.ttf".to_string()), + css_content: None, + weight_ranges: Vec::new(), + }], + }; + let before = existing.clone(); + let imported = FontSettings { + custom_fonts: vec![CustomFont { + id: "imported-id".to_string(), + font_type: FontType::Local, + name: "ImportedFont".to_string(), + display_name: "Imported Font".to_string(), + enabled: true, + local_path: None, + css_content: None, + weight_ranges: Vec::new(), + }], + }; + + let result = prepare_tab_preset_fonts(&existing, imported, |fonts| { + fonts.custom_fonts[0].local_path = Some("/staged/font.ttf".to_string()); + Err(CommandError::msg("restore-failed")) + }); + + assert!(result.is_err()); + assert_eq!(existing, before); +} + +#[test] +fn legacy_percent_encoded_file_url_is_copied_on_import() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-image-url-load-test-{}", + uuid::Uuid::new_v4() + )); + let source_dir = temp_dir.join("source folder"); + let images_dir = temp_dir.join("restored-images"); + std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::create_dir_all(&images_dir).unwrap(); + let source_path = source_dir.join("image with space.png"); + std::fs::write(&source_path, b"legacy-image").unwrap(); + let mut image_ref = Some(url::Url::from_file_path(&source_path).unwrap().to_string()); + + restore_position_image_reference( + &images_dir, + &HashMap::new(), + &mut HashMap::new(), + &mut image_ref, + ) + .unwrap(); + + let restored_path = Path::new(image_ref.as_deref().unwrap()); + assert!(restored_path.starts_with(&images_dir)); + assert_eq!(std::fs::read(restored_path).unwrap(), b"legacy-image"); + assert_eq!(std::fs::read(&source_path).unwrap(), b"legacy-image"); + let _ = std::fs::remove_dir_all(temp_dir); +} + +#[test] +fn sound_restore_restores_knob_sound() { + let temp_dir = std::env::temp_dir().join(format!( + "dmnote-preset-knob-load-test-{}", + uuid::Uuid::new_v4() + )); + let sounds_dir = temp_dir.join("sounds"); + let sound_id = "knob-sound"; + let embedded = vec![EmbeddedLocalSound { + sound_id: sound_id.to_string(), + extension: Some("wav".to_string()), + data_base64: BASE64_STANDARD.encode(b"restored-knob-sound"), + }]; + + let mut position = default_positions()["4key"][0].clone(); + position.sound_path = Some(format!("{PRESET_LOCAL_SOUND_PREFIX}{sound_id}")); + let mut knob_positions = KnobPositions::new(); + knob_positions.insert( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }], + ); + + restore_preset_local_sounds_in_dir( + &sounds_dir, + &mut KeyPositions::new(), + &mut StatPositions::new(), + &mut GraphPositions::new(), + &mut knob_positions, + Some(&embedded), + ) + .unwrap(); + + let restored_path = Path::new( + knob_positions["4key"][0] + .position + .sound_path + .as_deref() + .unwrap(), + ); + assert!(restored_path.starts_with(&sounds_dir)); + assert_eq!( + std::fs::read(restored_path).unwrap(), + b"restored-knob-sound" + ); + let _ = std::fs::remove_dir_all(temp_dir); +} diff --git a/src-tauri/src/commands/preset/load/validation.rs b/src-tauri/src/commands/preset/load/validation.rs new file mode 100644 index 000000000..783fbbd4b --- /dev/null +++ b/src-tauri/src/commands/preset/load/validation.rs @@ -0,0 +1,248 @@ +use super::*; + +pub(super) fn read_preset_file(path: &Path) -> CmdResult { + let content = fs::read_to_string(path)?; + let mut value: serde_json::Value = + serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset"))?; + scrub_removed_text_outline_fields(&mut value); + if let Some(detail) = invalid_position_style_detail(&value) { + return Err(CommandError::msg(format!("invalid-preset: {detail}"))); + } + default_preset_note_gradient_multipliers(&mut value); + serde_json::from_value(value).map_err(|_| CommandError::msg("invalid-preset")) +} + +fn default_preset_note_gradient_multipliers(value: &mut serde_json::Value) { + for collection in POSITION_COLLECTION_FIELDS { + let Some(modes) = value + .get_mut(collection) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + for position in modes + .values_mut() + .filter_map(serde_json::Value::as_array_mut) + .flatten() + { + default_missing_note_gradient_multipliers(position); + } + } +} + +pub(super) fn invalid_position_style_detail(preset: &serde_json::Value) -> Option { + const ELEMENT_FIELDS: [&str; 9] = [ + "backgroundGradient", + "activeBackgroundGradient", + "borderGradient", + "activeBorderGradient", + "fontGradient", + "activeFontGradient", + "noteBorderGradient", + "noteGradient", + "noteGlowGradient", + ]; + const COUNTER_FIELDS: [&str; 2] = ["fillIdleGradient", "fillActiveGradient"]; + const SHADOW_FIELDS: [&str; 2] = ["shadow", "activeShadow"]; + const IMAGE_TRANSFORM_FIELDS: [&str; 2] = ["idleImageTransform", "activeImageTransform"]; + + for collection_name in POSITION_COLLECTION_FIELDS { + let Some(modes) = preset + .get(collection_name) + .and_then(serde_json::Value::as_object) + else { + continue; + }; + for (mode, entries) in modes { + let Some(entries) = entries.as_array() else { + continue; + }; + for (index, entry) in entries.iter().enumerate() { + let Some(entry) = entry.as_object() else { + continue; + }; + for field in ELEMENT_FIELDS { + let error = match field { + "noteBorderGradient" => invalid_note_gradient_error( + entry.get(field), + "unsupported note border color", + ), + "noteGradient" | "noteGlowGradient" => invalid_note_gradient_error( + entry.get(field), + "unsupported note gradient color", + ), + _ => invalid_gradient_error(entry.get(field)), + }; + if let Some(error) = error { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}: {error}" + )); + } + } + for field in SHADOW_FIELDS { + // null은 Option 역직렬화와 동일하게 "값 없음" 취급 + let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { + continue; + }; + if let Some((suffix, error)) = invalid_shadow_error(value) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" + )); + } + } + for field in IMAGE_TRANSFORM_FIELDS { + let Some(value) = entry.get(field).filter(|value| !value.is_null()) else { + continue; + }; + if let Some((suffix, error)) = invalid_image_transform_error(value) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].{field}{suffix}: {error}" + )); + } + } + let Some(counter) = entry.get("counter").and_then(serde_json::Value::as_object) + else { + continue; + }; + for field in COUNTER_FIELDS { + if let Some(error) = invalid_gradient_error(counter.get(field)) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].counter.{field}: {error}" + )); + } + } + } + } + } + None +} + +fn invalid_gradient_error(value: Option<&serde_json::Value>) -> Option { + let value = value?; + let gradient = match serde_json::from_value::>(value.clone()) { + Ok(gradient) => gradient, + Err(error) => return Some(error.to_string()), + }?; + // 공백 stop 색은 로드 복구가 수렴하지 않는 손상 값이라 문에서 거부 + gradient + .stops + .iter() + .position(|stop| stop.color.trim().is_empty()) + .map(|index| format!("stops[{index}].color must not be blank")) +} + +fn invalid_note_gradient_error( + value: Option<&serde_json::Value>, + color_error: &str, +) -> Option { + let value = value?; + let gradient = match serde_json::from_value::>(value.clone()) { + Ok(gradient) => gradient, + Err(error) => return Some(error.to_string()), + }?; + gradient + .note_border_invalid_stop_index() + .map(|index| format!("stops[{index}].color contains an {color_error}")) +} + +fn invalid_shadow_error(value: &serde_json::Value) -> Option<(&'static str, &'static str)> { + let Some(shadow) = value.as_object() else { + return Some(("", "must be an object")); + }; + if !shadow + .get("enabled") + .is_some_and(serde_json::Value::is_boolean) + { + return Some((".enabled", "must be a boolean")); + } + if shadow + .get("color") + .and_then(serde_json::Value::as_str) + .is_none_or(str::is_empty) + { + return Some((".color", "must be a non-empty string")); + } + for field in ["offsetX", "offsetY"] { + if !shadow + .get(field) + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| { + value.is_finite() && (SHADOW_OFFSET_MIN..=SHADOW_OFFSET_MAX).contains(&value) + }) + { + let suffix = if field == "offsetX" { + ".offsetX" + } else { + ".offsetY" + }; + return Some((suffix, "must be a finite number between -100 and 100")); + } + } + if !shadow + .get("blur") + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| { + value.is_finite() && (SHADOW_BLUR_MIN..=SHADOW_BLUR_MAX).contains(&value) + }) + { + return Some((".blur", "must be a finite number between 0 and 100")); + } + None +} + +// 이미지 변환은 그림자와 같은 정책으로 문에서 거부한다 - 문서 검증(editor.rs)과 같은 범위 +fn invalid_image_transform_error( + value: &serde_json::Value, +) -> Option<(&'static str, &'static str)> { + use crate::models::{ + IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, + IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, + }; + let Some(transform) = value.as_object() else { + return Some(("", "must be an object")); + }; + for (field, suffix, min, max, error) in [ + ( + "offsetX", + ".offsetX", + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + "must be a finite number between -500 and 500", + ), + ( + "offsetY", + ".offsetY", + IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_OFFSET_MAX, + "must be a finite number between -500 and 500", + ), + ( + "rotation", + ".rotation", + IMAGE_TRANSFORM_ROTATION_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, + "must be a finite number between -180 and 180", + ), + ( + "scale", + ".scale", + IMAGE_TRANSFORM_SCALE_MIN, + IMAGE_TRANSFORM_SCALE_MAX, + "must be a finite number between 0.1 and 10", + ), + ] { + if !transform + .get(field) + .and_then(serde_json::Value::as_f64) + .is_some_and(|value| value.is_finite() && (min..=max).contains(&value)) + { + return Some((suffix, error)); + } + } + None +} + +#[cfg(test)] +pub(crate) fn read_preset_file_for_simulation(path: &Path) -> CmdResult { + read_preset_file(path) +} diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index e50b74741..97dced52c 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -243,637 +243,4 @@ pub(crate) fn option_has_non_empty_text(value: &Option) -> bool { } #[cfg(test)] -mod tests { - use super::PresetFile; - // file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 - #[cfg(not(target_os = "windows"))] - use super::local_source_path_from_image_ref; - use crate::models::{KeyCounterColor, KeySlot, NoteColor}; - use serde::{Deserialize, Serialize}; - use serde_json::json; - - #[test] - fn preset_round_trip_preserves_layer_groups_and_tab_css_overrides() { - let value = json!({ - "layerGroups": { "4key": [] }, - "tabCssOverrides": { - "4key": { - "path": "/tmp/tab.css", - "content": ".key { color: red; }", - "enabled": true - } - } - }); - let preset: PresetFile = serde_json::from_value(value.clone()).unwrap(); - let serialized = serde_json::to_value(preset).unwrap(); - - assert_eq!(serialized["layerGroups"], value["layerGroups"]); - assert_eq!(serialized["tabCssOverrides"], value["tabCssOverrides"]); - } - - #[test] - fn legacy_preset_defaults_new_fields_to_none() { - let preset: PresetFile = serde_json::from_value(json!({})).unwrap(); - - assert!(preset.layer_groups.is_none()); - assert!(preset.tab_css_overrides.is_none()); - assert!(preset.bar_count.is_none()); - } - - #[test] - fn preset_round_trip_uses_bar_count_camel_case_field() { - let preset: PresetFile = serde_json::from_value(json!({ "barCount": 3 })).unwrap(); - let serialized = serde_json::to_value(preset).unwrap(); - - assert_eq!(serialized["barCount"], 3); - assert!(serialized.get("bar_count").is_none()); - } - - #[test] - fn legacy_preset_positions_default_new_surface_gradients_to_none() { - let preset: PresetFile = serde_json::from_value(json!({ - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteBorderColor": "#112233" - }] - } - })) - .unwrap(); - let position = &preset.key_positions.as_ref().unwrap()["4key"][0]; - - assert!(position.note_gradient.is_none()); - assert!(position.note_glow_gradient.is_none()); - assert!(position.note_border_gradient.is_none()); - assert!(position.font_gradient.is_none()); - assert!(position.active_font_gradient.is_none()); - } - - #[test] - fn preset_wire_schema_excludes_internal_store_fields() { - let serialized = serde_json::to_value(PresetFile::default()).unwrap(); - - assert!(serialized.get("customCssHistory").is_none()); - assert!(serialized.get("panelBounds").is_none()); - } - - #[derive(Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeaturePresetPosition { - dx: f64, - dy: f64, - width: f64, - count: u32, - background_color: Option, - font_color: Option, - active_font_color: Option, - note_color: NoteColor, - note_opacity: u32, - note_opacity_top: Option, - note_opacity_bottom: Option, - note_glow_color: Option, - note_glow_opacity: u32, - note_glow_opacity_top: Option, - note_glow_opacity_bottom: Option, - note_border_color: Option, - counter: PreFeaturePresetCounter, - } - - #[derive(Serialize, Deserialize)] - struct PreFeaturePresetCounter { - fill: KeyCounterColor, - } - - #[derive(Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeaturePreset { - key_positions: Option>>, - } - - #[test] - fn gradient_preset_round_trip_and_pre_feature_shadow_preserve_representative_colors() { - let source = json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "backgroundColor": "rgba(16, 32, 48, 1)", - "backgroundGradient": { - "angle": 90, - "stops": [ - { "color": "rgba(16, 32, 48, 1)", "pos": 0 }, - { "color": "rgba(64, 80, 96, 0.5)", "pos": 1 } - ] - }, - "fontColor": "#123456", - "fontGradient": { - "angle": 45, - "stops": [ - { "color": "#123456", "pos": 0 }, - { "color": "#ABCDEF", "pos": 1 } - ] - }, - "activeFontColor": "#654321", - "activeFontGradient": { - "angle": 135, - "stops": [ - { "color": "#654321", "pos": 0 }, - { "color": "#FEDCBA", "pos": 1 } - ] - }, - "counter": { - "fill": { - "idle": "rgba(255,255,255,1)", - "active": "rgba(20,20,24,0.9)" - }, - "fillIdleGradient": { - "angle": 180, - "stops": [ - { "color": "#FFFFFF", "pos": 0 }, - { "color": "#000000", "pos": 1 } - ] - } - } - }] - } - }); - let preset: PresetFile = serde_json::from_value(source).unwrap(); - let serialized = serde_json::to_value(&preset).unwrap(); - let restored: PresetFile = serde_json::from_value(serialized.clone()).unwrap(); - let reserialized = serde_json::to_value(restored).unwrap(); - - assert_eq!(reserialized, serialized); - assert_eq!( - serialized["keyPositions"]["4key"][0]["backgroundGradient"]["angle"].as_f64(), - Some(90.0) - ); - assert_eq!( - serialized["keyPositions"]["4key"][0]["fontGradient"]["angle"].as_f64(), - Some(45.0) - ); - assert_eq!( - serialized["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"]["angle"].as_f64(), - Some(180.0) - ); - - let shadow: PreFeaturePreset = serde_json::from_value(serialized).unwrap(); - let shadow_positions = shadow.key_positions.unwrap(); - let shadow_position = &shadow_positions["4key"][0]; - assert_eq!( - shadow_position.background_color.as_deref(), - Some("rgba(16, 32, 48, 1)") - ); - assert_eq!(shadow_position.font_color.as_deref(), Some("#123456")); - assert_eq!( - shadow_position.active_font_color.as_deref(), - Some("#654321") - ); - assert_eq!(shadow_position.counter.fill.idle, "rgba(255,255,255,1)"); - } - - #[test] - fn preset_1_6_1_shadow_round_trip_drops_new_gradients_but_keeps_representatives() { - let source = json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": { - "4key": [{ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteColor": { - "type": "gradient", - "top": "#112233", - "bottom": "#445566" - }, - "noteOpacity": 80, - "noteOpacityTop": 40, - "noteOpacityBottom": 20, - "noteGradient": { - "angle": 45, - "stops": [ - { "color": "rgba(17,34,51,.5)", "pos": 0 }, - { "color": "#44556640", "pos": 1 } - ] - }, - "noteGlowColor": { - "type": "gradient", - "top": "#778899", - "bottom": "#AABBCC" - }, - "noteGlowOpacity": 60, - "noteGlowOpacityTop": 30, - "noteGlowOpacityBottom": 60, - "noteGlowGradient": { - "angle": 135, - "stops": [ - { "color": "#77889980", "pos": 0 }, - { "color": "rgb(170,187,204)", "pos": 1 } - ] - }, - "noteBorderColor": "#112233", - "noteBorderGradient": { - "angle": 90, - "stops": [ - { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, - { "color": "#ABC8", "pos": 1 } - ] - }, - "fontColor": "#123456", - "fontGradient": { - "angle": 90, - "stops": [ - { "color": "#123456", "pos": 0 }, - { "color": "#ABCDEF", "pos": 1 } - ] - }, - "activeFontColor": "#654321", - "activeFontGradient": { - "angle": 135, - "stops": [ - { "color": "#654321", "pos": 0 }, - { "color": "#FEDCBA", "pos": 1 } - ] - }, - "counter": { - "fill": { - "idle": "rgba(255,255,255,1)", - "active": "rgba(20,20,24,0.9)" - } - } - }] - } - }); - let current: PresetFile = serde_json::from_value(source).unwrap(); - let current_wire = serde_json::to_value(current).unwrap(); - - let old: PreFeaturePreset = serde_json::from_value(current_wire).unwrap(); - let old_wire = serde_json::to_value(old).unwrap(); - let restored: PresetFile = serde_json::from_value(old_wire.clone()).unwrap(); - let position = &restored.key_positions.as_ref().unwrap()["4key"][0]; - - assert!(old_wire["keyPositions"]["4key"][0] - .get("noteGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("noteGlowGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("noteBorderGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("fontGradient") - .is_none()); - assert!(old_wire["keyPositions"]["4key"][0] - .get("activeFontGradient") - .is_none()); - assert!(position.note_gradient.is_none()); - assert!(position.note_glow_gradient.is_none()); - assert!(position.note_border_gradient.is_none()); - assert!(position.font_gradient.is_none()); - assert!(position.active_font_gradient.is_none()); - assert_eq!( - position.note_color, - NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#445566".to_string(), - } - ); - assert_eq!(position.note_opacity, 80); - assert_eq!(position.note_opacity_top, Some(40)); - assert_eq!(position.note_opacity_bottom, Some(20)); - assert_eq!( - position.note_glow_color, - Some(NoteColor::Gradient { - top: "#778899".to_string(), - bottom: "#AABBCC".to_string(), - }) - ); - assert_eq!(position.note_glow_opacity, 60); - assert_eq!(position.note_glow_opacity_top, Some(30)); - assert_eq!(position.note_glow_opacity_bottom, Some(60)); - assert_eq!(position.note_border_color.as_deref(), Some("#112233")); - assert_eq!(position.font_color.as_deref(), Some("#123456")); - assert_eq!(position.active_font_color.as_deref(), Some("#654321")); - } - - #[test] - fn tauri_era_preset_schema_transitions_remain_readable() { - let tauri_130: PresetFile = serde_json::from_value(json!({ - "keys": { "custom-130": ["Q"] }, - "keyPositions": { - "custom-130": [{ - "dx": 12.5, - "dy": -3.0, - "width": 64.0, - "count": 7, - "displayText": "Tauri 1.3" - }] - }, - "backgroundColor": "#112233", - "noteSettings": { "speed": 321, "trackHeight": 222 }, - "noteEffect": false, - "laboratoryEnabled": true, - "customTabs": [{ "id": "custom-130", "name": "Old tab" }], - "selectedKeyType": "custom-130", - "useCustomCSS": true, - "customCSS": { "path": "/tmp/old.css", "content": ".old {}" } - })) - .expect("1.3.0 preset must deserialize"); - - assert_eq!( - tauri_130.keys.as_ref().unwrap()["custom-130"], - vec![KeySlot::from("Q")] - ); - let old_position = &tauri_130.key_positions.as_ref().unwrap()["custom-130"][0]; - assert_eq!(old_position.dx, 12.5); - assert_eq!(old_position.dy, -3.0); - assert_eq!(old_position.width, 64.0); - assert_eq!(old_position.count, 7); - assert_eq!(old_position.display_text.as_deref(), Some("Tauri 1.3")); - assert_eq!(tauri_130.background_color.as_deref(), Some("#112233")); - assert_eq!(tauri_130.note_settings.as_ref().unwrap().speed, 321); - assert_eq!(tauri_130.note_settings.as_ref().unwrap().track_height, 222); - assert_eq!(tauri_130.note_effect, Some(false)); - assert_eq!(tauri_130.laboratory_enabled, Some(true)); - assert_eq!(tauri_130.custom_tabs.as_ref().unwrap()[0].id, "custom-130"); - assert_eq!(tauri_130.selected_key_type.as_deref(), Some("custom-130")); - assert_eq!(tauri_130.use_custom_css, Some(true)); - assert_eq!(tauri_130.custom_css.as_ref().unwrap().content, ".old {}"); - - let transition_fixtures = [ - ( - "1.4.0", - json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": {}, - "useCustomJS": true, - "customJS": { "path": null, "content": "void 0", "plugins": [] } - }), - ), - ( - "1.5.1", - json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": {}, - "statPositions": {}, - "fontSettings": { "customFonts": [] }, - "embeddedLocalFonts": [], - "embeddedLocalImages": [] - }), - ), - ( - "1.6.0", - json!({ - "keys": { "4key": ["Q"] }, - "keyPositions": {}, - "statPositions": {}, - "graphPositions": {}, - "tabNoteOverrides": {}, - "embeddedLocalSounds": [] - }), - ), - ]; - - for (version, fixture) in transition_fixtures { - let preset: PresetFile = serde_json::from_value(fixture) - .unwrap_or_else(|error| panic!("{version} preset must deserialize: {error}")); - assert_eq!( - preset.keys.as_ref().unwrap()["4key"], - vec![KeySlot::from("Q")] - ); - } - - let tauri_161: PresetFile = serde_json::from_value(json!({ - "keys": { "custom-161": ["W"] }, - "keyPositions": { - "custom-161": [{ - "dx": 10.0, - "dy": 20.0, - "width": 70.0, - "height": 80.0, - "count": 9, - "groupId": "historic-group", - "activeImage": "dmnote-local-image://image-1", - "soundPath": "dmnote-local-sound://sound-1" - }] - }, - "statPositions": { - "custom-161": [{ - "statType": "total", - "dx": 1.0, - "dy": 2.0, - "width": 100.0, - "count": 0 - }] - }, - "graphPositions": { - "custom-161": [{ - "statType": "kpsAvg", - "graphType": "line", - "graphSpeed": 4, - "graphColor": "#abcdef", - "dx": 3.0, - "dy": 4.0, - "width": 120.0, - "count": 0 - }] - }, - "knobPositions": { - "custom-161": [{ - "axisId": "HIDA:1:2:3:4", - "sensitivity": 1.5, - "reverse": true, - "dx": 5.0, - "dy": 6.0, - "width": 90.0, - "count": 0 - }] - }, - "useCustomJS": true, - "customJS": { - "path": null, - "content": "globalThis.legacy = true", - "plugins": [{ - "id": "plugin-161", - "name": "Legacy plugin", - "path": null, - "content": "globalThis.plugin161 = true", - "enabled": true - }] - }, - "fontSettings": { - "customFonts": [{ - "id": "font-1", - "type": "local", - "name": "LegacyFont", - "displayName": "Legacy Font", - "enabled": true, - "localPath": null, - "cssContent": null - }] - }, - "tabNoteOverrides": { - "custom-161": { "speed": 444, "reverse": true } - }, - "embeddedLocalFonts": [{ - "fontId": "font-1", - "extension": "ttf", - "dataBase64": "AA==" - }], - "embeddedLocalImages": [{ - "imageId": "image-1", - "extension": "png", - "dataBase64": "AA==" - }], - "embeddedLocalSounds": [{ - "soundId": "sound-1", - "extension": "wav", - "dataBase64": "AA==" - }] - })) - .expect("1.6.1 preset must deserialize"); - - assert_eq!( - tauri_161.keys.as_ref().unwrap()["custom-161"], - [KeySlot::from("W")] - ); - let position_161 = &tauri_161.key_positions.as_ref().unwrap()["custom-161"][0]; - assert_eq!(position_161.group_id.as_deref(), Some("historic-group")); - assert_eq!( - position_161.active_image.as_deref(), - Some("dmnote-local-image://image-1") - ); - assert_eq!( - position_161.sound_path.as_deref(), - Some("dmnote-local-sound://sound-1") - ); - assert_eq!( - tauri_161.stat_positions.as_ref().unwrap()["custom-161"].len(), - 1 - ); - assert_eq!( - tauri_161.graph_positions.as_ref().unwrap()["custom-161"][0].graph_speed, - 4 - ); - assert_eq!( - tauri_161.knob_positions.as_ref().unwrap()["custom-161"][0].axis_id, - "HIDA:1:2:3:4" - ); - assert_eq!( - tauri_161.custom_js.as_ref().unwrap().plugins[0].id, - "plugin-161" - ); - assert_eq!( - tauri_161.font_settings.as_ref().unwrap().custom_fonts[0].id, - "font-1" - ); - assert_eq!( - tauri_161.tab_note_overrides.as_ref().unwrap()["custom-161"].speed, - Some(444) - ); - assert_eq!( - tauri_161.embedded_local_fonts.as_ref().unwrap()[0].font_id, - "font-1" - ); - assert_eq!( - tauri_161.embedded_local_images.as_ref().unwrap()[0].image_id, - "image-1" - ); - assert_eq!( - tauri_161.embedded_local_sounds.as_ref().unwrap()[0].sound_id, - "sound-1" - ); - } - - #[test] - fn internal_editor_revision_never_becomes_part_of_a_preset() { - let preset: PresetFile = serde_json::from_value(json!({ - "keys": { "4key": ["Q"] }, - "editorRevision": 42 - })) - .unwrap(); - let serialized = serde_json::to_value(preset).unwrap(); - - assert!(serialized.get("editorRevision").is_none()); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn file_url_image_source_preserves_its_absolute_path() { - assert_eq!( - local_source_path_from_image_ref("file:///tmp/dmnote-image.png"), - Some(std::path::PathBuf::from("/tmp/dmnote-image.png")) - ); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn file_url_image_source_decodes_percent_sequences_only() { - assert_eq!( - local_source_path_from_image_ref( - "file:///tmp/Application%20Support/%ED%95%9C%EA%B8%80%25+a.png" - ), - Some(std::path::PathBuf::from( - "/tmp/Application Support/한글%+a.png" - )) - ); - } - - #[cfg(not(target_os = "windows"))] - #[test] - fn invalid_file_url_uses_legacy_literal_path_for_preset_compatibility() { - assert_eq!( - local_source_path_from_image_ref("file://[invalid/path.png"), - Some(std::path::PathBuf::from("[invalid/path.png")) - ); - assert_eq!( - local_source_path_from_image_ref("file:///tmp/broken%ZZ.png"), - Some(std::path::PathBuf::from("/tmp/broken%ZZ.png")) - ); - } - - #[test] - fn preset_1_0_fixture_preserves_values_and_fills_visual_defaults() { - // 1.0.0이 실제 저장하던 프리셋 형식 — height/noteColor/noteOpacity 없음 - let fixture = r#"{ - "keys": { - "4key": ["Q"], - "5key": [], - "6key": [], - "8key": [] - }, - "keyPositions": { - "4key": [ - { - "dx": 777, - "dy": 130, - "width": 60, - "activeImage": "", - "inactiveImage": "", - "count": 42 - } - ], - "5key": [], - "6key": [], - "8key": [] - }, - "backgroundColor": "transparent" - }"#; - let preset: PresetFile = serde_json::from_str(fixture).unwrap(); - let positions = preset.key_positions.unwrap(); - let position = &positions["4key"][0]; - - assert_eq!(position.dx, 777.0); - assert_eq!(position.count, 42); - assert_eq!(position.height, 60.0); - assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 90); - } -} +mod tests; diff --git a/src-tauri/src/commands/preset/tests.rs b/src-tauri/src/commands/preset/tests.rs new file mode 100644 index 000000000..74918de72 --- /dev/null +++ b/src-tauri/src/commands/preset/tests.rs @@ -0,0 +1,632 @@ +use super::PresetFile; +// file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 +#[cfg(not(target_os = "windows"))] +use super::local_source_path_from_image_ref; +use crate::models::{KeyCounterColor, KeySlot, NoteColor}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[test] +fn preset_round_trip_preserves_layer_groups_and_tab_css_overrides() { + let value = json!({ + "layerGroups": { "4key": [] }, + "tabCssOverrides": { + "4key": { + "path": "/tmp/tab.css", + "content": ".key { color: red; }", + "enabled": true + } + } + }); + let preset: PresetFile = serde_json::from_value(value.clone()).unwrap(); + let serialized = serde_json::to_value(preset).unwrap(); + + assert_eq!(serialized["layerGroups"], value["layerGroups"]); + assert_eq!(serialized["tabCssOverrides"], value["tabCssOverrides"]); +} + +#[test] +fn legacy_preset_defaults_new_fields_to_none() { + let preset: PresetFile = serde_json::from_value(json!({})).unwrap(); + + assert!(preset.layer_groups.is_none()); + assert!(preset.tab_css_overrides.is_none()); + assert!(preset.bar_count.is_none()); +} + +#[test] +fn preset_round_trip_uses_bar_count_camel_case_field() { + let preset: PresetFile = serde_json::from_value(json!({ "barCount": 3 })).unwrap(); + let serialized = serde_json::to_value(preset).unwrap(); + + assert_eq!(serialized["barCount"], 3); + assert!(serialized.get("bar_count").is_none()); +} + +#[test] +fn legacy_preset_positions_default_new_surface_gradients_to_none() { + let preset: PresetFile = serde_json::from_value(json!({ + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteBorderColor": "#112233" + }] + } + })) + .unwrap(); + let position = &preset.key_positions.as_ref().unwrap()["4key"][0]; + + assert!(position.note_gradient.is_none()); + assert!(position.note_glow_gradient.is_none()); + assert!(position.note_border_gradient.is_none()); + assert!(position.font_gradient.is_none()); + assert!(position.active_font_gradient.is_none()); +} + +#[test] +fn preset_wire_schema_excludes_internal_store_fields() { + let serialized = serde_json::to_value(PresetFile::default()).unwrap(); + + assert!(serialized.get("customCssHistory").is_none()); + assert!(serialized.get("panelBounds").is_none()); +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeaturePresetPosition { + dx: f64, + dy: f64, + width: f64, + count: u32, + background_color: Option, + font_color: Option, + active_font_color: Option, + note_color: NoteColor, + note_opacity: u32, + note_opacity_top: Option, + note_opacity_bottom: Option, + note_glow_color: Option, + note_glow_opacity: u32, + note_glow_opacity_top: Option, + note_glow_opacity_bottom: Option, + note_border_color: Option, + counter: PreFeaturePresetCounter, +} + +#[derive(Serialize, Deserialize)] +struct PreFeaturePresetCounter { + fill: KeyCounterColor, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeaturePreset { + key_positions: Option>>, +} + +#[test] +fn gradient_preset_round_trip_and_pre_feature_shadow_preserve_representative_colors() { + let source = json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "rgba(16, 32, 48, 1)", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(16, 32, 48, 1)", "pos": 0 }, + { "color": "rgba(64, 80, 96, 0.5)", "pos": 1 } + ] + }, + "fontColor": "#123456", + "fontGradient": { + "angle": 45, + "stops": [ + { "color": "#123456", "pos": 0 }, + { "color": "#ABCDEF", "pos": 1 } + ] + }, + "activeFontColor": "#654321", + "activeFontGradient": { + "angle": 135, + "stops": [ + { "color": "#654321", "pos": 0 }, + { "color": "#FEDCBA", "pos": 1 } + ] + }, + "counter": { + "fill": { + "idle": "rgba(255,255,255,1)", + "active": "rgba(20,20,24,0.9)" + }, + "fillIdleGradient": { + "angle": 180, + "stops": [ + { "color": "#FFFFFF", "pos": 0 }, + { "color": "#000000", "pos": 1 } + ] + } + } + }] + } + }); + let preset: PresetFile = serde_json::from_value(source).unwrap(); + let serialized = serde_json::to_value(&preset).unwrap(); + let restored: PresetFile = serde_json::from_value(serialized.clone()).unwrap(); + let reserialized = serde_json::to_value(restored).unwrap(); + + assert_eq!(reserialized, serialized); + assert_eq!( + serialized["keyPositions"]["4key"][0]["backgroundGradient"]["angle"].as_f64(), + Some(90.0) + ); + assert_eq!( + serialized["keyPositions"]["4key"][0]["fontGradient"]["angle"].as_f64(), + Some(45.0) + ); + assert_eq!( + serialized["keyPositions"]["4key"][0]["counter"]["fillIdleGradient"]["angle"].as_f64(), + Some(180.0) + ); + + let shadow: PreFeaturePreset = serde_json::from_value(serialized).unwrap(); + let shadow_positions = shadow.key_positions.unwrap(); + let shadow_position = &shadow_positions["4key"][0]; + assert_eq!( + shadow_position.background_color.as_deref(), + Some("rgba(16, 32, 48, 1)") + ); + assert_eq!(shadow_position.font_color.as_deref(), Some("#123456")); + assert_eq!( + shadow_position.active_font_color.as_deref(), + Some("#654321") + ); + assert_eq!(shadow_position.counter.fill.idle, "rgba(255,255,255,1)"); +} + +#[test] +fn preset_1_6_1_shadow_round_trip_drops_new_gradients_but_keeps_representatives() { + let source = json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": { + "4key": [{ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteColor": { + "type": "gradient", + "top": "#112233", + "bottom": "#445566" + }, + "noteOpacity": 80, + "noteOpacityTop": 40, + "noteOpacityBottom": 20, + "noteGradient": { + "angle": 45, + "stops": [ + { "color": "rgba(17,34,51,.5)", "pos": 0 }, + { "color": "#44556640", "pos": 1 } + ] + }, + "noteGlowColor": { + "type": "gradient", + "top": "#778899", + "bottom": "#AABBCC" + }, + "noteGlowOpacity": 60, + "noteGlowOpacityTop": 30, + "noteGlowOpacityBottom": 60, + "noteGlowGradient": { + "angle": 135, + "stops": [ + { "color": "#77889980", "pos": 0 }, + { "color": "rgb(170,187,204)", "pos": 1 } + ] + }, + "noteBorderColor": "#112233", + "noteBorderGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, + { "color": "#ABC8", "pos": 1 } + ] + }, + "fontColor": "#123456", + "fontGradient": { + "angle": 90, + "stops": [ + { "color": "#123456", "pos": 0 }, + { "color": "#ABCDEF", "pos": 1 } + ] + }, + "activeFontColor": "#654321", + "activeFontGradient": { + "angle": 135, + "stops": [ + { "color": "#654321", "pos": 0 }, + { "color": "#FEDCBA", "pos": 1 } + ] + }, + "counter": { + "fill": { + "idle": "rgba(255,255,255,1)", + "active": "rgba(20,20,24,0.9)" + } + } + }] + } + }); + let current: PresetFile = serde_json::from_value(source).unwrap(); + let current_wire = serde_json::to_value(current).unwrap(); + + let old: PreFeaturePreset = serde_json::from_value(current_wire).unwrap(); + let old_wire = serde_json::to_value(old).unwrap(); + let restored: PresetFile = serde_json::from_value(old_wire.clone()).unwrap(); + let position = &restored.key_positions.as_ref().unwrap()["4key"][0]; + + assert!(old_wire["keyPositions"]["4key"][0] + .get("noteGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("noteGlowGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("noteBorderGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("fontGradient") + .is_none()); + assert!(old_wire["keyPositions"]["4key"][0] + .get("activeFontGradient") + .is_none()); + assert!(position.note_gradient.is_none()); + assert!(position.note_glow_gradient.is_none()); + assert!(position.note_border_gradient.is_none()); + assert!(position.font_gradient.is_none()); + assert!(position.active_font_gradient.is_none()); + assert_eq!( + position.note_color, + NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#445566".to_string(), + } + ); + assert_eq!(position.note_opacity, 80); + assert_eq!(position.note_opacity_top, Some(40)); + assert_eq!(position.note_opacity_bottom, Some(20)); + assert_eq!( + position.note_glow_color, + Some(NoteColor::Gradient { + top: "#778899".to_string(), + bottom: "#AABBCC".to_string(), + }) + ); + assert_eq!(position.note_glow_opacity, 60); + assert_eq!(position.note_glow_opacity_top, Some(30)); + assert_eq!(position.note_glow_opacity_bottom, Some(60)); + assert_eq!(position.note_border_color.as_deref(), Some("#112233")); + assert_eq!(position.font_color.as_deref(), Some("#123456")); + assert_eq!(position.active_font_color.as_deref(), Some("#654321")); +} + +#[test] +fn tauri_era_preset_schema_transitions_remain_readable() { + let tauri_130: PresetFile = serde_json::from_value(json!({ + "keys": { "custom-130": ["Q"] }, + "keyPositions": { + "custom-130": [{ + "dx": 12.5, + "dy": -3.0, + "width": 64.0, + "count": 7, + "displayText": "Tauri 1.3" + }] + }, + "backgroundColor": "#112233", + "noteSettings": { "speed": 321, "trackHeight": 222 }, + "noteEffect": false, + "laboratoryEnabled": true, + "customTabs": [{ "id": "custom-130", "name": "Old tab" }], + "selectedKeyType": "custom-130", + "useCustomCSS": true, + "customCSS": { "path": "/tmp/old.css", "content": ".old {}" } + })) + .expect("1.3.0 preset must deserialize"); + + assert_eq!( + tauri_130.keys.as_ref().unwrap()["custom-130"], + vec![KeySlot::from("Q")] + ); + let old_position = &tauri_130.key_positions.as_ref().unwrap()["custom-130"][0]; + assert_eq!(old_position.dx, 12.5); + assert_eq!(old_position.dy, -3.0); + assert_eq!(old_position.width, 64.0); + assert_eq!(old_position.count, 7); + assert_eq!(old_position.display_text.as_deref(), Some("Tauri 1.3")); + assert_eq!(tauri_130.background_color.as_deref(), Some("#112233")); + assert_eq!(tauri_130.note_settings.as_ref().unwrap().speed, 321); + assert_eq!(tauri_130.note_settings.as_ref().unwrap().track_height, 222); + assert_eq!(tauri_130.note_effect, Some(false)); + assert_eq!(tauri_130.laboratory_enabled, Some(true)); + assert_eq!(tauri_130.custom_tabs.as_ref().unwrap()[0].id, "custom-130"); + assert_eq!(tauri_130.selected_key_type.as_deref(), Some("custom-130")); + assert_eq!(tauri_130.use_custom_css, Some(true)); + assert_eq!(tauri_130.custom_css.as_ref().unwrap().content, ".old {}"); + + let transition_fixtures = [ + ( + "1.4.0", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "useCustomJS": true, + "customJS": { "path": null, "content": "void 0", "plugins": [] } + }), + ), + ( + "1.5.1", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "statPositions": {}, + "fontSettings": { "customFonts": [] }, + "embeddedLocalFonts": [], + "embeddedLocalImages": [] + }), + ), + ( + "1.6.0", + json!({ + "keys": { "4key": ["Q"] }, + "keyPositions": {}, + "statPositions": {}, + "graphPositions": {}, + "tabNoteOverrides": {}, + "embeddedLocalSounds": [] + }), + ), + ]; + + for (version, fixture) in transition_fixtures { + let preset: PresetFile = serde_json::from_value(fixture) + .unwrap_or_else(|error| panic!("{version} preset must deserialize: {error}")); + assert_eq!( + preset.keys.as_ref().unwrap()["4key"], + vec![KeySlot::from("Q")] + ); + } + + let tauri_161: PresetFile = serde_json::from_value(json!({ + "keys": { "custom-161": ["W"] }, + "keyPositions": { + "custom-161": [{ + "dx": 10.0, + "dy": 20.0, + "width": 70.0, + "height": 80.0, + "count": 9, + "groupId": "historic-group", + "activeImage": "dmnote-local-image://image-1", + "soundPath": "dmnote-local-sound://sound-1" + }] + }, + "statPositions": { + "custom-161": [{ + "statType": "total", + "dx": 1.0, + "dy": 2.0, + "width": 100.0, + "count": 0 + }] + }, + "graphPositions": { + "custom-161": [{ + "statType": "kpsAvg", + "graphType": "line", + "graphSpeed": 4, + "graphColor": "#abcdef", + "dx": 3.0, + "dy": 4.0, + "width": 120.0, + "count": 0 + }] + }, + "knobPositions": { + "custom-161": [{ + "axisId": "HIDA:1:2:3:4", + "sensitivity": 1.5, + "reverse": true, + "dx": 5.0, + "dy": 6.0, + "width": 90.0, + "count": 0 + }] + }, + "useCustomJS": true, + "customJS": { + "path": null, + "content": "globalThis.legacy = true", + "plugins": [{ + "id": "plugin-161", + "name": "Legacy plugin", + "path": null, + "content": "globalThis.plugin161 = true", + "enabled": true + }] + }, + "fontSettings": { + "customFonts": [{ + "id": "font-1", + "type": "local", + "name": "LegacyFont", + "displayName": "Legacy Font", + "enabled": true, + "localPath": null, + "cssContent": null + }] + }, + "tabNoteOverrides": { + "custom-161": { "speed": 444, "reverse": true } + }, + "embeddedLocalFonts": [{ + "fontId": "font-1", + "extension": "ttf", + "dataBase64": "AA==" + }], + "embeddedLocalImages": [{ + "imageId": "image-1", + "extension": "png", + "dataBase64": "AA==" + }], + "embeddedLocalSounds": [{ + "soundId": "sound-1", + "extension": "wav", + "dataBase64": "AA==" + }] + })) + .expect("1.6.1 preset must deserialize"); + + assert_eq!( + tauri_161.keys.as_ref().unwrap()["custom-161"], + [KeySlot::from("W")] + ); + let position_161 = &tauri_161.key_positions.as_ref().unwrap()["custom-161"][0]; + assert_eq!(position_161.group_id.as_deref(), Some("historic-group")); + assert_eq!( + position_161.active_image.as_deref(), + Some("dmnote-local-image://image-1") + ); + assert_eq!( + position_161.sound_path.as_deref(), + Some("dmnote-local-sound://sound-1") + ); + assert_eq!( + tauri_161.stat_positions.as_ref().unwrap()["custom-161"].len(), + 1 + ); + assert_eq!( + tauri_161.graph_positions.as_ref().unwrap()["custom-161"][0].graph_speed, + 4 + ); + assert_eq!( + tauri_161.knob_positions.as_ref().unwrap()["custom-161"][0].axis_id, + "HIDA:1:2:3:4" + ); + assert_eq!( + tauri_161.custom_js.as_ref().unwrap().plugins[0].id, + "plugin-161" + ); + assert_eq!( + tauri_161.font_settings.as_ref().unwrap().custom_fonts[0].id, + "font-1" + ); + assert_eq!( + tauri_161.tab_note_overrides.as_ref().unwrap()["custom-161"].speed, + Some(444) + ); + assert_eq!( + tauri_161.embedded_local_fonts.as_ref().unwrap()[0].font_id, + "font-1" + ); + assert_eq!( + tauri_161.embedded_local_images.as_ref().unwrap()[0].image_id, + "image-1" + ); + assert_eq!( + tauri_161.embedded_local_sounds.as_ref().unwrap()[0].sound_id, + "sound-1" + ); +} + +#[test] +fn internal_editor_revision_never_becomes_part_of_a_preset() { + let preset: PresetFile = serde_json::from_value(json!({ + "keys": { "4key": ["Q"] }, + "editorRevision": 42 + })) + .unwrap(); + let serialized = serde_json::to_value(preset).unwrap(); + + assert!(serialized.get("editorRevision").is_none()); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn file_url_image_source_preserves_its_absolute_path() { + assert_eq!( + local_source_path_from_image_ref("file:///tmp/dmnote-image.png"), + Some(std::path::PathBuf::from("/tmp/dmnote-image.png")) + ); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn file_url_image_source_decodes_percent_sequences_only() { + assert_eq!( + local_source_path_from_image_ref( + "file:///tmp/Application%20Support/%ED%95%9C%EA%B8%80%25+a.png" + ), + Some(std::path::PathBuf::from( + "/tmp/Application Support/한글%+a.png" + )) + ); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn invalid_file_url_uses_legacy_literal_path_for_preset_compatibility() { + assert_eq!( + local_source_path_from_image_ref("file://[invalid/path.png"), + Some(std::path::PathBuf::from("[invalid/path.png")) + ); + assert_eq!( + local_source_path_from_image_ref("file:///tmp/broken%ZZ.png"), + Some(std::path::PathBuf::from("/tmp/broken%ZZ.png")) + ); +} + +#[test] +fn preset_1_0_fixture_preserves_values_and_fills_visual_defaults() { + // 1.0.0이 실제 저장하던 프리셋 형식 — height/noteColor/noteOpacity 없음 + let fixture = r#"{ + "keys": { + "4key": ["Q"], + "5key": [], + "6key": [], + "8key": [] + }, + "keyPositions": { + "4key": [ + { + "dx": 777, + "dy": 130, + "width": 60, + "activeImage": "", + "inactiveImage": "", + "count": 42 + } + ], + "5key": [], + "6key": [], + "8key": [] + }, + "backgroundColor": "transparent" + }"#; + let preset: PresetFile = serde_json::from_str(fixture).unwrap(); + let positions = preset.key_positions.unwrap(); + let position = &positions["4key"][0]; + + assert_eq!(position.dx, 777.0); + assert_eq!(position.count, 42); + assert_eq!(position.height, 60.0); + assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); + assert_eq!(position.note_opacity, 90); +} diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 93838e97d..6861832a2 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -290,6 +290,7 @@ mod tests { // 한도 헬퍼를 쓰는 소스. 손목록끼리만 대조하면 백엔드에 새 한도 코드가 // 생겨도 전부 green이라, 실제 호출부를 스캔해 결합한다 const EDITOR_SOURCE: &str = include_str!("state/editor.rs"); + const EDITOR_LIMITS_SOURCE: &str = include_str!("state/editor/limits.rs"); const PLUGIN_SOURCE: &str = include_str!("state/plugin.rs"); /// 용량 목록에 넣지 않는 한도 코드와 사유. 새 한도 코드는 목록에 넣거나 @@ -347,6 +348,7 @@ mod tests { #[test] fn every_limit_helper_code_is_classified() { let mut found = limit_codes_in(EDITOR_SOURCE); + found.extend(limit_codes_in(EDITOR_LIMITS_SOURCE)); found.extend(limit_codes_in(PLUGIN_SOURCE)); found.sort(); found.dedup(); diff --git a/src-tauri/src/keyboard/manager.rs b/src-tauri/src/keyboard/manager.rs index abb66e47a..89624e926 100644 --- a/src-tauri/src/keyboard/manager.rs +++ b/src-tauri/src/keyboard/manager.rs @@ -457,693 +457,4 @@ fn compose_active_key(mode: &str, canonical: &str) -> String { } #[cfg(test)] -mod tests { - use std::collections::HashMap; - - use crate::{ - ipc::InputDeviceKind, - models::{KeySlot, SlotMatch}, - }; - - use super::{KeyboardManager, MatchOutcome, SlotEvent}; - - fn single(key: &str) -> KeySlot { - KeySlot::Single(key.to_string()) - } - - fn multi(keys: &[&str], match_mode: SlotMatch) -> KeySlot { - KeySlot::Multi { - keys: keys.iter().map(|key| (*key).to_string()).collect(), - match_mode, - } - } - - fn input( - manager: &KeyboardManager, - physical_id: Option<&str>, - device: InputDeviceKind, - labels: &[&str], - is_down: bool, - ) -> Option { - manager.match_and_register(physical_id, device, labels.iter().copied(), is_down) - } - - fn event( - canonical: &str, - slot_indices: Vec, - transition: Option, - press: bool, - ) -> SlotEvent { - SlotEvent { - canonical: canonical.to_string(), - slot_indices, - transition, - press, - can_use_physical_hold_duration: false, - } - } - - fn event_with_physical_hold( - canonical: &str, - slot_indices: Vec, - transition: Option, - press: bool, - ) -> SlotEvent { - SlotEvent { - can_use_physical_hold_duration: true, - ..event(canonical, slot_indices, transition, press) - } - } - - #[test] - fn any_slot_counts_each_alternating_fresh_press_and_transitions_at_edges() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![multi(&["Z", "B"], SlotMatch::Any)])]), - "mode", - ); - - let z_down = input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - true, - ) - .unwrap(); - assert_eq!(z_down.pressed_label.as_deref(), Some("Z")); - assert_eq!(z_down.events, vec![event("Z|B", vec![0], Some(true), true)]); - - let b_down = input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ) - .unwrap(); - assert_eq!(b_down.events, vec![event("Z|B", vec![0], None, true)]); - assert_eq!( - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - false, - ) - .unwrap() - .events, - vec![event("Z|B", vec![0], None, false)] - ); - assert_eq!( - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ) - .unwrap() - .events, - vec![event("Z|B", vec![0], None, true)] - ); - input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ); - assert_eq!( - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - false, - ) - .unwrap() - .events, - vec![event("Z|B", vec![0], Some(false), false)] - ); - - input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - true, - ); - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ); - input( - &manager, - Some("keyboard:b"), - InputDeviceKind::Keyboard, - &["B"], - false, - ); - assert_eq!( - input( - &manager, - Some("keyboard:z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ) - .unwrap() - .events, - vec![event_with_physical_hold("Z|B", vec![0], Some(false), false,)] - ); - } - - #[test] - fn all_slot_completes_and_recompletes_in_either_order() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![multi(&["LEFT CTRL", "Z"], SlotMatch::All)], - )]), - "mode", - ); - - assert_eq!( - input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], None, false)] - ); - assert_eq!( - input( - &manager, - Some("ctrl"), - InputDeviceKind::Keyboard, - &["LEFT CTRL"], - true, - ) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] - ); - assert_eq!( - input( - &manager, - Some("z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], Some(false), false)] - ); - assert_eq!( - input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) - .unwrap() - .events, - vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] - ); - assert_eq!( - input( - &manager, - Some("z"), - InputDeviceKind::Keyboard, - &["Z"], - false, - ) - .unwrap() - .events, - vec![event_with_physical_hold( - "LEFT CTRL+Z", - vec![0], - Some(false), - false, - )] - ); - } - - #[test] - fn repeat_down_and_ghost_up_are_suppressed_by_physical_identity() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("A")])]), - "mode", - ); - - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .is_some()); - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .is_none()); - assert!(input( - &manager, - Some("physical:ghost"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .is_none()); - assert!(input( - &manager, - Some("physical:empty"), - InputDeviceKind::Keyboard, - &[], - true, - ) - .is_some()); - assert!(input( - &manager, - Some("physical:empty"), - InputDeviceKind::Keyboard, - &[], - false, - ) - .is_some()); - } - - #[test] - fn overlapping_same_label_uses_physical_hold_only_when_transition_sources_match() { - let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); - - let different_source = KeyboardManager::new(mappings.clone(), "mode"); - let first_down = input( - &different_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!( - first_down.events, - vec![event("A", vec![0], Some(true), true)] - ); - assert_eq!( - input( - &different_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap() - .events, - vec![event("A", vec![0], None, true)] - ); - assert_eq!( - input( - &different_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event("A", vec![0], None, false)] - ); - assert_eq!( - input( - &different_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event("A", vec![0], Some(false), false)] - ); - - let same_source = KeyboardManager::new(mappings, "mode"); - input( - &same_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - input( - &same_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - input( - &same_source, - Some("keyboard-2:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ); - assert_eq!( - input( - &same_source, - Some("keyboard-1:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event_with_physical_hold("A", vec![0], Some(false), false,)] - ); - } - - #[test] - fn mapping_rebuild_discards_unknown_activation_source() { - let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); - let manager = KeyboardManager::new(mappings.clone(), "mode"); - input( - &manager, - Some("keyboard:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - - manager.update_mappings(mappings); - - assert_eq!( - input( - &manager, - Some("keyboard:a"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .unwrap() - .events, - vec![event("A", vec![0], Some(false), false)] - ); - } - - #[test] - fn mode_switch_and_mapping_removal_do_not_lose_later_key_up() { - let manager = KeyboardManager::new( - HashMap::from([ - ("source".to_string(), vec![single("A")]), - ("target".to_string(), vec![single("B")]), - ]), - "source", - ); - input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - assert!(manager.set_mode("target")); - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["changed-up-label"], - false, - ) - .is_some()); - assert!(manager.set_mode("source")); - assert!(manager.pressed_keys().is_empty()); - - input( - &manager, - Some("physical:a2"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - manager.update_mappings(HashMap::from([("source".to_string(), Vec::new())])); - assert!(input( - &manager, - Some("physical:a2"), - InputDeviceKind::Keyboard, - &["A"], - false, - ) - .is_some()); - manager.update_mappings(HashMap::from([("source".to_string(), vec![single("A")])])); - assert!(manager.pressed_keys().is_empty()); - } - - #[test] - fn held_unmapped_key_activates_when_switching_to_a_matching_mode() { - let manager = KeyboardManager::new( - HashMap::from([ - ("source".to_string(), vec![single("B")]), - ("target".to_string(), vec![single("A")]), - ]), - "source", - ); - let down = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert!(down.pressed_label.is_none()); - - assert!(manager.set_mode("target")); - assert_eq!(manager.pressed_keys(), vec!["A"]); - } - - #[test] - fn collect_sound_dispatch_merges_press_slots_into_single_dispatch() { - let events = vec![ - SlotEvent { - canonical: "A".to_string(), - slot_indices: vec![1], - transition: Some(true), - press: true, - can_use_physical_hold_duration: false, - }, - SlotEvent { - canonical: "A|B".to_string(), - slot_indices: vec![0, 1], - transition: None, - press: true, - can_use_physical_hold_duration: false, - }, - SlotEvent { - canonical: "C".to_string(), - slot_indices: vec![2], - transition: Some(false), - press: false, - can_use_physical_hold_duration: true, - }, - ]; - - let (canonical, indices) = super::collect_sound_dispatch(&events).unwrap(); - - assert_eq!(canonical, "A"); - assert_eq!(indices, vec![0, 1]); - - // press 이벤트가 없으면 디스패치 자체가 없음 - assert!(super::collect_sound_dispatch(&events[2..]).is_none()); - } - - #[test] - fn shared_member_fans_out_slots_but_resolves_one_physical_press() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![single("A"), multi(&["A", "B"], SlotMatch::Any)], - )]), - "mode", - ); - - let outcome = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!(outcome.pressed_label.as_deref(), Some("A")); - assert_eq!( - outcome.events, - vec![ - event("A", vec![0], Some(true), true), - event("A|B", vec![1], Some(true), true), - ] - ); - } - - #[test] - fn duplicate_canonical_slots_deduplicate_event_and_collect_indices() { - let slot = multi(&["A", "B"], SlotMatch::Any); - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![slot.clone(), slot])]), - "mode", - ); - - let outcome = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!( - outcome.events, - vec![event("A|B", vec![0, 1], Some(true), true)] - ); - } - - #[test] - fn alias_candidates_resolve_only_the_first_matching_label() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![multi(&["21", "RIGHT ALT"], SlotMatch::All)], - )]), - "mode", - ); - - let alias = input( - &manager, - Some("physical:altgr"), - InputDeviceKind::Keyboard, - &["21", "RIGHT ALT"], - true, - ) - .unwrap(); - assert_eq!(alias.pressed_label.as_deref(), Some("21")); - assert_eq!(alias.events[0].transition, None); - - let second = input( - &manager, - Some("physical:right-alt-2"), - InputDeviceKind::Keyboard, - &["RIGHT ALT"], - true, - ) - .unwrap(); - assert_eq!(second.events[0].transition, Some(true)); - } - - #[test] - fn key_up_uses_resolved_label_saved_on_key_down() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("RIGHT ALT")])]), - "mode", - ); - input( - &manager, - Some("physical:altgr"), - InputDeviceKind::Keyboard, - &["21", "RIGHT ALT"], - true, - ); - - let up = input( - &manager, - Some("physical:altgr"), - InputDeviceKind::Keyboard, - &["21"], - false, - ) - .unwrap(); - assert_eq!( - up.events, - vec![event_with_physical_hold( - "RIGHT ALT", - vec![0], - Some(false), - false, - )] - ); - } - - #[test] - fn missing_physical_id_falls_back_to_device_kind_and_primary_label() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("A")])]), - "mode", - ); - - assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_some()); - assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_none()); - assert!(input(&manager, None, InputDeviceKind::Mouse, &["A"], true,).is_some()); - } - - #[test] - fn inert_single_canonical_collision_is_not_a_contributing_slot() { - let manager = KeyboardManager::new( - HashMap::from([( - "mode".to_string(), - vec![single("A+B"), multi(&["A", "B"], SlotMatch::All)], - )]), - "mode", - ); - - let first = input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .unwrap(); - assert_eq!(first.events, vec![event("A+B", vec![1], None, false)]); - let completed = input( - &manager, - Some("physical:b"), - InputDeviceKind::Keyboard, - &["B"], - true, - ) - .unwrap(); - assert_eq!( - completed.events, - vec![event("A+B", vec![1], Some(true), true)] - ); - } - - #[test] - fn clear_active_keys_clears_physical_and_canonical_state() { - let manager = KeyboardManager::new( - HashMap::from([("mode".to_string(), vec![single("A")])]), - "mode", - ); - input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ); - manager.clear_active_keys(); - - assert!(manager.pressed_keys().is_empty()); - assert!(manager.register_key_down("mode", "A")); - assert!(manager.register_key_up("mode", "A")); - assert!(input( - &manager, - Some("physical:a"), - InputDeviceKind::Keyboard, - &["A"], - true, - ) - .is_some()); - } -} +mod tests; diff --git a/src-tauri/src/keyboard/manager/tests.rs b/src-tauri/src/keyboard/manager/tests.rs new file mode 100644 index 000000000..26dd1f3b7 --- /dev/null +++ b/src-tauri/src/keyboard/manager/tests.rs @@ -0,0 +1,688 @@ +use std::collections::HashMap; + +use crate::{ + ipc::InputDeviceKind, + models::{KeySlot, SlotMatch}, +}; + +use super::{KeyboardManager, MatchOutcome, SlotEvent}; + +fn single(key: &str) -> KeySlot { + KeySlot::Single(key.to_string()) +} + +fn multi(keys: &[&str], match_mode: SlotMatch) -> KeySlot { + KeySlot::Multi { + keys: keys.iter().map(|key| (*key).to_string()).collect(), + match_mode, + } +} + +fn input( + manager: &KeyboardManager, + physical_id: Option<&str>, + device: InputDeviceKind, + labels: &[&str], + is_down: bool, +) -> Option { + manager.match_and_register(physical_id, device, labels.iter().copied(), is_down) +} + +fn event( + canonical: &str, + slot_indices: Vec, + transition: Option, + press: bool, +) -> SlotEvent { + SlotEvent { + canonical: canonical.to_string(), + slot_indices, + transition, + press, + can_use_physical_hold_duration: false, + } +} + +fn event_with_physical_hold( + canonical: &str, + slot_indices: Vec, + transition: Option, + press: bool, +) -> SlotEvent { + SlotEvent { + can_use_physical_hold_duration: true, + ..event(canonical, slot_indices, transition, press) + } +} + +#[test] +fn any_slot_counts_each_alternating_fresh_press_and_transitions_at_edges() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![multi(&["Z", "B"], SlotMatch::Any)])]), + "mode", + ); + + let z_down = input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + true, + ) + .unwrap(); + assert_eq!(z_down.pressed_label.as_deref(), Some("Z")); + assert_eq!(z_down.events, vec![event("Z|B", vec![0], Some(true), true)]); + + let b_down = input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ) + .unwrap(); + assert_eq!(b_down.events, vec![event("Z|B", vec![0], None, true)]); + assert_eq!( + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + false, + ) + .unwrap() + .events, + vec![event("Z|B", vec![0], None, false)] + ); + assert_eq!( + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ) + .unwrap() + .events, + vec![event("Z|B", vec![0], None, true)] + ); + input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ); + assert_eq!( + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + false, + ) + .unwrap() + .events, + vec![event("Z|B", vec![0], Some(false), false)] + ); + + input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + true, + ); + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ); + input( + &manager, + Some("keyboard:b"), + InputDeviceKind::Keyboard, + &["B"], + false, + ); + assert_eq!( + input( + &manager, + Some("keyboard:z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ) + .unwrap() + .events, + vec![event_with_physical_hold("Z|B", vec![0], Some(false), false,)] + ); +} + +#[test] +fn all_slot_completes_and_recompletes_in_either_order() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![multi(&["LEFT CTRL", "Z"], SlotMatch::All)], + )]), + "mode", + ); + + assert_eq!( + input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], None, false)] + ); + assert_eq!( + input( + &manager, + Some("ctrl"), + InputDeviceKind::Keyboard, + &["LEFT CTRL"], + true, + ) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] + ); + assert_eq!( + input( + &manager, + Some("z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], Some(false), false)] + ); + assert_eq!( + input(&manager, Some("z"), InputDeviceKind::Keyboard, &["Z"], true,) + .unwrap() + .events, + vec![event("LEFT CTRL+Z", vec![0], Some(true), true)] + ); + assert_eq!( + input( + &manager, + Some("z"), + InputDeviceKind::Keyboard, + &["Z"], + false, + ) + .unwrap() + .events, + vec![event_with_physical_hold( + "LEFT CTRL+Z", + vec![0], + Some(false), + false, + )] + ); +} + +#[test] +fn repeat_down_and_ghost_up_are_suppressed_by_physical_identity() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("A")])]), + "mode", + ); + + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .is_some()); + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .is_none()); + assert!(input( + &manager, + Some("physical:ghost"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .is_none()); + assert!(input( + &manager, + Some("physical:empty"), + InputDeviceKind::Keyboard, + &[], + true, + ) + .is_some()); + assert!(input( + &manager, + Some("physical:empty"), + InputDeviceKind::Keyboard, + &[], + false, + ) + .is_some()); +} + +#[test] +fn overlapping_same_label_uses_physical_hold_only_when_transition_sources_match() { + let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); + + let different_source = KeyboardManager::new(mappings.clone(), "mode"); + let first_down = input( + &different_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!( + first_down.events, + vec![event("A", vec![0], Some(true), true)] + ); + assert_eq!( + input( + &different_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap() + .events, + vec![event("A", vec![0], None, true)] + ); + assert_eq!( + input( + &different_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event("A", vec![0], None, false)] + ); + assert_eq!( + input( + &different_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event("A", vec![0], Some(false), false)] + ); + + let same_source = KeyboardManager::new(mappings, "mode"); + input( + &same_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + input( + &same_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + input( + &same_source, + Some("keyboard-2:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ); + assert_eq!( + input( + &same_source, + Some("keyboard-1:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event_with_physical_hold("A", vec![0], Some(false), false,)] + ); +} + +#[test] +fn mapping_rebuild_discards_unknown_activation_source() { + let mappings = HashMap::from([("mode".to_string(), vec![single("A")])]); + let manager = KeyboardManager::new(mappings.clone(), "mode"); + input( + &manager, + Some("keyboard:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + + manager.update_mappings(mappings); + + assert_eq!( + input( + &manager, + Some("keyboard:a"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .unwrap() + .events, + vec![event("A", vec![0], Some(false), false)] + ); +} + +#[test] +fn mode_switch_and_mapping_removal_do_not_lose_later_key_up() { + let manager = KeyboardManager::new( + HashMap::from([ + ("source".to_string(), vec![single("A")]), + ("target".to_string(), vec![single("B")]), + ]), + "source", + ); + input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + assert!(manager.set_mode("target")); + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["changed-up-label"], + false, + ) + .is_some()); + assert!(manager.set_mode("source")); + assert!(manager.pressed_keys().is_empty()); + + input( + &manager, + Some("physical:a2"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + manager.update_mappings(HashMap::from([("source".to_string(), Vec::new())])); + assert!(input( + &manager, + Some("physical:a2"), + InputDeviceKind::Keyboard, + &["A"], + false, + ) + .is_some()); + manager.update_mappings(HashMap::from([("source".to_string(), vec![single("A")])])); + assert!(manager.pressed_keys().is_empty()); +} + +#[test] +fn held_unmapped_key_activates_when_switching_to_a_matching_mode() { + let manager = KeyboardManager::new( + HashMap::from([ + ("source".to_string(), vec![single("B")]), + ("target".to_string(), vec![single("A")]), + ]), + "source", + ); + let down = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert!(down.pressed_label.is_none()); + + assert!(manager.set_mode("target")); + assert_eq!(manager.pressed_keys(), vec!["A"]); +} + +#[test] +fn collect_sound_dispatch_merges_press_slots_into_single_dispatch() { + let events = vec![ + SlotEvent { + canonical: "A".to_string(), + slot_indices: vec![1], + transition: Some(true), + press: true, + can_use_physical_hold_duration: false, + }, + SlotEvent { + canonical: "A|B".to_string(), + slot_indices: vec![0, 1], + transition: None, + press: true, + can_use_physical_hold_duration: false, + }, + SlotEvent { + canonical: "C".to_string(), + slot_indices: vec![2], + transition: Some(false), + press: false, + can_use_physical_hold_duration: true, + }, + ]; + + let (canonical, indices) = super::collect_sound_dispatch(&events).unwrap(); + + assert_eq!(canonical, "A"); + assert_eq!(indices, vec![0, 1]); + + // press 이벤트가 없으면 디스패치 자체가 없음 + assert!(super::collect_sound_dispatch(&events[2..]).is_none()); +} + +#[test] +fn shared_member_fans_out_slots_but_resolves_one_physical_press() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![single("A"), multi(&["A", "B"], SlotMatch::Any)], + )]), + "mode", + ); + + let outcome = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!(outcome.pressed_label.as_deref(), Some("A")); + assert_eq!( + outcome.events, + vec![ + event("A", vec![0], Some(true), true), + event("A|B", vec![1], Some(true), true), + ] + ); +} + +#[test] +fn duplicate_canonical_slots_deduplicate_event_and_collect_indices() { + let slot = multi(&["A", "B"], SlotMatch::Any); + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![slot.clone(), slot])]), + "mode", + ); + + let outcome = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!( + outcome.events, + vec![event("A|B", vec![0, 1], Some(true), true)] + ); +} + +#[test] +fn alias_candidates_resolve_only_the_first_matching_label() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![multi(&["21", "RIGHT ALT"], SlotMatch::All)], + )]), + "mode", + ); + + let alias = input( + &manager, + Some("physical:altgr"), + InputDeviceKind::Keyboard, + &["21", "RIGHT ALT"], + true, + ) + .unwrap(); + assert_eq!(alias.pressed_label.as_deref(), Some("21")); + assert_eq!(alias.events[0].transition, None); + + let second = input( + &manager, + Some("physical:right-alt-2"), + InputDeviceKind::Keyboard, + &["RIGHT ALT"], + true, + ) + .unwrap(); + assert_eq!(second.events[0].transition, Some(true)); +} + +#[test] +fn key_up_uses_resolved_label_saved_on_key_down() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("RIGHT ALT")])]), + "mode", + ); + input( + &manager, + Some("physical:altgr"), + InputDeviceKind::Keyboard, + &["21", "RIGHT ALT"], + true, + ); + + let up = input( + &manager, + Some("physical:altgr"), + InputDeviceKind::Keyboard, + &["21"], + false, + ) + .unwrap(); + assert_eq!( + up.events, + vec![event_with_physical_hold( + "RIGHT ALT", + vec![0], + Some(false), + false, + )] + ); +} + +#[test] +fn missing_physical_id_falls_back_to_device_kind_and_primary_label() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("A")])]), + "mode", + ); + + assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_some()); + assert!(input(&manager, None, InputDeviceKind::Keyboard, &["A"], true,).is_none()); + assert!(input(&manager, None, InputDeviceKind::Mouse, &["A"], true,).is_some()); +} + +#[test] +fn inert_single_canonical_collision_is_not_a_contributing_slot() { + let manager = KeyboardManager::new( + HashMap::from([( + "mode".to_string(), + vec![single("A+B"), multi(&["A", "B"], SlotMatch::All)], + )]), + "mode", + ); + + let first = input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .unwrap(); + assert_eq!(first.events, vec![event("A+B", vec![1], None, false)]); + let completed = input( + &manager, + Some("physical:b"), + InputDeviceKind::Keyboard, + &["B"], + true, + ) + .unwrap(); + assert_eq!( + completed.events, + vec![event("A+B", vec![1], Some(true), true)] + ); +} + +#[test] +fn clear_active_keys_clears_physical_and_canonical_state() { + let manager = KeyboardManager::new( + HashMap::from([("mode".to_string(), vec![single("A")])]), + "mode", + ); + input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ); + manager.clear_active_keys(); + + assert!(manager.pressed_keys().is_empty()); + assert!(manager.register_key_down("mode", "A")); + assert!(manager.register_key_up("mode", "A")); + assert!(input( + &manager, + Some("physical:a"), + InputDeviceKind::Keyboard, + &["A"], + true, + ) + .is_some()); +} diff --git a/src-tauri/src/models/bootstrap.rs b/src-tauri/src/models/bootstrap.rs new file mode 100644 index 000000000..8d7887dde --- /dev/null +++ b/src-tauri/src/models/bootstrap.rs @@ -0,0 +1,42 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapOverlayState { + pub visible: bool, + pub locked: bool, + pub anchor: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapPayload { + pub settings: SettingsState, + pub defaults: DefaultsPayload, + pub keys: KeyMappings, + pub positions: KeyPositions, + pub stat_positions: StatPositions, + pub graph_positions: GraphPositions, + pub knob_positions: KnobPositions, + pub custom_tabs: Vec, + pub tab_order: Vec, + pub bar_count: u8, + pub selected_key_type: String, + pub current_mode: String, + pub active_keys: Vec, + pub overlay: BootstrapOverlayState, + pub key_counters: KeyCounters, + pub key_counters_session_id: String, + pub key_counters_revision: u64, + pub layer_groups: LayerGroups, + pub tab_note_overrides: TabNoteOverrides, + pub tab_css_overrides: TabCssOverrides, + pub editor_revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DefaultsPayload { + pub settings: SettingsState, + pub counter_settings: KeyCounterSettings, +} diff --git a/src-tauri/src/models/counter.rs b/src-tauri/src/models/counter.rs new file mode 100644 index 000000000..b73c33a4c --- /dev/null +++ b/src-tauri/src/models/counter.rs @@ -0,0 +1,377 @@ +use serde::{Deserialize, Serialize}; + +use super::GradientSpec; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum KeyCounterPlacement { + #[default] + Inside, + Outside, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum KeyCounterAlign { + Top, + // align 필드 부재 시에도 새 기본 배치와 일치하도록 serde 기본값 겸용 + #[default] + Bottom, + Left, + Right, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum KeyCounterAlignMode { + #[default] + Center, + Between, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyCounterColor { + pub idle: String, + pub active: String, +} + +impl Default for KeyCounterColor { + fn default() -> Self { + Self { + // 렌더러 기본 키 텍스트 색과 일치 (utils/core/elementDefaults.ts) + idle: "rgba(237, 238, 242, 0.78)".to_string(), + active: "rgba(20, 20, 24, 0.9)".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CounterAnimationSource { + Builtin, + User, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CounterAnimationPreset { + pub id: String, + pub name: String, + #[serde(rename = "source")] + pub source: CounterAnimationSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label_key: Option, + pub bezier: [f64; 4], + pub scale: f64, + pub duration_ms: u32, +} + +impl CounterAnimationPreset { + pub fn normalize(&mut self) { + let mut animation = KeyCounterAnimationSettings { + enabled: true, + preset_id: None, + bezier: self.bezier, + scale: self.scale, + duration_ms: self.duration_ms, + }; + animation.normalize(); + self.bezier = animation.bezier; + self.scale = animation.scale; + self.duration_ms = animation.duration_ms; + self.name = self.name.trim().to_string(); + } + + pub fn is_valid_user_entry(&self) -> bool { + self.source == CounterAnimationSource::User + && !self.id.trim().is_empty() + && !self.name.trim().is_empty() + } +} + +fn builtin_counter_animation_presets() -> Vec { + vec![ + CounterAnimationPreset { + id: "builtin-ease-out".to_string(), + name: "Default".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.25, 0.46, 0.45, 0.94], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-linear".to_string(), + name: "Linear".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.0, 0.0, 1.0, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-ease-out-css".to_string(), + name: "Ease Out".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.0, 0.0, 0.58, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-ease-in".to_string(), + name: "Ease In".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.42, 0.0, 1.0, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-ease-in-out".to_string(), + name: "Ease In-Out".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.42, 0.0, 0.58, 1.0], + scale: 1.1, + duration_ms: 300, + }, + CounterAnimationPreset { + id: "builtin-overshoot".to_string(), + name: "Overshoot".to_string(), + source: CounterAnimationSource::Builtin, + label_key: None, + bezier: [0.34, 1.56, 0.64, 1.0], + scale: 1.15, + duration_ms: 360, + }, + ] +} + +pub fn default_counter_animation_preset_id() -> &'static str { + "builtin-ease-out" +} + +pub fn default_counter_animation_builtin_presets() -> Vec { + builtin_counter_animation_presets() +} + +pub fn find_builtin_counter_animation_preset_by_id(id: &str) -> Option { + builtin_counter_animation_presets() + .into_iter() + .find(|preset| preset.id == id) +} + +pub fn infer_builtin_counter_animation_preset_id( + bezier: [f64; 4], + scale: f64, + duration_ms: u32, +) -> Option { + const BEZIER_EPSILON: f64 = 0.001; + const SCALE_EPSILON: f64 = 0.001; + + builtin_counter_animation_presets() + .into_iter() + .find(|preset| { + let bezier_matches = preset + .bezier + .iter() + .zip(bezier.iter()) + .all(|(a, b)| (*a - *b).abs() <= BEZIER_EPSILON); + let scale_matches = (preset.scale - scale).abs() <= SCALE_EPSILON; + let duration_matches = preset.duration_ms == duration_ms; + bezier_matches && scale_matches && duration_matches + }) + .map(|preset| preset.id) +} + +pub fn normalize_user_counter_animation_presets( + presets: Vec, +) -> Vec { + let mut seen_ids = std::collections::HashSet::new(); + let mut normalized: Vec = presets + .into_iter() + .filter_map(|mut preset| { + preset.normalize(); + if !preset.is_valid_user_entry() { + return None; + } + if !seen_ids.insert(preset.id.clone()) { + return None; + } + preset.source = CounterAnimationSource::User; + preset.label_key = None; + Some(preset) + }) + .collect(); + + normalized.sort_by_key(|a| a.name.to_lowercase()); + normalized +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyCounterAnimationSettings { + #[serde(default = "default_counter_animation_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preset_id: Option, + #[serde(default = "default_counter_animation_bezier")] + pub bezier: [f64; 4], + #[serde(default = "default_counter_animation_scale")] + pub scale: f64, + #[serde(default = "default_counter_animation_duration_ms")] + pub duration_ms: u32, +} + +impl Default for KeyCounterAnimationSettings { + fn default() -> Self { + Self { + enabled: default_counter_animation_enabled(), + preset_id: Some(default_counter_animation_preset_id().to_string()), + bezier: default_counter_animation_bezier(), + scale: default_counter_animation_scale(), + duration_ms: default_counter_animation_duration_ms(), + } + } +} + +impl KeyCounterAnimationSettings { + pub fn normalize(&mut self) { + let fallback = default_counter_animation_bezier(); + for i in [0, 2] { + let value = self.bezier[i]; + self.bezier[i] = if value.is_finite() { + value.clamp(0.0, 1.0) + } else { + fallback[i] + }; + } + for i in [1, 3] { + let value = self.bezier[i]; + self.bezier[i] = if value.is_finite() { + value.clamp(-2.0, 2.0) + } else { + fallback[i] + }; + } + + self.scale = if self.scale.is_finite() { + self.scale + } else { + default_counter_animation_scale() + }; + self.duration_ms = self.duration_ms.clamp(1, 5000); + + self.preset_id = self + .preset_id + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if self.preset_id.is_none() { + self.preset_id = infer_builtin_counter_animation_preset_id( + self.bezier, + self.scale, + self.duration_ms, + ); + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyCounterSettings { + #[serde(default = "default_counter_enabled")] + pub enabled: bool, + #[serde(default)] + pub placement: KeyCounterPlacement, + #[serde(default)] + pub align: KeyCounterAlign, + #[serde(default)] + pub align_mode: KeyCounterAlignMode, + #[serde(default)] + pub fill: KeyCounterColor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_idle_gradient: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_active_gradient: Option, + #[serde(default = "default_gap")] + pub gap: u32, + #[serde(default = "default_counter_font_size")] + pub font_size: u32, + #[serde(default = "default_counter_font_weight")] + pub font_weight: u32, + /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 + /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_bold: Option, + /// 카운터 글꼴 패밀리 (커스텀 폰트 이름) + #[serde(default)] + pub font_family: Option, + #[serde(default)] + pub font_italic: bool, + #[serde(default)] + pub font_underline: bool, + #[serde(default)] + pub font_strikethrough: bool, + #[serde(default)] + pub animation: KeyCounterAnimationSettings, +} + +impl Default for KeyCounterSettings { + fn default() -> Self { + Self { + enabled: true, + placement: KeyCounterPlacement::Inside, + align: KeyCounterAlign::Bottom, + align_mode: KeyCounterAlignMode::Center, + fill: KeyCounterColor::default(), + fill_idle_gradient: None, + fill_active_gradient: None, + gap: default_gap(), + font_size: default_counter_font_size(), + font_weight: default_counter_font_weight(), + font_bold: Some(false), + font_family: None, + font_italic: false, + font_underline: false, + font_strikethrough: false, + animation: KeyCounterAnimationSettings::default(), + } + } +} + +fn default_counter_animation_enabled() -> bool { + false +} + +fn default_counter_animation_bezier() -> [f64; 4] { + [0.25, 0.46, 0.45, 0.94] +} + +fn default_counter_animation_scale() -> f64 { + 1.1 +} + +fn default_counter_animation_duration_ms() -> u32 { + 300 +} + +pub(super) fn default_gap() -> u32 { + 4 +} +pub(super) fn default_counter_font_size() -> u32 { + 11 +} +pub(super) fn default_counter_font_weight() -> u32 { + 500 +} + +fn default_counter_enabled() -> bool { + true +} diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 67b78e346..5761d15f2 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -884,619 +884,4 @@ pub struct EditorTransactionResult { } #[cfg(test)] -mod tests { - use super::*; - - // TS canonical 배열과 공유하는 property 태그 fixture - const PROPERTY_TAG_FIXTURE: &str = - include_str!("../../../tests/fixtures/editor-property-tags.json"); - - fn paint(color: &str) -> EditorPaintDescriptorV1 { - EditorPaintDescriptorV1 { - color: color.to_string(), - gradient: None, - } - } - - // 75개 variant 전수: (patch, property 태그, value wire) 고정 표본 - fn property_patch_samples() -> Vec<( - EditorElementPropertyPatchV1, - &'static str, - serde_json::Value, - )> { - use serde_json::json; - use EditorElementPropertyPatchV1 as P; - vec![ - (P::Hidden(true), "hidden", json!(true)), - ( - P::LayerName(Some("layer".to_string())), - "layerName", - json!("layer"), - ), - (P::GraphType(GraphType::Bar), "graphType", json!("bar")), - ( - P::GraphColor("#123456".to_string()), - "graphColor", - json!("#123456"), - ), - (P::ShowAvgLine(false), "showAvgLine", json!(false)), - ( - P::GraphAnimationEnabled(true), - "graphAnimationEnabled", - json!(true), - ), - (P::GraphSpeed(7), "graphSpeed", json!(7)), - (P::Reverse(true), "reverse", json!(true)), - (P::Sensitivity(1.5), "sensitivity", json!(1.5)), - (P::AxisId("axis-1".to_string()), "axisId", json!("axis-1")), - (P::UseInlineStyles(true), "useInlineStyles", json!(true)), - (P::FontWeight(700), "fontWeight", json!(700)), - (P::FontBold(true), "fontBold", json!(true)), - (P::FontItalic(true), "fontItalic", json!(true)), - (P::FontUnderline(false), "fontUnderline", json!(false)), - (P::FontStrikethrough(true), "fontStrikethrough", json!(true)), - ( - P::FontFamily("font".to_string()), - "fontFamily", - json!("font"), - ), - ( - P::DisplayText("text".to_string()), - "displayText", - json!("text"), - ), - ( - P::ClassName("class".to_string()), - "className", - json!("class"), - ), - ( - P::FontPaint(paint("#111111")), - "fontPaint", - json!({ "color": "#111111", "gradient": null }), - ), - ( - P::ActiveFontPaint(paint("#222222")), - "activeFontPaint", - json!({ "color": "#222222", "gradient": null }), - ), - ( - P::Shadow(EditorShadowLeafPatchV1::Color( - "rgba(0, 0, 0, 0.2)".to_string(), - )), - "shadow", - json!({ "leaf": "color", "value": "rgba(0, 0, 0, 0.2)" }), - ), - ( - P::ActiveShadow(EditorShadowLeafPatchV1::OffsetY(-3.25)), - "activeShadow", - json!({ "leaf": "offsetY", "value": -3.25 }), - ), - (P::ShadowEnabled(false), "shadowEnabled", json!(false)), - ( - P::BackgroundPaint(paint("#010101")), - "backgroundPaint", - json!({ "color": "#010101", "gradient": null }), - ), - ( - P::ActiveBackgroundPaint(paint("#020202")), - "activeBackgroundPaint", - json!({ "color": "#020202", "gradient": null }), - ), - ( - P::BorderPaint(paint("#030303")), - "borderPaint", - json!({ "color": "#030303", "gradient": null }), - ), - ( - P::ActiveBorderPaint(paint("#040404")), - "activeBorderPaint", - json!({ "color": "#040404", "gradient": null }), - ), - (P::BorderWidth(2.5), "borderWidth", json!(2.5)), - (P::BorderRadius(8.5), "borderRadius", json!(8.5)), - (P::FontSize(12.5), "fontSize", json!(12.5)), - ( - P::InactiveImage("idle.png".to_string()), - "inactiveImage", - json!("idle.png"), - ), - ( - P::ActiveImage("active.png".to_string()), - "activeImage", - json!("active.png"), - ), - (P::IdleTransparent(true), "idleTransparent", json!(true)), - ( - P::ActiveTransparent(false), - "activeTransparent", - json!(false), - ), - ( - P::IdleImageFit(ImageFit::Contain), - "idleImageFit", - json!("contain"), - ), - ( - P::ActiveImageFit(ImageFit::Fill), - "activeImageFit", - json!("fill"), - ), - ( - P::ImageMode(ImageMode::Overlay), - "imageMode", - json!("overlay"), - ), - ( - P::IdleImageTransform(Some(ImageTransformLeafPatchV1::Rotation(-45.5))), - "idleImageTransform", - json!({ "leaf": "rotation", "value": -45.5 }), - ), - ( - P::ActiveImageTransform(None), - "activeImageTransform", - serde_json::Value::Null, - ), - ( - P::SoundPath("sound.wav".to_string()), - "soundPath", - json!("sound.wav"), - ), - (P::SoundEnabled(true), "soundEnabled", json!(true)), - (P::SoundVolume(80.5), "soundVolume", json!(80.5)), - (P::CounterEnabled(true), "counterEnabled", json!(true)), - ( - P::CounterAnimationEnabled(false), - "counterAnimationEnabled", - json!(false), - ), - ( - P::CounterPlacement(KeyCounterPlacement::Outside), - "counterPlacement", - json!("outside"), - ), - ( - P::CounterAlign(KeyCounterAlign::Top), - "counterAlign", - json!("top"), - ), - ( - P::CounterAlignMode(KeyCounterAlignMode::Between), - "counterAlignMode", - json!("between"), - ), - (P::CounterGap(4), "counterGap", json!(4)), - (P::CounterFontSize(16), "counterFontSize", json!(16)), - (P::CounterFontWeight(500), "counterFontWeight", json!(500)), - (P::CounterFontBold(true), "counterFontBold", json!(true)), - (P::CounterFontItalic(true), "counterFontItalic", json!(true)), - ( - P::CounterFontUnderline(false), - "counterFontUnderline", - json!(false), - ), - ( - P::CounterFontStrikethrough(true), - "counterFontStrikethrough", - json!(true), - ), - ( - P::CounterFontFamily("counter-font".to_string()), - "counterFontFamily", - json!("counter-font"), - ), - ( - P::CounterFillIdle(EditorCounterFillIntentV1::Solid( - EditorCounterFillSolidIntentV1 { - color: "#050505".to_string(), - }, - )), - "counterFillIdle", - json!({ "color": "#050505" }), - ), - ( - P::CounterFillActive(EditorCounterFillIntentV1::Solid( - EditorCounterFillSolidIntentV1 { - color: "#060606".to_string(), - }, - )), - "counterFillActive", - json!({ "color": "#060606" }), - ), - ( - P::CounterAnimationPreset(EditorCounterAnimationPresetIntentV1 { - preset_id: "preset".to_string(), - apply_preset_id: None, - bezier: None, - scale: None, - duration_ms: None, - }), - "counterAnimationPreset", - json!({ "presetId": "preset" }), - ), - (P::StatType(StatType::Total), "statType", json!("total")), - (P::NoteEffectEnabled(true), "noteEffectEnabled", json!(true)), - (P::NoteGlowEnabled(false), "noteGlowEnabled", json!(false)), - (P::NoteGlowSyncPaint(true), "noteGlowSyncPaint", json!(true)), - (P::NoteGlowSize(6.5), "noteGlowSize", json!(6.5)), - ( - P::NotePaint(EditorNotePaintIntentV1::Opacity( - EditorNotePaintOpacityIntentV1 { opacity: 40 }, - )), - "notePaint", - json!({ "opacity": 40 }), - ), - ( - P::NoteGlowPaint(EditorNotePaintIntentV1::Opacity( - EditorNotePaintOpacityIntentV1 { opacity: 60 }, - )), - "noteGlowPaint", - json!({ "opacity": 60 }), - ), - ( - P::NoteBorderPaint(EditorNoteBorderPaintV1 { - color: "#0a0b0c".to_string(), - opacity: 30, - gradient: None, - }), - "noteBorderPaint", - json!({ "color": "#0a0b0c", "opacity": 30 }), - ), - (P::NoteOffsetX(Some(11.5)), "noteOffsetX", json!(11.5)), - (P::NoteOffsetY(Some(-12.5)), "noteOffsetY", json!(-12.5)), - (P::NoteWidth(Some(13.5)), "noteWidth", json!(13.5)), - (P::NoteBorderWidth(1.5), "noteBorderWidth", json!(1.5)), - (P::NoteBorderRadius(9.5), "noteBorderRadius", json!(9.5)), - ( - P::NoteAutoYCorrection(true), - "noteAutoYCorrection", - json!(true), - ), - ( - P::NoteAlignment(NoteAlignment::Right), - "noteAlignment", - json!("right"), - ), - ( - P::NoteBorderSide(EditorNoteBorderSideV1::Vertical), - "noteBorderSide", - json!("vertical"), - ), - ] - } - - #[test] - fn property_patch_wire_pins_all_tag_value_pairs_and_roundtrips() { - let samples = property_patch_samples(); - assert_eq!(samples.len(), 75); - for (patch, tag, value) in samples { - let wire = serde_json::to_value(&patch).unwrap(); - assert_eq!( - wire, - serde_json::json!({ "property": tag, "value": value }), - "wire mismatch for {tag}" - ); - let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(wire).unwrap(); - assert_eq!(decoded, patch, "roundtrip mismatch for {tag}"); - } - } - - #[test] - fn expanded_paint_values_keep_existing_property_tags_and_exact_keys() { - let gradient = serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }); - let cases = [ - serde_json::json!({ - "property": "noteBorderPaint", - "value": { "color": "#112233", "opacity": 80 } - }), - serde_json::json!({ - "property": "noteBorderPaint", - "value": { "color": "#112233", "opacity": 80, "gradient": null } - }), - serde_json::json!({ - "property": "noteBorderPaint", - "value": { - "color": "#112233", - "opacity": 80, - "gradient": gradient.clone() - } - }), - serde_json::json!({ - "property": "notePaint", - "value": { - "color": "#112233", - "opacity": 80, - "gradient": null - } - }), - serde_json::json!({ - "property": "noteGlowPaint", - "value": { - "color": { - "type": "gradient", - "top": "#112233", - "bottom": "#445566" - }, - "opacity": 80, - "gradient": gradient - } - }), - ]; - - for wire in cases { - let patch: EditorElementPropertyPatchV1 = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!( - serde_json::to_value(&patch).unwrap()["property"], - wire["property"] - ); - } - - for wire in [ - serde_json::json!({ - "property": "noteBorderPaint", - "value": { "color": "#112233", "opacity": 80, "extra": true } - }), - serde_json::json!({ - "property": "notePaint", - "value": { "color": "#112233", "opacity": 80 } - }), - serde_json::json!({ - "property": "noteGlowPaint", - "value": { - "color": "#112233", - "opacity": 80, - "gradient": null, - "extra": true - } - }), - ] { - assert!(serde_json::from_value::(wire).is_err()); - } - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct PropertyTagFixture { - version: u16, - properties: Vec, - } - - fn property_tag_fixture() -> PropertyTagFixture { - serde_json::from_str(PROPERTY_TAG_FIXTURE).unwrap() - } - - #[test] - fn property_patch_tags_match_shared_fixture() { - let fixture = property_tag_fixture(); - // 양방향 anchor: Rust 상수만 승격되는 사고도 fixture 대조로 잡는다 - assert_eq!(fixture.version, EDITOR_OPS_VERSION); - let tags: Vec = property_patch_samples() - .iter() - .map(|(_, tag, _)| (*tag).to_string()) - .collect(); - assert_eq!(tags, fixture.properties); - } - - // serde가 알 수 없는 variant 오류에 나열하는 기대 목록에서 enum의 - // wire 태그 전수를 기계적으로 추출 - 수기 목록 없이 variant 추가를 감지 - fn wire_property_tags_from_serde() -> std::collections::BTreeSet { - let message = serde_json::from_value::(serde_json::json!({ - "property": "__unknown__", - "value": null, - })) - .unwrap_err() - .to_string(); - let (_, listed) = message - .split_once("expected one of ") - .expect("serde unknown-variant error must enumerate every variant"); - let tags: std::collections::BTreeSet = listed - .split(", ") - .map(|tag| tag.trim().trim_matches('`').to_string()) - .collect(); - assert!(!tags.is_empty()); - tags - } - - #[test] - fn sample_and_fixture_tags_cover_every_enum_variant() { - let enum_tags = wire_property_tags_from_serde(); - let sample_tags: std::collections::BTreeSet = property_patch_samples() - .iter() - .map(|(_, tag, _)| (*tag).to_string()) - .collect(); - let fixture_tags: std::collections::BTreeSet = - property_tag_fixture().properties.into_iter().collect(); - // variant 신규 추가 시 표본·fixture 갱신 누락을 단방향 드리프트 없이 차단 - assert_eq!(sample_tags, enum_tags); - assert_eq!(fixture_tags, enum_tags); - } - - #[test] - fn property_patch_rejects_untagged_extra_unknown_and_missing_fields() { - use serde_json::json; - let reject = |value: serde_json::Value| { - assert!( - serde_json::from_value::(value.clone()).is_err(), - "expected rejection: {value}" - ); - }; - // 옛 one-key 형식 - reject(json!({ "hidden": true })); - reject(json!({ "graphType": "bar" })); - // outer 추가 필드 - reject(json!({ "property": "hidden", "value": true, "extra": 1 })); - // 알 수 없는 property - reject(json!({ "property": "unknown", "value": 1 })); - // property 누락 - reject(json!({ "value": true })); - // 원시값 payload의 value 누락 - reject(json!({ "property": "hidden" })); - // 잘못된 값 타입 - reject(json!({ "property": "hidden", "value": "true" })); - // 중복 키 - for duplicated in [ - r#"{ "property": "hidden", "property": "hidden", "value": true }"#, - r#"{ "property": "hidden", "value": true, "value": false }"#, - ] { - assert!( - serde_json::from_str::(duplicated).is_err(), - "expected duplicate key rejection: {duplicated}" - ); - } - } - - #[test] - fn nullable_variants_require_an_explicit_value_key() { - use serde_json::json; - use EditorElementPropertyPatchV1 as P; - let cases = [ - ("layerName", P::LayerName(None)), - ("noteOffsetX", P::NoteOffsetX(None)), - ("noteOffsetY", P::NoteOffsetY(None)), - ("noteWidth", P::NoteWidth(None)), - ("idleImageTransform", P::IdleImageTransform(None)), - ("activeImageTransform", P::ActiveImageTransform(None)), - ]; - for (tag, expected_null) in cases { - // value 키 자체가 없으면 None으로 통과하지 않고 거부 - assert!( - serde_json::from_value::(json!({ "property": tag })) - .is_err(), - "expected missing value rejection for {tag}" - ); - let decoded: EditorElementPropertyPatchV1 = - serde_json::from_value(json!({ "property": tag, "value": null })).unwrap(); - assert_eq!(decoded, expected_null, "explicit null mismatch for {tag}"); - } - let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(json!({ - "property": "layerName", - "value": "named" - })) - .unwrap(); - assert_eq!(decoded, P::LayerName(Some("named".to_string()))); - } - - #[test] - fn shadow_leaf_wire_requires_leaf_tag_and_exact_fields() { - use serde_json::json; - let decoded: EditorShadowLeafPatchV1 = - serde_json::from_value(json!({ "leaf": "blur", "value": 4.5 })).unwrap(); - assert_eq!(decoded, EditorShadowLeafPatchV1::Blur(4.5)); - for invalid in [ - // 옛 one-key 형식 - json!({ "blur": 4.5 }), - // leaf 누락 - json!({ "value": 4.5 }), - // leaf 오타 - json!({ "leaf": "blr", "value": 4.5 }), - // value 누락 - json!({ "leaf": "blur" }), - // 추가 필드 - json!({ "leaf": "blur", "value": 4.5, "extra": 1 }), - // 잘못된 값 타입 - json!({ "leaf": "offsetX", "value": "1" }), - ] { - assert!( - serde_json::from_value::(invalid.clone()).is_err(), - "expected shadow leaf rejection: {invalid}" - ); - } - } - - #[test] - fn image_transform_leaf_wire_requires_leaf_tag_and_exact_fields() { - use serde_json::json; - for (wire, expected) in [ - ( - json!({ "leaf": "offsetX", "value": -10.5 }), - ImageTransformLeafPatchV1::OffsetX(-10.5), - ), - ( - json!({ "leaf": "offsetY", "value": 20.5 }), - ImageTransformLeafPatchV1::OffsetY(20.5), - ), - ( - json!({ "leaf": "rotation", "value": 45.0 }), - ImageTransformLeafPatchV1::Rotation(45.0), - ), - ( - json!({ "leaf": "scale", "value": 1.25 }), - ImageTransformLeafPatchV1::Scale(1.25), - ), - ] { - let decoded: ImageTransformLeafPatchV1 = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(decoded, expected); - assert_eq!(serde_json::to_value(decoded).unwrap(), wire); - } - for invalid in [ - json!({ "scale": 1.0 }), - json!({ "value": 1.0 }), - json!({ "leaf": "zoom", "value": 1.0 }), - json!({ "leaf": "scale" }), - json!({ "leaf": "scale", "value": 1.0, "extra": true }), - json!({ "leaf": "offsetX", "value": "1" }), - ] { - assert!( - serde_json::from_value::(invalid.clone()).is_err(), - "expected image transform leaf rejection: {invalid}" - ); - } - } - - #[test] - fn editor_request_detects_every_direct_key_mapping_mutation() { - let request = - |changes: Option, ops: Option>| EditorCommitRequest { - base_revision: 0, - mutation_id: uuid::Uuid::new_v4().to_string(), - multi_key: false, - gesture_id: None, - gesture_ids: Vec::new(), - changes, - ops_version: ops.as_ref().map(|_| EDITOR_OPS_VERSION), - ops, - }; - - let key_patch = EditorPatchV1 { - keys: Some(KeyMappings::new()), - ..EditorPatchV1::default() - }; - assert!(request(Some(key_patch), None).may_change_keys()); - assert!(request( - None, - Some(vec![EditorOpV1::SetKeySlot { - id: uuid::Uuid::new_v4().to_string(), - slot: EditorFrozenKeySlotV1::Single("A".to_string()), - }]), - ) - .may_change_keys()); - assert!(request( - None, - Some(vec![EditorOpV1::DeleteElement { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - }]), - ) - .may_change_keys()); - assert!(!request( - None, - Some(vec![EditorOpV1::SetBounds { - element_type: EditorElementTypeV1::Key, - id: uuid::Uuid::new_v4().to_string(), - bounds: EditorBoundsV1 { - dx: 1.0, - dy: 2.0, - width: 60.0, - height: 60.0, - }, - }]), - ) - .may_change_keys()); - } -} +mod tests; diff --git a/src-tauri/src/models/editor/tests.rs b/src-tauri/src/models/editor/tests.rs new file mode 100644 index 000000000..76f972d7e --- /dev/null +++ b/src-tauri/src/models/editor/tests.rs @@ -0,0 +1,614 @@ +use super::*; + +// TS canonical 배열과 공유하는 property 태그 fixture +const PROPERTY_TAG_FIXTURE: &str = + include_str!("../../../../tests/fixtures/editor-property-tags.json"); + +fn paint(color: &str) -> EditorPaintDescriptorV1 { + EditorPaintDescriptorV1 { + color: color.to_string(), + gradient: None, + } +} + +// 75개 variant 전수: (patch, property 태그, value wire) 고정 표본 +fn property_patch_samples() -> Vec<( + EditorElementPropertyPatchV1, + &'static str, + serde_json::Value, +)> { + use serde_json::json; + use EditorElementPropertyPatchV1 as P; + vec![ + (P::Hidden(true), "hidden", json!(true)), + ( + P::LayerName(Some("layer".to_string())), + "layerName", + json!("layer"), + ), + (P::GraphType(GraphType::Bar), "graphType", json!("bar")), + ( + P::GraphColor("#123456".to_string()), + "graphColor", + json!("#123456"), + ), + (P::ShowAvgLine(false), "showAvgLine", json!(false)), + ( + P::GraphAnimationEnabled(true), + "graphAnimationEnabled", + json!(true), + ), + (P::GraphSpeed(7), "graphSpeed", json!(7)), + (P::Reverse(true), "reverse", json!(true)), + (P::Sensitivity(1.5), "sensitivity", json!(1.5)), + (P::AxisId("axis-1".to_string()), "axisId", json!("axis-1")), + (P::UseInlineStyles(true), "useInlineStyles", json!(true)), + (P::FontWeight(700), "fontWeight", json!(700)), + (P::FontBold(true), "fontBold", json!(true)), + (P::FontItalic(true), "fontItalic", json!(true)), + (P::FontUnderline(false), "fontUnderline", json!(false)), + (P::FontStrikethrough(true), "fontStrikethrough", json!(true)), + ( + P::FontFamily("font".to_string()), + "fontFamily", + json!("font"), + ), + ( + P::DisplayText("text".to_string()), + "displayText", + json!("text"), + ), + ( + P::ClassName("class".to_string()), + "className", + json!("class"), + ), + ( + P::FontPaint(paint("#111111")), + "fontPaint", + json!({ "color": "#111111", "gradient": null }), + ), + ( + P::ActiveFontPaint(paint("#222222")), + "activeFontPaint", + json!({ "color": "#222222", "gradient": null }), + ), + ( + P::Shadow(EditorShadowLeafPatchV1::Color( + "rgba(0, 0, 0, 0.2)".to_string(), + )), + "shadow", + json!({ "leaf": "color", "value": "rgba(0, 0, 0, 0.2)" }), + ), + ( + P::ActiveShadow(EditorShadowLeafPatchV1::OffsetY(-3.25)), + "activeShadow", + json!({ "leaf": "offsetY", "value": -3.25 }), + ), + (P::ShadowEnabled(false), "shadowEnabled", json!(false)), + ( + P::BackgroundPaint(paint("#010101")), + "backgroundPaint", + json!({ "color": "#010101", "gradient": null }), + ), + ( + P::ActiveBackgroundPaint(paint("#020202")), + "activeBackgroundPaint", + json!({ "color": "#020202", "gradient": null }), + ), + ( + P::BorderPaint(paint("#030303")), + "borderPaint", + json!({ "color": "#030303", "gradient": null }), + ), + ( + P::ActiveBorderPaint(paint("#040404")), + "activeBorderPaint", + json!({ "color": "#040404", "gradient": null }), + ), + (P::BorderWidth(2.5), "borderWidth", json!(2.5)), + (P::BorderRadius(8.5), "borderRadius", json!(8.5)), + (P::FontSize(12.5), "fontSize", json!(12.5)), + ( + P::InactiveImage("idle.png".to_string()), + "inactiveImage", + json!("idle.png"), + ), + ( + P::ActiveImage("active.png".to_string()), + "activeImage", + json!("active.png"), + ), + (P::IdleTransparent(true), "idleTransparent", json!(true)), + ( + P::ActiveTransparent(false), + "activeTransparent", + json!(false), + ), + ( + P::IdleImageFit(ImageFit::Contain), + "idleImageFit", + json!("contain"), + ), + ( + P::ActiveImageFit(ImageFit::Fill), + "activeImageFit", + json!("fill"), + ), + ( + P::ImageMode(ImageMode::Overlay), + "imageMode", + json!("overlay"), + ), + ( + P::IdleImageTransform(Some(ImageTransformLeafPatchV1::Rotation(-45.5))), + "idleImageTransform", + json!({ "leaf": "rotation", "value": -45.5 }), + ), + ( + P::ActiveImageTransform(None), + "activeImageTransform", + serde_json::Value::Null, + ), + ( + P::SoundPath("sound.wav".to_string()), + "soundPath", + json!("sound.wav"), + ), + (P::SoundEnabled(true), "soundEnabled", json!(true)), + (P::SoundVolume(80.5), "soundVolume", json!(80.5)), + (P::CounterEnabled(true), "counterEnabled", json!(true)), + ( + P::CounterAnimationEnabled(false), + "counterAnimationEnabled", + json!(false), + ), + ( + P::CounterPlacement(KeyCounterPlacement::Outside), + "counterPlacement", + json!("outside"), + ), + ( + P::CounterAlign(KeyCounterAlign::Top), + "counterAlign", + json!("top"), + ), + ( + P::CounterAlignMode(KeyCounterAlignMode::Between), + "counterAlignMode", + json!("between"), + ), + (P::CounterGap(4), "counterGap", json!(4)), + (P::CounterFontSize(16), "counterFontSize", json!(16)), + (P::CounterFontWeight(500), "counterFontWeight", json!(500)), + (P::CounterFontBold(true), "counterFontBold", json!(true)), + (P::CounterFontItalic(true), "counterFontItalic", json!(true)), + ( + P::CounterFontUnderline(false), + "counterFontUnderline", + json!(false), + ), + ( + P::CounterFontStrikethrough(true), + "counterFontStrikethrough", + json!(true), + ), + ( + P::CounterFontFamily("counter-font".to_string()), + "counterFontFamily", + json!("counter-font"), + ), + ( + P::CounterFillIdle(EditorCounterFillIntentV1::Solid( + EditorCounterFillSolidIntentV1 { + color: "#050505".to_string(), + }, + )), + "counterFillIdle", + json!({ "color": "#050505" }), + ), + ( + P::CounterFillActive(EditorCounterFillIntentV1::Solid( + EditorCounterFillSolidIntentV1 { + color: "#060606".to_string(), + }, + )), + "counterFillActive", + json!({ "color": "#060606" }), + ), + ( + P::CounterAnimationPreset(EditorCounterAnimationPresetIntentV1 { + preset_id: "preset".to_string(), + apply_preset_id: None, + bezier: None, + scale: None, + duration_ms: None, + }), + "counterAnimationPreset", + json!({ "presetId": "preset" }), + ), + (P::StatType(StatType::Total), "statType", json!("total")), + (P::NoteEffectEnabled(true), "noteEffectEnabled", json!(true)), + (P::NoteGlowEnabled(false), "noteGlowEnabled", json!(false)), + (P::NoteGlowSyncPaint(true), "noteGlowSyncPaint", json!(true)), + (P::NoteGlowSize(6.5), "noteGlowSize", json!(6.5)), + ( + P::NotePaint(EditorNotePaintIntentV1::Opacity( + EditorNotePaintOpacityIntentV1 { opacity: 40 }, + )), + "notePaint", + json!({ "opacity": 40 }), + ), + ( + P::NoteGlowPaint(EditorNotePaintIntentV1::Opacity( + EditorNotePaintOpacityIntentV1 { opacity: 60 }, + )), + "noteGlowPaint", + json!({ "opacity": 60 }), + ), + ( + P::NoteBorderPaint(EditorNoteBorderPaintV1 { + color: "#0a0b0c".to_string(), + opacity: 30, + gradient: None, + }), + "noteBorderPaint", + json!({ "color": "#0a0b0c", "opacity": 30 }), + ), + (P::NoteOffsetX(Some(11.5)), "noteOffsetX", json!(11.5)), + (P::NoteOffsetY(Some(-12.5)), "noteOffsetY", json!(-12.5)), + (P::NoteWidth(Some(13.5)), "noteWidth", json!(13.5)), + (P::NoteBorderWidth(1.5), "noteBorderWidth", json!(1.5)), + (P::NoteBorderRadius(9.5), "noteBorderRadius", json!(9.5)), + ( + P::NoteAutoYCorrection(true), + "noteAutoYCorrection", + json!(true), + ), + ( + P::NoteAlignment(NoteAlignment::Right), + "noteAlignment", + json!("right"), + ), + ( + P::NoteBorderSide(EditorNoteBorderSideV1::Vertical), + "noteBorderSide", + json!("vertical"), + ), + ] +} + +#[test] +fn property_patch_wire_pins_all_tag_value_pairs_and_roundtrips() { + let samples = property_patch_samples(); + assert_eq!(samples.len(), 75); + for (patch, tag, value) in samples { + let wire = serde_json::to_value(&patch).unwrap(); + assert_eq!( + wire, + serde_json::json!({ "property": tag, "value": value }), + "wire mismatch for {tag}" + ); + let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(wire).unwrap(); + assert_eq!(decoded, patch, "roundtrip mismatch for {tag}"); + } +} + +#[test] +fn expanded_paint_values_keep_existing_property_tags_and_exact_keys() { + let gradient = serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }); + let cases = [ + serde_json::json!({ + "property": "noteBorderPaint", + "value": { "color": "#112233", "opacity": 80 } + }), + serde_json::json!({ + "property": "noteBorderPaint", + "value": { "color": "#112233", "opacity": 80, "gradient": null } + }), + serde_json::json!({ + "property": "noteBorderPaint", + "value": { + "color": "#112233", + "opacity": 80, + "gradient": gradient.clone() + } + }), + serde_json::json!({ + "property": "notePaint", + "value": { + "color": "#112233", + "opacity": 80, + "gradient": null + } + }), + serde_json::json!({ + "property": "noteGlowPaint", + "value": { + "color": { + "type": "gradient", + "top": "#112233", + "bottom": "#445566" + }, + "opacity": 80, + "gradient": gradient + } + }), + ]; + + for wire in cases { + let patch: EditorElementPropertyPatchV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!( + serde_json::to_value(&patch).unwrap()["property"], + wire["property"] + ); + } + + for wire in [ + serde_json::json!({ + "property": "noteBorderPaint", + "value": { "color": "#112233", "opacity": 80, "extra": true } + }), + serde_json::json!({ + "property": "notePaint", + "value": { "color": "#112233", "opacity": 80 } + }), + serde_json::json!({ + "property": "noteGlowPaint", + "value": { + "color": "#112233", + "opacity": 80, + "gradient": null, + "extra": true + } + }), + ] { + assert!(serde_json::from_value::(wire).is_err()); + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PropertyTagFixture { + version: u16, + properties: Vec, +} + +fn property_tag_fixture() -> PropertyTagFixture { + serde_json::from_str(PROPERTY_TAG_FIXTURE).unwrap() +} + +#[test] +fn property_patch_tags_match_shared_fixture() { + let fixture = property_tag_fixture(); + // 양방향 anchor: Rust 상수만 승격되는 사고도 fixture 대조로 잡는다 + assert_eq!(fixture.version, EDITOR_OPS_VERSION); + let tags: Vec = property_patch_samples() + .iter() + .map(|(_, tag, _)| (*tag).to_string()) + .collect(); + assert_eq!(tags, fixture.properties); +} + +// serde가 알 수 없는 variant 오류에 나열하는 기대 목록에서 enum의 +// wire 태그 전수를 기계적으로 추출 - 수기 목록 없이 variant 추가를 감지 +fn wire_property_tags_from_serde() -> std::collections::BTreeSet { + let message = serde_json::from_value::(serde_json::json!({ + "property": "__unknown__", + "value": null, + })) + .unwrap_err() + .to_string(); + let (_, listed) = message + .split_once("expected one of ") + .expect("serde unknown-variant error must enumerate every variant"); + let tags: std::collections::BTreeSet = listed + .split(", ") + .map(|tag| tag.trim().trim_matches('`').to_string()) + .collect(); + assert!(!tags.is_empty()); + tags +} + +#[test] +fn sample_and_fixture_tags_cover_every_enum_variant() { + let enum_tags = wire_property_tags_from_serde(); + let sample_tags: std::collections::BTreeSet = property_patch_samples() + .iter() + .map(|(_, tag, _)| (*tag).to_string()) + .collect(); + let fixture_tags: std::collections::BTreeSet = + property_tag_fixture().properties.into_iter().collect(); + // variant 신규 추가 시 표본·fixture 갱신 누락을 단방향 드리프트 없이 차단 + assert_eq!(sample_tags, enum_tags); + assert_eq!(fixture_tags, enum_tags); +} + +#[test] +fn property_patch_rejects_untagged_extra_unknown_and_missing_fields() { + use serde_json::json; + let reject = |value: serde_json::Value| { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "expected rejection: {value}" + ); + }; + // 옛 one-key 형식 + reject(json!({ "hidden": true })); + reject(json!({ "graphType": "bar" })); + // outer 추가 필드 + reject(json!({ "property": "hidden", "value": true, "extra": 1 })); + // 알 수 없는 property + reject(json!({ "property": "unknown", "value": 1 })); + // property 누락 + reject(json!({ "value": true })); + // 원시값 payload의 value 누락 + reject(json!({ "property": "hidden" })); + // 잘못된 값 타입 + reject(json!({ "property": "hidden", "value": "true" })); + // 중복 키 + for duplicated in [ + r#"{ "property": "hidden", "property": "hidden", "value": true }"#, + r#"{ "property": "hidden", "value": true, "value": false }"#, + ] { + assert!( + serde_json::from_str::(duplicated).is_err(), + "expected duplicate key rejection: {duplicated}" + ); + } +} + +#[test] +fn nullable_variants_require_an_explicit_value_key() { + use serde_json::json; + use EditorElementPropertyPatchV1 as P; + let cases = [ + ("layerName", P::LayerName(None)), + ("noteOffsetX", P::NoteOffsetX(None)), + ("noteOffsetY", P::NoteOffsetY(None)), + ("noteWidth", P::NoteWidth(None)), + ("idleImageTransform", P::IdleImageTransform(None)), + ("activeImageTransform", P::ActiveImageTransform(None)), + ]; + for (tag, expected_null) in cases { + // value 키 자체가 없으면 None으로 통과하지 않고 거부 + assert!( + serde_json::from_value::(json!({ "property": tag })) + .is_err(), + "expected missing value rejection for {tag}" + ); + let decoded: EditorElementPropertyPatchV1 = + serde_json::from_value(json!({ "property": tag, "value": null })).unwrap(); + assert_eq!(decoded, expected_null, "explicit null mismatch for {tag}"); + } + let decoded: EditorElementPropertyPatchV1 = serde_json::from_value(json!({ + "property": "layerName", + "value": "named" + })) + .unwrap(); + assert_eq!(decoded, P::LayerName(Some("named".to_string()))); +} + +#[test] +fn shadow_leaf_wire_requires_leaf_tag_and_exact_fields() { + use serde_json::json; + let decoded: EditorShadowLeafPatchV1 = + serde_json::from_value(json!({ "leaf": "blur", "value": 4.5 })).unwrap(); + assert_eq!(decoded, EditorShadowLeafPatchV1::Blur(4.5)); + for invalid in [ + // 옛 one-key 형식 + json!({ "blur": 4.5 }), + // leaf 누락 + json!({ "value": 4.5 }), + // leaf 오타 + json!({ "leaf": "blr", "value": 4.5 }), + // value 누락 + json!({ "leaf": "blur" }), + // 추가 필드 + json!({ "leaf": "blur", "value": 4.5, "extra": 1 }), + // 잘못된 값 타입 + json!({ "leaf": "offsetX", "value": "1" }), + ] { + assert!( + serde_json::from_value::(invalid.clone()).is_err(), + "expected shadow leaf rejection: {invalid}" + ); + } +} + +#[test] +fn image_transform_leaf_wire_requires_leaf_tag_and_exact_fields() { + use serde_json::json; + for (wire, expected) in [ + ( + json!({ "leaf": "offsetX", "value": -10.5 }), + ImageTransformLeafPatchV1::OffsetX(-10.5), + ), + ( + json!({ "leaf": "offsetY", "value": 20.5 }), + ImageTransformLeafPatchV1::OffsetY(20.5), + ), + ( + json!({ "leaf": "rotation", "value": 45.0 }), + ImageTransformLeafPatchV1::Rotation(45.0), + ), + ( + json!({ "leaf": "scale", "value": 1.25 }), + ImageTransformLeafPatchV1::Scale(1.25), + ), + ] { + let decoded: ImageTransformLeafPatchV1 = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(decoded, expected); + assert_eq!(serde_json::to_value(decoded).unwrap(), wire); + } + for invalid in [ + json!({ "scale": 1.0 }), + json!({ "value": 1.0 }), + json!({ "leaf": "zoom", "value": 1.0 }), + json!({ "leaf": "scale" }), + json!({ "leaf": "scale", "value": 1.0, "extra": true }), + json!({ "leaf": "offsetX", "value": "1" }), + ] { + assert!( + serde_json::from_value::(invalid.clone()).is_err(), + "expected image transform leaf rejection: {invalid}" + ); + } +} + +#[test] +fn editor_request_detects_every_direct_key_mapping_mutation() { + let request = + |changes: Option, ops: Option>| EditorCommitRequest { + base_revision: 0, + mutation_id: uuid::Uuid::new_v4().to_string(), + multi_key: false, + gesture_id: None, + gesture_ids: Vec::new(), + changes, + ops_version: ops.as_ref().map(|_| EDITOR_OPS_VERSION), + ops, + }; + + let key_patch = EditorPatchV1 { + keys: Some(KeyMappings::new()), + ..EditorPatchV1::default() + }; + assert!(request(Some(key_patch), None).may_change_keys()); + assert!(request( + None, + Some(vec![EditorOpV1::SetKeySlot { + id: uuid::Uuid::new_v4().to_string(), + slot: EditorFrozenKeySlotV1::Single("A".to_string()), + }]), + ) + .may_change_keys()); + assert!(request( + None, + Some(vec![EditorOpV1::DeleteElement { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + }]), + ) + .may_change_keys()); + assert!(!request( + None, + Some(vec![EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Key, + id: uuid::Uuid::new_v4().to_string(), + bounds: EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 60.0, + height: 60.0, + }, + }]), + ) + .may_change_keys()); +} diff --git a/src-tauri/src/models/element_positions.rs b/src-tauri/src/models/element_positions.rs new file mode 100644 index 000000000..cae2840af --- /dev/null +++ b/src-tauri/src/models/element_positions.rs @@ -0,0 +1,416 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::{ + default_key_height, default_key_note_color, default_key_note_opacity, + default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled, + default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size, GradientSpec, + ImageFit, KeyCounterSettings, NoteColor, +}; + +pub const POSITION_COLLECTION_FIELDS: [&str; 4] = [ + "keyPositions", + "statPositions", + "graphPositions", + "knobPositions", +]; + +pub type KeyPositions = HashMap>; +pub type StatPositions = HashMap>; +pub type GraphPositions = HashMap>; +pub type KnobPositions = HashMap>; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum NoteAlignment { + Left, + #[default] + Center, + Right, +} + +// 그림자 범위 계약 — 프론트 zod(ELEMENT_SHADOW_CONSTRAINTS)와 동기 유지 +pub const SHADOW_OFFSET_MIN: f64 = -100.0; +pub const SHADOW_OFFSET_MAX: f64 = 100.0; +pub const SHADOW_BLUR_MIN: f64 = 0.0; +pub const SHADOW_BLUR_MAX: f64 = 100.0; + +pub const IMAGE_TRANSFORM_OFFSET_MIN: f64 = -500.0; +pub const IMAGE_TRANSFORM_OFFSET_MAX: f64 = 500.0; +pub const IMAGE_TRANSFORM_ROTATION_MIN: f64 = -180.0; +pub const IMAGE_TRANSFORM_ROTATION_MAX: f64 = 180.0; +pub const IMAGE_TRANSFORM_SCALE_MIN: f64 = 0.1; +pub const IMAGE_TRANSFORM_SCALE_MAX: f64 = 10.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ImageMode { + Replace, + Overlay, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageTransform { + pub offset_x: f64, + pub offset_y: f64, + pub rotation: f64, + pub scale: f64, +} + +impl Default for ImageTransform { + fn default() -> Self { + Self { + offset_x: 0.0, + offset_y: 0.0, + rotation: 0.0, + scale: 1.0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ElementShadowSpec { + pub enabled: bool, + pub color: String, + pub offset_x: f64, + pub offset_y: f64, + pub blur: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyPosition { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + pub dx: f64, + pub dy: f64, + pub width: f64, + #[serde(default = "default_key_height")] + pub height: f64, + /// 레이어 표시 여부 (true면 숨김) + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub active_image: Option, + #[serde(default)] + pub inactive_image: Option, + /// 키별 사운드 활성화 여부 (기본값 false) + #[serde(default)] + pub sound_enabled: Option, + /// 키 입력 시 재생할 로컬 사운드 파일 경로 + #[serde(default)] + pub sound_path: Option, + /// 키별 사운드 볼륨 (0~200, 기본값 100) + #[serde(default)] + pub sound_volume: Option, + #[serde(default)] + pub active_transparent: bool, + #[serde(default)] + pub idle_transparent: bool, + pub count: u32, + #[serde(default = "default_key_note_color")] + pub note_color: NoteColor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_gradient: Option, + #[serde(default = "default_key_note_opacity")] + pub note_opacity: u32, + #[serde(default)] + pub note_opacity_top: Option, + #[serde(default)] + pub note_opacity_bottom: Option, + #[serde(default)] + pub note_border_radius: Option, + /// 노트 넓이(px). None이면 키 width를 사용(자동). + #[serde(default)] + pub note_width: Option, + /// 노트 정렬 (left/center/right). 기본값 center. + #[serde(default)] + pub note_alignment: NoteAlignment, + #[serde(default = "default_note_effect_enabled")] + pub note_effect_enabled: bool, + #[serde(default = "default_note_glow_enabled")] + pub note_glow_enabled: bool, + #[serde(default)] + pub note_glow_sync_paint: bool, + #[serde(default = "default_note_glow_size")] + pub note_glow_size: f64, + #[serde(default = "default_note_glow_opacity")] + pub note_glow_opacity: u32, + #[serde(default)] + pub note_glow_opacity_top: Option, + #[serde(default)] + pub note_glow_opacity_bottom: Option, + #[serde(default)] + pub note_glow_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_glow_gradient: Option, + #[serde(default = "default_note_auto_y_correction")] + pub note_auto_y_correction: bool, + /// 노트 오프셋 X (px). 기본 정렬에 추가 보정값. + #[serde(default)] + pub note_offset_x: Option, + /// 노트 오프셋 Y (px). 기본 정렬에 추가 보정값. + #[serde(default)] + pub note_offset_y: Option, + /// 노트 테두리 굵기 (px). 0이면 테두리 없음. + #[serde(default)] + pub note_border_width: Option, + /// 노트 테두리 색상 + #[serde(default)] + pub note_border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note_border_gradient: Option, + /// 노트 테두리 투명도 (0~100). 노트 배경 투명도와 독립. 기본 100. + #[serde(default = "default_note_border_opacity")] + pub note_border_opacity: u32, + /// 노트 테두리 방향 (all/vertical/horizontal) + #[serde(default)] + pub note_border_side: Option, + #[serde(default)] + pub class_name: Option, + #[serde(default)] + pub z_index: Option, + #[serde(default)] + pub counter: KeyCounterSettings, + // 스타일 관련 속성들 + #[serde(default)] + pub background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_gradient: Option, + #[serde(default)] + pub active_background_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_background_gradient: Option, + #[serde(default)] + pub border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub border_gradient: Option, + #[serde(default)] + pub active_border_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_border_gradient: Option, + #[serde(default)] + pub border_width: Option, + #[serde(default)] + pub border_radius: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shadow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_shadow: Option, + #[serde(default)] + pub font_size: Option, + #[serde(default)] + pub font_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_gradient: Option, + #[serde(default)] + pub active_font_color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_font_gradient: Option, + #[serde(default)] + pub graph_animation_enabled: Option, + /// 글꼴 패밀리 (커스텀 폰트 이름) + #[serde(default)] + pub font_family: Option, + #[serde(default)] + pub image_fit: Option, + /// 이미지 맞춤(대기/입력 개별). 없으면 image_fit을 fallback으로 사용. + #[serde(default)] + pub idle_image_fit: Option, + #[serde(default)] + pub active_image_fit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_image_transform: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_image_transform: Option, + /// 인라인 스타일 우선 여부 (true: 속성 패널 스타일 우선, false: 커스텀 CSS 우선) + #[serde(default)] + pub use_inline_styles: Option, + /// 키에 표시할 커스텀 텍스트 (None이면 기본 키 이름 표시) + #[serde(default)] + pub display_text: Option, + /// 글꼴 굵기 (CSS font-weight 값, 예: 400, 700) + #[serde(default)] + pub font_weight: Option, + /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 + /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_bold: Option, + /// 이탤릭체 여부 + #[serde(default)] + pub font_italic: Option, + /// 밑줄 여부 + #[serde(default)] + pub font_underline: Option, + /// 취소선 여부 + #[serde(default)] + pub font_strikethrough: Option, + /// 레이어 패널에서 표시할 커스텀 이름 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_name: Option, + /// 레이어 그룹 ID + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, +} + +impl Default for KeyPosition { + fn default() -> Self { + Self { + id: String::new(), + dx: 0.0, + dy: 0.0, + width: 60.0, + height: default_key_height(), + hidden: false, + active_image: None, + inactive_image: None, + sound_enabled: None, + sound_path: None, + sound_volume: None, + active_transparent: false, + idle_transparent: false, + count: 0, + note_color: default_key_note_color(), + note_gradient: None, + note_opacity: default_key_note_opacity(), + note_opacity_top: None, + note_opacity_bottom: None, + note_border_radius: None, + note_width: None, + note_alignment: NoteAlignment::default(), + note_effect_enabled: default_note_effect_enabled(), + note_glow_enabled: default_note_glow_enabled(), + note_glow_sync_paint: false, + note_glow_size: default_note_glow_size(), + note_glow_opacity: default_note_glow_opacity(), + note_glow_opacity_top: None, + note_glow_opacity_bottom: None, + note_glow_color: None, + note_glow_gradient: None, + note_auto_y_correction: default_note_auto_y_correction(), + note_offset_x: None, + note_offset_y: None, + note_border_width: None, + note_border_color: None, + note_border_gradient: None, + note_border_opacity: default_note_border_opacity(), + note_border_side: None, + class_name: None, + z_index: None, + counter: KeyCounterSettings::default(), + background_color: None, + background_gradient: None, + active_background_color: None, + active_background_gradient: None, + border_color: None, + border_gradient: None, + active_border_color: None, + active_border_gradient: None, + border_width: None, + border_radius: None, + shadow: None, + active_shadow: None, + font_size: None, + font_color: None, + font_gradient: None, + active_font_color: None, + active_font_gradient: None, + graph_animation_enabled: None, + font_family: None, + image_fit: None, + idle_image_fit: None, + active_image_fit: None, + image_mode: None, + idle_image_transform: None, + active_image_transform: None, + use_inline_styles: None, + display_text: None, + font_weight: Some(400), + font_bold: Some(true), + font_italic: None, + font_underline: None, + font_strikethrough: None, + layer_name: None, + group_id: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum StatType { + Kps, + KpsAvg, + KpsMax, + Total, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StatPosition { + pub stat_type: StatType, + #[serde(flatten)] + pub position: KeyPosition, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum GraphStatType { + Kps, + KpsAvg, + KpsMax, + Total, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum GraphType { + Line, + Bar, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GraphPosition { + pub stat_type: GraphStatType, + pub graph_type: GraphType, + pub graph_speed: u32, + pub graph_color: String, + #[serde(default = "default_true")] + pub show_avg_line: bool, + #[serde(flatten)] + pub position: KeyPosition, +} + +fn default_knob_sensitivity() -> f64 { + // 순수 배율 — 1이면 축 해상도와 무관하게 물리 1회전 ≈ 화면 1회전 + // (프론트엔드가 wrap 델타를 축 해상도로 정규화해 회전수 단위로 누적) + 1.0 +} + +/// 회전(노브) 요소 — HID 축에 바인딩. KeyPosition 상속(표시명/스타일/클래스/이미지) + 노브 전용 필드. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KnobPosition { + /// 바인딩된 HID 축 식별자 "HIDA:vid:pid:usagePage:usage" + #[serde(default)] + pub axis_id: String, + /// 회전 배율 (물리 1회전당 화면 회전 수) + #[serde(default = "default_knob_sensitivity")] + pub sensitivity: f64, + /// 회전 방향 반전 + #[serde(default)] + pub reverse: bool, + #[serde(flatten)] + pub position: KeyPosition, +} + +pub(super) fn default_true() -> bool { + true +} diff --git a/src-tauri/src/models/key_position_normalization.rs b/src-tauri/src/models/key_position_normalization.rs new file mode 100644 index 000000000..3199d353d --- /dev/null +++ b/src-tauri/src/models/key_position_normalization.rs @@ -0,0 +1,649 @@ +use super::*; + +impl KeyCounterSettings { + pub(crate) fn migrate_legacy_font_weight(&mut self) -> bool { + if self.font_bold.is_some() || self.font_weight != 700 { + return false; + } + + self.font_weight = 400; + self.font_bold = Some(true); + true + } + + pub fn normalize(&mut self) { + self.animation.normalize(); + } + + /// Migrate legacy defaults that were previously serialized into store.json. + /// This keeps existing user customizations intact, while fixing old defaults + /// that diverged from the renderer. + // 당시 직렬화되던 스냅샷 값 고정 + fn matches_legacy_default_snapshot(&self) -> bool { + self.fill.idle == "#FFFFFF" + && self.fill.active == "#000000" + && matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_weight == 400 + && self.font_bold.is_none() + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough + } + + // 직전 기본값 스냅샷(회색 카운터·16px·700·상단 배치) 전 필드 일치 검사 + fn matches_previous_default_snapshot(&self) -> bool { + self.fill.idle == "rgba(121, 121, 121, 0.9)" + && self.fill.active == "#FFFFFF" + && matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_weight == 700 + && self.font_bold.is_none() + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough + } + + pub fn migrate_legacy_defaults(&mut self) -> bool { + if self.fill_idle_gradient.is_some() || self.fill_active_gradient.is_some() { + self.normalize(); + return false; + } + + if self.matches_legacy_default_snapshot() { + self.fill = KeyCounterColor::default(); + self.align = KeyCounterAlign::Bottom; + self.gap = default_gap(); + self.font_size = default_counter_font_size(); + self.font_weight = default_counter_font_weight(); + self.font_bold = Some(false); + self.animation = KeyCounterAnimationSettings::default(); + self.normalize(); + return true; + } + + if self.matches_previous_default_snapshot() { + self.fill = KeyCounterColor::default(); + self.align = KeyCounterAlign::Bottom; + self.gap = default_gap(); + self.font_size = default_counter_font_size(); + self.font_weight = default_counter_font_weight(); + self.font_bold = Some(false); + self.normalize(); + return true; + } + + self.normalize(); + false + } + + pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + let (idle_changed, idle_pair_repaired) = + canonicalize_counter_gradient_pair(&mut self.fill.idle, &mut self.fill_idle_gradient); + changed |= idle_changed; + pair_repaired |= idle_pair_repaired; + + let (active_changed, active_pair_repaired) = canonicalize_counter_gradient_pair( + &mut self.fill.active, + &mut self.fill_active_gradient, + ); + changed |= active_changed; + pair_repaired |= active_pair_repaired; + + (changed, pair_repaired) + } +} + +fn removed_counter_stroke_matches( + stroke: Option<&serde_json::Value>, + expected_idle: &str, + expected_active: &str, +) -> bool { + let Some(stroke) = stroke else { + return expected_idle == "transparent" && expected_active == "transparent"; + }; + let Some(stroke) = stroke.as_object() else { + return false; + }; + stroke.get("idle").and_then(serde_json::Value::as_str) == Some(expected_idle) + && stroke.get("active").and_then(serde_json::Value::as_str) == Some(expected_active) +} + +fn escape_removed_counter_stroke_collision( + counter: &mut serde_json::Map, + removed_stroke: Option<&serde_json::Value>, + had_removed_gradient: bool, +) -> bool { + let Ok(parsed) = + serde_json::from_value::(serde_json::Value::Object(counter.clone())) + else { + return false; + }; + let legacy_collision = parsed.matches_legacy_default_snapshot(); + let previous_collision = parsed.matches_previous_default_snapshot(); + let custom_stroke = (legacy_collision + && !removed_counter_stroke_matches(removed_stroke, "#000000", "#FFFFFF")) + || (previous_collision + && !removed_counter_stroke_matches(removed_stroke, "transparent", "transparent")); + if !(had_removed_gradient || custom_stroke) || !(legacy_collision || previous_collision) { + return false; + } + let Some(fill) = counter + .get_mut("fill") + .and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + let mut changed = false; + for state in ["idle", "active"] { + let Some(color) = fill.get(state).and_then(serde_json::Value::as_str) else { + continue; + }; + let escaped = serde_json::Value::String(compact_canonical_rgba(color)); + if fill.get(state) != Some(&escaped) { + fill.insert(state.to_string(), escaped); + changed = true; + } + } + changed +} + +fn scrub_removed_text_outline_from_position(position: &mut serde_json::Value) -> bool { + let Some(position) = position.as_object_mut() else { + return false; + }; + let mut changed = position.remove("fontStrokeColor").is_some(); + changed |= position.remove("activeFontStrokeColor").is_some(); + + let Some(counter) = position + .get_mut("counter") + .and_then(serde_json::Value::as_object_mut) + else { + return changed; + }; + let removed_stroke = counter.remove("stroke"); + let had_removed_gradient = counter.remove("strokeIdleGradient").is_some() + | counter.remove("strokeActiveGradient").is_some(); + changed |= removed_stroke.is_some() || had_removed_gradient; + changed |= escape_removed_counter_stroke_collision( + counter, + removed_stroke.as_ref(), + had_removed_gradient, + ); + changed +} + +// 제거된 텍스트 외곽선 필드 정리 +pub(crate) fn scrub_removed_text_outline_fields(value: &mut serde_json::Value) -> bool { + let mut changed = false; + for collection in POSITION_COLLECTION_FIELDS { + let Some(modes) = value + .get_mut(collection) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + for position in modes + .values_mut() + .filter_map(serde_json::Value::as_array_mut) + .flatten() + { + changed |= scrub_removed_text_outline_from_position(position); + } + } + changed +} + +impl KeyPosition { + pub(crate) fn migrate_legacy_font_weight(&mut self) -> bool { + let mut changed = false; + if self.font_bold.is_none() && self.font_weight == Some(700) { + self.font_weight = Some(400); + self.font_bold = Some(true); + changed = true; + } + + changed | self.counter.migrate_legacy_font_weight() + } + + /// 본체 페인트를 글로우로 복사. 바뀐 게 있으면 true + pub(crate) fn mirror_note_body_to_glow(&mut self) -> bool { + let changed = self.note_glow_gradient != self.note_gradient + || self.note_glow_opacity != self.note_opacity + || self.note_glow_color.as_ref() != Some(&self.note_color) + || self.note_glow_opacity_top != self.note_opacity_top + || self.note_glow_opacity_bottom != self.note_opacity_bottom; + if changed { + self.note_glow_gradient = self.note_gradient.clone(); + self.note_glow_opacity = self.note_opacity; + self.note_glow_color = Some(self.note_color.clone()); + self.note_glow_opacity_top = self.note_opacity_top; + self.note_glow_opacity_bottom = self.note_opacity_bottom; + } + changed + } + + // replace는 sparse 저장(None)이 정본 - 프리셋·플러그인·frozen insert로 들어온 + // Some(Replace)를 접어 이후 ImageMode(Replace) 패치가 빈 undo 항목을 만들지 않게 + pub(crate) fn canonicalize_image_mode(&mut self) -> bool { + if self.image_mode == Some(ImageMode::Replace) { + self.image_mode = None; + true + } else { + false + } + } + + pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { + let mut changed = false; + let mut pair_repaired = false; + + let (note_changed, note_pair_repaired) = + canonicalize_note_gradient(&mut self.note_gradient, self.note_opacity, |shadow| { + let shadow_changed = self.note_color != shadow.color + || self.note_opacity_top != Some(shadow.opacity_top) + || self.note_opacity_bottom != Some(shadow.opacity_bottom); + self.note_color = shadow.color; + self.note_opacity_top = Some(shadow.opacity_top); + self.note_opacity_bottom = Some(shadow.opacity_bottom); + shadow_changed + }); + changed |= note_changed; + pair_repaired |= note_pair_repaired; + + if self.note_glow_sync_paint { + changed |= self.mirror_note_body_to_glow(); + } + + let (glow_changed, glow_pair_repaired) = canonicalize_note_gradient( + &mut self.note_glow_gradient, + self.note_glow_opacity, + |shadow| { + let shadow_changed = self.note_glow_color.as_ref() != Some(&shadow.color) + || self.note_glow_opacity_top != Some(shadow.opacity_top) + || self.note_glow_opacity_bottom != Some(shadow.opacity_bottom); + self.note_glow_color = Some(shadow.color); + self.note_glow_opacity_top = Some(shadow.opacity_top); + self.note_glow_opacity_bottom = Some(shadow.opacity_bottom); + shadow_changed + }, + ); + changed |= glow_changed; + pair_repaired |= glow_pair_repaired; + + let (note_border_changed, note_border_pair_repaired) = + canonicalize_note_border_gradient_pair( + &mut self.note_border_color, + &mut self.note_border_gradient, + ); + changed |= note_border_changed; + pair_repaired |= note_border_pair_repaired; + + for (base, gradient) in [ + (&mut self.background_color, &mut self.background_gradient), + ( + &mut self.active_background_color, + &mut self.active_background_gradient, + ), + (&mut self.border_color, &mut self.border_gradient), + ( + &mut self.active_border_color, + &mut self.active_border_gradient, + ), + (&mut self.font_color, &mut self.font_gradient), + (&mut self.active_font_color, &mut self.active_font_gradient), + ] { + let (pair_changed, base_repaired) = canonicalize_optional_gradient_pair(base, gradient); + changed |= pair_changed; + pair_repaired |= base_repaired; + } + + let (counter_changed, counter_pair_repaired) = self.counter.canonicalize_gradient_pairs(); + changed |= counter_changed; + pair_repaired |= counter_pair_repaired; + + (changed, pair_repaired) + } +} + +fn canonicalize_optional_gradient_pair( + base: &mut Option, + gradient: &mut Option, +) -> (bool, bool) { + let Some(current) = gradient.as_mut() else { + return (false, false); + }; + + // 공백 stop 색은 대표색 동기와 공백 색 정규화가 서로 되돌려 로드 복구가 + // 수렴하지 않으므로 그라데이션 자체를 내린다 (note 계열의 invalid stop 처리와 동일) + if current + .stops + .iter() + .any(|stop| stop.color.trim().is_empty()) + { + *gradient = None; + return (true, true); + } + + let gradient = current; + let mut changed = gradient.canonicalize(); + let representative = gradient + .stops + .first() + .expect("a deserialized gradient always has at least two stops") + .color + .clone(); + let pair_repaired = base.as_deref() != Some(representative.as_str()); + if pair_repaired { + *base = Some(representative); + changed = true; + } + (changed, pair_repaired) +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct NoteGradientShadow { + pub(crate) color: NoteColor, + pub(crate) opacity_top: u32, + pub(crate) opacity_bottom: u32, +} + +pub(crate) fn note_gradient_shadow( + gradient: &GradientSpec, + opacity: u32, +) -> Option { + if gradient.note_border_invalid_stop_index().is_some() { + return None; + } + let first = gradient.stops.first()?; + let last = gradient.stops.last()?; + let (top, top_alpha) = note_gradient_stop_color(&first.color)?; + let (bottom, bottom_alpha) = note_gradient_stop_color(&last.color)?; + Some(NoteGradientShadow { + color: NoteColor::Gradient { top, bottom }, + // 배율이 검증 범위를 벗어난 저장값이어도 shadow는 0~100 계약 유지 (TS clamp 미러) + opacity_top: ((top_alpha * f64::from(opacity)).round() as u32).min(100), + opacity_bottom: ((bottom_alpha * f64::from(opacity)).round() as u32).min(100), + }) +} + +// 유효한 sibling만 배율 부재를 100으로 실체화 - 손상 sibling은 이후 canonicalize가 +// drop하므로 구형 폴백 의미(부재 = 80/70)를 변조하지 않는다 (TS 미러와 동일 순서) +fn is_materializable_note_gradient(value: &serde_json::Value) -> bool { + if value.is_null() { + return false; + } + serde_json::from_value::(value.clone()) + .is_ok_and(|gradient| gradient.note_border_invalid_stop_index().is_none()) +} + +pub(crate) fn default_missing_note_gradient_multipliers(position: &mut serde_json::Value) -> bool { + let Some(position) = position.as_object_mut() else { + return false; + }; + let mut changed = false; + if position + .get("noteGradient") + .is_some_and(is_materializable_note_gradient) + && !position.contains_key("noteOpacity") + { + position.insert("noteOpacity".to_string(), serde_json::Value::from(100)); + changed = true; + } + if position + .get("noteGlowGradient") + .is_some_and(is_materializable_note_gradient) + && !position.contains_key("noteGlowOpacity") + { + position.insert("noteGlowOpacity".to_string(), serde_json::Value::from(100)); + changed = true; + } + changed +} + +fn canonicalize_note_gradient( + gradient: &mut Option, + opacity: u32, + apply_shadow: impl FnOnce(NoteGradientShadow) -> bool, +) -> (bool, bool) { + let Some(current) = gradient.as_mut() else { + return (false, false); + }; + + let mut changed = current.canonicalize(); + let Some(shadow) = note_gradient_shadow(current, opacity) else { + *gradient = None; + return (true, true); + }; + let pair_repaired = apply_shadow(shadow); + changed |= pair_repaired; + (changed, pair_repaired) +} + +fn canonicalize_counter_gradient_pair( + base: &mut String, + gradient: &mut Option, +) -> (bool, bool) { + let Some(gradient) = gradient else { + return (false, false); + }; + + let mut changed = gradient.canonicalize(); + let representative = compact_canonical_rgba( + &gradient + .stops + .first() + .expect("a deserialized gradient always has at least two stops") + .color, + ); + let pair_repaired = *base != representative; + if pair_repaired { + *base = representative; + changed = true; + } + (changed, pair_repaired) +} + +fn canonicalize_note_border_gradient_pair( + base: &mut Option, + gradient: &mut Option, +) -> (bool, bool) { + let Some(current) = gradient.as_mut() else { + return (false, false); + }; + + let mut changed = current.canonicalize(); + if current.note_border_invalid_stop_index().is_some() { + *gradient = None; + return (true, true); + } + + let representative = note_border_representative_hex( + ¤t + .stops + .first() + .expect("a deserialized gradient always has at least two stops") + .color, + ) + .expect("all note border stop colors were validated"); + let pair_repaired = base.as_deref() != Some(representative.as_str()); + if pair_repaired { + *base = Some(representative); + changed = true; + } + (changed, pair_repaired) +} + +pub(crate) fn note_border_representative_hex(color: &str) -> Option { + note_gradient_stop_color(color).map(|(hex, _)| hex) +} + +fn note_gradient_stop_color(color: &str) -> Option<(String, f64)> { + let trimmed = color.trim(); + if let Some(hex) = trimmed.strip_prefix('#') { + if matches!(hex.len(), 3 | 4 | 6 | 8) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + let channels = if matches!(hex.len(), 3 | 4) { + hex.chars() + .take(3) + .flat_map(|character| [character, character]) + .collect::() + } else { + hex[..6].to_string() + }; + let alpha = match hex.len() { + 4 => { + let digit = &hex[3..4]; + u8::from_str_radix(&format!("{digit}{digit}"), 16).ok()? as f64 / 255.0 + } + 8 => u8::from_str_radix(&hex[6..8], 16).ok()? as f64 / 255.0, + _ => 1.0, + }; + return Some((format!("#{}", channels.to_ascii_uppercase()), alpha)); + } + return None; + } + + let open = trimmed.find('(')?; + if !trimmed.ends_with(')') { + return None; + } + let name = &trimmed[..open]; + let channels = trimmed[open + 1..trimmed.len() - 1] + .split(',') + .map(str::trim) + .collect::>(); + let expected_channels = if name.eq_ignore_ascii_case("rgb") { + 3 + } else if name.eq_ignore_ascii_case("rgba") { + 4 + } else { + return None; + }; + if channels.len() != expected_channels { + return None; + } + + let rgb = channels[..3] + .iter() + .map(|channel| { + if !(1..=3).contains(&channel.len()) + || !channel.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + channel.parse::().ok().filter(|value| *value <= 255) + }) + .collect::>>()?; + + let alpha = if expected_channels == 4 { + let alpha = channels[3]; + let mut parts = alpha.split('.'); + let whole = parts.next().unwrap_or_default(); + let fractional = parts.next(); + let decimal_syntax = parts.next().is_none() + && whole.bytes().all(|byte| byte.is_ascii_digit()) + && fractional.is_none_or(|digits| { + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) + }) + && (!whole.is_empty() || fractional.is_some()); + if !decimal_syntax { + return None; + } + let alpha = alpha.parse::().ok()?; + if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { + return None; + } + alpha + } else { + 1.0 + }; + + Some(( + format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]), + alpha, + )) +} + +pub(crate) fn compact_canonical_rgba(color: &str) -> String { + let trimmed = color.trim(); + if let Some(hex) = trimmed.strip_prefix('#') { + if matches!(hex.len(), 3 | 6 | 8) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + let expanded; + let hex = if hex.len() == 3 { + expanded = hex + .chars() + .flat_map(|character| [character, character]) + .collect::(); + expanded.as_str() + } else { + hex + }; + let red = u8::from_str_radix(&hex[0..2], 16).expect("validated hex channel"); + let green = u8::from_str_radix(&hex[2..4], 16).expect("validated hex channel"); + let blue = u8::from_str_radix(&hex[4..6], 16).expect("validated hex channel"); + let alpha = if hex.len() == 8 { + f64::from(u8::from_str_radix(&hex[6..8], 16).expect("validated alpha channel")) + / 255.0 + } else { + 1.0 + }; + return format!("rgba({red},{green},{blue},{})", format_compact_alpha(alpha)); + } + } + + let functional = trimmed + .strip_prefix("rgba(") + .or_else(|| trimmed.strip_prefix("rgb(")); + if let Some(body) = functional { + if let Some(body) = body.strip_suffix(')') { + let channels = body.split(',').map(str::trim).collect::>(); + if matches!(channels.len(), 3 | 4) + && channels.iter().all(|channel| { + !channel.is_empty() + && channel + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + }) + { + let parsed = channels + .iter() + .map(|channel| channel.parse::()) + .collect::, _>>(); + if let Ok(parsed) = parsed { + let alpha = parsed.get(3).copied().unwrap_or(1.0); + return format!( + "rgba({},{},{},{})", + parsed[0].round() as i64, + parsed[1].round() as i64, + parsed[2].round() as i64, + format_compact_alpha(alpha) + ); + } + } + } + } + + trimmed.to_string() +} + +fn format_compact_alpha(alpha: f64) -> String { + let rounded = (alpha.clamp(0.0, 1.0) * 10_000.0).round() / 10_000.0; + let formatted = format!("{rounded:.4}"); + let compact = formatted.trim_end_matches('0').trim_end_matches('.'); + if compact.is_empty() { + "0".to_string() + } else { + compact.to_string() + } +} diff --git a/src-tauri/src/models/key_slot.rs b/src-tauri/src/models/key_slot.rs new file mode 100644 index 000000000..3e750bc89 --- /dev/null +++ b/src-tauri/src/models/key_slot.rs @@ -0,0 +1,163 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +pub const MAX_SLOT_KEYS: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SlotMatch { + All, + Any, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged, from = "RawKeySlot")] +pub enum KeySlot { + Single(String), + Multi { + keys: Vec, + #[serde(rename = "match")] + match_mode: SlotMatch, + }, +} + +#[derive(Deserialize)] +#[serde(transparent)] +struct RawKeySlot(serde_json::Value); + +impl From for KeySlot { + fn from(raw: RawKeySlot) -> Self { + normalize_key_slot(raw.0) + } +} + +impl From for KeySlot { + fn from(value: String) -> Self { + Self::Single(value) + } +} + +impl From<&str> for KeySlot { + fn from(value: &str) -> Self { + Self::Single(value.to_string()) + } +} + +impl Default for KeySlot { + fn default() -> Self { + Self::Single(String::new()) + } +} + +impl KeySlot { + pub fn canonical(&self) -> String { + match self { + Self::Single(key) => key.clone(), + Self::Multi { + keys, + match_mode: SlotMatch::All, + } => keys.join("+"), + Self::Multi { + keys, + match_mode: SlotMatch::Any, + } => keys.join("|"), + } + } + + pub fn members(&self) -> std::slice::Iter<'_, String> { + match self { + Self::Single(key) => std::slice::from_ref(key).iter(), + Self::Multi { keys, .. } => keys.iter(), + } + } + + pub fn is_unassigned(&self) -> bool { + matches!(self, Self::Single(key) if key.is_empty()) + } + + pub fn is_multi(&self) -> bool { + matches!(self, Self::Multi { .. }) + } +} + +pub fn normalize_key_slot(raw: serde_json::Value) -> KeySlot { + match raw { + serde_json::Value::String(key) => { + if (key.contains('+') && key != "+") || key.contains('|') { + log::warn!( + "[Store] Key slot string contains a reserved canonical separator and may collide visually" + ); + } + KeySlot::Single(key) + } + serde_json::Value::Object(object) => { + let has_unknown_fields = object.keys().any(|key| key != "keys" && key != "match"); + let match_mode = match object.get("match").and_then(serde_json::Value::as_str) { + Some("all") => SlotMatch::All, + Some("any") => SlotMatch::Any, + _ => { + log::warn!("[Store] Normalized an invalid multi-key slot to an unassigned key"); + return KeySlot::default(); + } + }; + + let mut changed = has_unknown_fields; + let mut seen = HashSet::new(); + let mut keys = Vec::new(); + let entries = match object.get("keys") { + Some(serde_json::Value::Array(entries)) => entries.as_slice(), + _ => { + changed = true; + &[] + } + }; + for entry in entries { + let Some(key) = entry.as_str() else { + changed = true; + continue; + }; + if key.is_empty() || key.contains('+') || key.contains('|') { + changed = true; + continue; + } + if !seen.insert(key.to_string()) { + changed = true; + continue; + } + if keys.len() == MAX_SLOT_KEYS { + changed = true; + continue; + } + keys.push(key.to_string()); + } + + let normalized = match keys.len() { + 0 => KeySlot::default(), + 1 => KeySlot::Single(keys.pop().unwrap_or_default()), + _ => KeySlot::Multi { keys, match_mode }, + }; + changed |= !normalized.is_multi(); + if changed { + log::warn!("[Store] Normalized a malformed multi-key slot"); + } + normalized + } + _ => { + log::warn!("[Store] Normalized an invalid key slot to an unassigned key"); + KeySlot::default() + } + } +} + +pub fn normalize_key_mappings(mappings: &mut HashMap>) { + for slot in mappings.values_mut().flatten() { + let raw = serde_json::to_value(&*slot).unwrap_or(serde_json::Value::Null); + *slot = normalize_key_slot(raw); + } +} + +pub fn key_mappings_contain_multi(mappings: &HashMap>) -> bool { + mappings.values().flatten().any(KeySlot::is_multi) +} + +pub type KeyMappings = HashMap>; diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index ec4f02490..30a27faaf 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -3,188 +3,63 @@ pub mod gesture; pub mod obs; pub mod plugin; +mod bootstrap; +mod counter; +mod element_positions; +mod key_position_normalization; +mod key_slot; +mod note_settings; +mod settings; +mod shortcuts; + +pub use bootstrap::*; +pub use counter::{ + default_counter_animation_builtin_presets, default_counter_animation_preset_id, + find_builtin_counter_animation_preset_by_id, infer_builtin_counter_animation_preset_id, + normalize_user_counter_animation_presets, CounterAnimationPreset, CounterAnimationSource, + KeyCounterAlign, KeyCounterAlignMode, KeyCounterAnimationSettings, KeyCounterColor, + KeyCounterPlacement, KeyCounterSettings, +}; +use counter::{default_counter_font_size, default_counter_font_weight, default_gap}; pub use editor::*; +use element_positions::default_true; +pub use element_positions::{ + ElementShadowSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, ImageMode, + ImageTransform, KeyPosition, KeyPositions, KnobPosition, KnobPositions, NoteAlignment, + StatPosition, StatPositions, StatType, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, + IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, + SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, +}; pub use gesture::*; +pub use key_slot::{ + key_mappings_contain_multi, normalize_key_mappings, normalize_key_slot, KeyMappings, KeySlot, + SlotMatch, MAX_SLOT_KEYS, +}; +use note_settings::{ + default_key_height, default_key_note_color, default_key_note_opacity, + default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled, + default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size, +}; +pub use note_settings::{FadePosition, ImageFit, NoteSettings, TabNoteOverrides, TabNoteSettings}; pub use plugin::*; +pub use settings::*; +pub use shortcuts::*; + +pub(crate) use key_position_normalization::{ + compact_canonical_rgba, default_missing_note_gradient_multipliers, + note_border_representative_hex, note_gradient_shadow, scrub_removed_text_outline_fields, + NoteGradientShadow, +}; use serde::de::Error as DeError; use serde::ser::{Error as SerError, SerializeMap}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; use uuid::Uuid; -pub const MAX_SLOT_KEYS: usize = 8; -pub const POSITION_COLLECTION_FIELDS: [&str; 4] = [ - "keyPositions", - "statPositions", - "graphPositions", - "knobPositions", -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SlotMatch { - All, - Any, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged, from = "RawKeySlot")] -pub enum KeySlot { - Single(String), - Multi { - keys: Vec, - #[serde(rename = "match")] - match_mode: SlotMatch, - }, -} - -#[derive(Deserialize)] -#[serde(transparent)] -struct RawKeySlot(serde_json::Value); - -impl From for KeySlot { - fn from(raw: RawKeySlot) -> Self { - normalize_key_slot(raw.0) - } -} - -impl From for KeySlot { - fn from(value: String) -> Self { - Self::Single(value) - } -} - -impl From<&str> for KeySlot { - fn from(value: &str) -> Self { - Self::Single(value.to_string()) - } -} - -impl Default for KeySlot { - fn default() -> Self { - Self::Single(String::new()) - } -} - -impl KeySlot { - pub fn canonical(&self) -> String { - match self { - Self::Single(key) => key.clone(), - Self::Multi { - keys, - match_mode: SlotMatch::All, - } => keys.join("+"), - Self::Multi { - keys, - match_mode: SlotMatch::Any, - } => keys.join("|"), - } - } - - pub fn members(&self) -> std::slice::Iter<'_, String> { - match self { - Self::Single(key) => std::slice::from_ref(key).iter(), - Self::Multi { keys, .. } => keys.iter(), - } - } - - pub fn is_unassigned(&self) -> bool { - matches!(self, Self::Single(key) if key.is_empty()) - } - - pub fn is_multi(&self) -> bool { - matches!(self, Self::Multi { .. }) - } -} - -pub fn normalize_key_slot(raw: serde_json::Value) -> KeySlot { - match raw { - serde_json::Value::String(key) => { - if (key.contains('+') && key != "+") || key.contains('|') { - log::warn!( - "[Store] Key slot string contains a reserved canonical separator and may collide visually" - ); - } - KeySlot::Single(key) - } - serde_json::Value::Object(object) => { - let has_unknown_fields = object.keys().any(|key| key != "keys" && key != "match"); - let match_mode = match object.get("match").and_then(serde_json::Value::as_str) { - Some("all") => SlotMatch::All, - Some("any") => SlotMatch::Any, - _ => { - log::warn!("[Store] Normalized an invalid multi-key slot to an unassigned key"); - return KeySlot::default(); - } - }; - - let mut changed = has_unknown_fields; - let mut seen = HashSet::new(); - let mut keys = Vec::new(); - let entries = match object.get("keys") { - Some(serde_json::Value::Array(entries)) => entries.as_slice(), - _ => { - changed = true; - &[] - } - }; - for entry in entries { - let Some(key) = entry.as_str() else { - changed = true; - continue; - }; - if key.is_empty() || key.contains('+') || key.contains('|') { - changed = true; - continue; - } - if !seen.insert(key.to_string()) { - changed = true; - continue; - } - if keys.len() == MAX_SLOT_KEYS { - changed = true; - continue; - } - keys.push(key.to_string()); - } - - let normalized = match keys.len() { - 0 => KeySlot::default(), - 1 => KeySlot::Single(keys.pop().unwrap_or_default()), - _ => KeySlot::Multi { keys, match_mode }, - }; - changed |= !normalized.is_multi(); - if changed { - log::warn!("[Store] Normalized a malformed multi-key slot"); - } - normalized - } - _ => { - log::warn!("[Store] Normalized an invalid key slot to an unassigned key"); - KeySlot::default() - } - } -} - -pub fn normalize_key_mappings(mappings: &mut HashMap>) { - for slot in mappings.values_mut().flatten() { - let raw = serde_json::to_value(&*slot).unwrap_or(serde_json::Value::Null); - *slot = normalize_key_slot(raw); - } -} - -pub fn key_mappings_contain_multi(mappings: &HashMap>) -> bool { - mappings.values().flatten().any(KeySlot::is_multi) -} - -pub type KeyMappings = HashMap>; -pub type KeyPositions = HashMap>; pub type KeyCounters = HashMap>; -pub type StatPositions = HashMap>; -pub type GraphPositions = HashMap>; -pub type KnobPositions = HashMap>; const DEFAULT_GRADIENT_ANGLE: f64 = 90.0; const MAX_GRADIENT_STOPS: usize = 8; @@ -491,2126 +366,468 @@ impl<'de> Deserialize<'de> for NoteColor { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "camelCase")] #[derive(Default)] -pub enum NoteAlignment { - Left, - #[default] - Center, - Right, -} - -// 그림자 범위 계약 — 프론트 zod(ELEMENT_SHADOW_CONSTRAINTS)와 동기 유지 -pub const SHADOW_OFFSET_MIN: f64 = -100.0; -pub const SHADOW_OFFSET_MAX: f64 = 100.0; -pub const SHADOW_BLUR_MIN: f64 = 0.0; -pub const SHADOW_BLUR_MAX: f64 = 100.0; - -pub const IMAGE_TRANSFORM_OFFSET_MIN: f64 = -500.0; -pub const IMAGE_TRANSFORM_OFFSET_MAX: f64 = 500.0; -pub const IMAGE_TRANSFORM_ROTATION_MIN: f64 = -180.0; -pub const IMAGE_TRANSFORM_ROTATION_MAX: f64 = 180.0; -pub const IMAGE_TRANSFORM_SCALE_MIN: f64 = 0.1; -pub const IMAGE_TRANSFORM_SCALE_MAX: f64 = 10.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ImageMode { - Replace, - Overlay, +pub struct CustomCss { + pub path: Option, + pub content: String, } -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct ImageTransform { - pub offset_x: f64, - pub offset_y: f64, - pub rotation: f64, - pub scale: f64, -} - -impl Default for ImageTransform { - fn default() -> Self { - Self { - offset_x: 0.0, - offset_y: 0.0, - rotation: 0.0, - scale: 1.0, - } - } +pub struct CustomCssHistoryEntry { + pub path: String, + #[serde(default)] + pub loaded_at: i64, + pub last_used_at: i64, } +/// 탭별 CSS 설정 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct ElementShadowSpec { +pub struct TabCss { + pub path: Option, + pub content: String, + /// 이 탭에서 CSS 사용 여부 (false면 전역/탭 CSS 모두 미적용) + #[serde(default = "default_tab_css_enabled")] pub enabled: bool, - pub color: String, - pub offset_x: f64, - pub offset_y: f64, - pub blur: f64, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KeyPosition { - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - pub dx: f64, - pub dy: f64, - pub width: f64, - #[serde(default = "default_key_height")] - pub height: f64, - /// 레이어 표시 여부 (true면 숨김) - #[serde(default)] - pub hidden: bool, - #[serde(default)] - pub active_image: Option, - #[serde(default)] - pub inactive_image: Option, - /// 키별 사운드 활성화 여부 (기본값 false) - #[serde(default)] - pub sound_enabled: Option, - /// 키 입력 시 재생할 로컬 사운드 파일 경로 - #[serde(default)] - pub sound_path: Option, - /// 키별 사운드 볼륨 (0~200, 기본값 100) - #[serde(default)] - pub sound_volume: Option, - #[serde(default)] - pub active_transparent: bool, - #[serde(default)] - pub idle_transparent: bool, - pub count: u32, - #[serde(default = "default_key_note_color")] - pub note_color: NoteColor, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note_gradient: Option, - #[serde(default = "default_key_note_opacity")] - pub note_opacity: u32, - #[serde(default)] - pub note_opacity_top: Option, - #[serde(default)] - pub note_opacity_bottom: Option, - #[serde(default)] - pub note_border_radius: Option, - /// 노트 넓이(px). None이면 키 width를 사용(자동). - #[serde(default)] - pub note_width: Option, - /// 노트 정렬 (left/center/right). 기본값 center. - #[serde(default)] - pub note_alignment: NoteAlignment, - #[serde(default = "default_note_effect_enabled")] - pub note_effect_enabled: bool, - #[serde(default = "default_note_glow_enabled")] - pub note_glow_enabled: bool, - #[serde(default)] - pub note_glow_sync_paint: bool, - #[serde(default = "default_note_glow_size")] - pub note_glow_size: f64, - #[serde(default = "default_note_glow_opacity")] - pub note_glow_opacity: u32, - #[serde(default)] - pub note_glow_opacity_top: Option, - #[serde(default)] - pub note_glow_opacity_bottom: Option, - #[serde(default)] - pub note_glow_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note_glow_gradient: Option, - #[serde(default = "default_note_auto_y_correction")] - pub note_auto_y_correction: bool, - /// 노트 오프셋 X (px). 기본 정렬에 추가 보정값. - #[serde(default)] - pub note_offset_x: Option, - /// 노트 오프셋 Y (px). 기본 정렬에 추가 보정값. - #[serde(default)] - pub note_offset_y: Option, - /// 노트 테두리 굵기 (px). 0이면 테두리 없음. - #[serde(default)] - pub note_border_width: Option, - /// 노트 테두리 색상 - #[serde(default)] - pub note_border_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note_border_gradient: Option, - /// 노트 테두리 투명도 (0~100). 노트 배경 투명도와 독립. 기본 100. - #[serde(default = "default_note_border_opacity")] - pub note_border_opacity: u32, - /// 노트 테두리 방향 (all/vertical/horizontal) - #[serde(default)] - pub note_border_side: Option, - #[serde(default)] - pub class_name: Option, - #[serde(default)] - pub z_index: Option, - #[serde(default)] - pub counter: KeyCounterSettings, - // 스타일 관련 속성들 - #[serde(default)] - pub background_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_gradient: Option, - #[serde(default)] - pub active_background_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_background_gradient: Option, - #[serde(default)] - pub border_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub border_gradient: Option, - #[serde(default)] - pub active_border_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_border_gradient: Option, - #[serde(default)] - pub border_width: Option, - #[serde(default)] - pub border_radius: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub shadow: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_shadow: Option, - #[serde(default)] - pub font_size: Option, - #[serde(default)] - pub font_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_gradient: Option, - #[serde(default)] - pub active_font_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_font_gradient: Option, - #[serde(default)] - pub graph_animation_enabled: Option, - /// 글꼴 패밀리 (커스텀 폰트 이름) - #[serde(default)] - pub font_family: Option, - #[serde(default)] - pub image_fit: Option, - /// 이미지 맞춤(대기/입력 개별). 없으면 image_fit을 fallback으로 사용. - #[serde(default)] - pub idle_image_fit: Option, - #[serde(default)] - pub active_image_fit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub idle_image_transform: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_image_transform: Option, - /// 인라인 스타일 우선 여부 (true: 속성 패널 스타일 우선, false: 커스텀 CSS 우선) - #[serde(default)] - pub use_inline_styles: Option, - /// 키에 표시할 커스텀 텍스트 (None이면 기본 키 이름 표시) - #[serde(default)] - pub display_text: Option, - /// 글꼴 굵기 (CSS font-weight 값, 예: 400, 700) - #[serde(default)] - pub font_weight: Option, - /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 - /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_bold: Option, - /// 이탤릭체 여부 - #[serde(default)] - pub font_italic: Option, - /// 밑줄 여부 - #[serde(default)] - pub font_underline: Option, - /// 취소선 여부 - #[serde(default)] - pub font_strikethrough: Option, - /// 레이어 패널에서 표시할 커스텀 이름 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer_name: Option, - /// 레이어 그룹 ID - #[serde(default, skip_serializing_if = "Option::is_none")] - pub group_id: Option, +fn default_tab_css_enabled() -> bool { + true } -impl Default for KeyPosition { +impl Default for TabCss { fn default() -> Self { Self { - id: String::new(), - dx: 0.0, - dy: 0.0, - width: 60.0, - height: default_key_height(), - hidden: false, - active_image: None, - inactive_image: None, - sound_enabled: None, - sound_path: None, - sound_volume: None, - active_transparent: false, - idle_transparent: false, - count: 0, - note_color: default_key_note_color(), - note_gradient: None, - note_opacity: default_key_note_opacity(), - note_opacity_top: None, - note_opacity_bottom: None, - note_border_radius: None, - note_width: None, - note_alignment: NoteAlignment::default(), - note_effect_enabled: default_note_effect_enabled(), - note_glow_enabled: default_note_glow_enabled(), - note_glow_sync_paint: false, - note_glow_size: default_note_glow_size(), - note_glow_opacity: default_note_glow_opacity(), - note_glow_opacity_top: None, - note_glow_opacity_bottom: None, - note_glow_color: None, - note_glow_gradient: None, - note_auto_y_correction: default_note_auto_y_correction(), - note_offset_x: None, - note_offset_y: None, - note_border_width: None, - note_border_color: None, - note_border_gradient: None, - note_border_opacity: default_note_border_opacity(), - note_border_side: None, - class_name: None, - z_index: None, - counter: KeyCounterSettings::default(), - background_color: None, - background_gradient: None, - active_background_color: None, - active_background_gradient: None, - border_color: None, - border_gradient: None, - active_border_color: None, - active_border_gradient: None, - border_width: None, - border_radius: None, - shadow: None, - active_shadow: None, - font_size: None, - font_color: None, - font_gradient: None, - active_font_color: None, - active_font_gradient: None, - graph_animation_enabled: None, - font_family: None, - image_fit: None, - idle_image_fit: None, - active_image_fit: None, - image_mode: None, - idle_image_transform: None, - active_image_transform: None, - use_inline_styles: None, - display_text: None, - font_weight: Some(400), - font_bold: Some(true), - font_italic: None, - font_underline: None, - font_strikethrough: None, - layer_name: None, - group_id: None, + path: None, + content: String::new(), + enabled: true, } } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum StatType { - Kps, - KpsAvg, - KpsMax, - Total, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StatPosition { - pub stat_type: StatType, - #[serde(flatten)] - pub position: KeyPosition, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub enum GraphStatType { - Kps, - KpsAvg, - KpsMax, - Total, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum GraphType { - Line, - Bar, -} +/// 탭별 CSS 오버라이드 맵 (키: 탭 ID, 값: TabCss) +pub type TabCssOverrides = HashMap; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct GraphPosition { - pub stat_type: GraphStatType, - pub graph_type: GraphType, - pub graph_speed: u32, - pub graph_color: String, - #[serde(default = "default_true")] - pub show_avg_line: bool, - #[serde(flatten)] - pub position: KeyPosition, -} - -fn default_knob_sensitivity() -> f64 { - // 순수 배율 — 1이면 축 해상도와 무관하게 물리 1회전 ≈ 화면 1회전 - // (프론트엔드가 wrap 델타를 축 해상도로 정규화해 회전수 단위로 누적) - 1.0 +pub struct JsPlugin { + pub id: String, + pub name: String, + pub path: Option, + pub content: String, + pub enabled: bool, } -/// 회전(노브) 요소 — HID 축에 바인딩. KeyPosition 상속(표시명/스타일/클래스/이미지) + 노브 전용 필드. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct KnobPosition { - /// 바인딩된 HID 축 식별자 "HIDA:vid:pid:usagePage:usage" +#[derive(Default)] +pub struct CustomJs { + #[serde(default)] + pub path: Option, #[serde(default)] - pub axis_id: String, - /// 회전 배율 (물리 1회전당 화면 회전 수) - #[serde(default = "default_knob_sensitivity")] - pub sensitivity: f64, - /// 회전 방향 반전 + pub content: String, #[serde(default)] - pub reverse: bool, - #[serde(flatten)] - pub position: KeyPosition, + pub plugins: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum KeyCounterPlacement { - #[default] - Inside, - Outside, -} +impl CustomJs { + pub fn normalize(&mut self) -> bool { + let mut mutated = false; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum KeyCounterAlign { - Top, - // align 필드 부재 시에도 새 기본 배치와 일치하도록 serde 기본값 겸용 - #[default] - Bottom, - Left, - Right, + for plugin in self.plugins.iter_mut() { + if plugin.id.trim().is_empty() { + plugin.id = Uuid::new_v4().to_string(); + mutated = true; + } + if plugin.name.trim().is_empty() { + plugin.name = plugin + .path + .as_deref() + .and_then(|value| Path::new(value).file_name()) + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "plugin.js".to_string()); + mutated = true; + } + } + + if self.plugins.is_empty() && (self.path.is_some() || !self.content.is_empty()) { + let name = self + .path + .as_deref() + .and_then(|value| Path::new(value).file_name()) + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "custom.js".to_string()); + let plugin = JsPlugin { + id: Uuid::new_v4().to_string(), + name, + path: self.path.clone(), + content: self.content.clone(), + enabled: true, + }; + self.plugins.push(plugin); + self.path = None; + self.content.clear(); + mutated = true; + } + + mutated + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] #[derive(Default)] -pub enum KeyCounterAlignMode { +pub enum OverlayResizeAnchor { #[default] + TopLeft, + TopRight, + BottomLeft, + BottomRight, Center, - Between, + FixedPosition, } +/// 그리드 스마트 가이드 설정 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct KeyCounterColor { - pub idle: String, - pub active: String, -} - -impl Default for KeyCounterColor { - fn default() -> Self { - Self { - // 렌더러 기본 키 텍스트 색과 일치 (utils/core/elementDefaults.ts) - idle: "rgba(237, 238, 242, 0.78)".to_string(), - active: "rgba(20, 20, 24, 0.9)".to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum CounterAnimationSource { - Builtin, - User, +pub struct GridSettings { + /// 정렬 가이드 활성화 (드래그/리사이즈 시 요소 정렬 스냅) + #[serde(default = "default_true")] + pub alignment_guides: bool, + /// 간격 일치 가이드 활성화 (요소 간 간격 일치 스냅) + #[serde(default = "default_true")] + pub spacing_guides: bool, + /// 크기 일치 가이드 활성화 (리사이즈 시 크기 일치 스냅) + #[serde(default = "default_true")] + pub size_match_guides: bool, + /// 미니맵 표시 여부 + #[serde(default = "default_true")] + pub minimap_enabled: bool, + /// 그리드 스냅 크기 (0-10px, 0은 끄기) + #[serde(default = "default_grid_snap_size")] + pub grid_snap_size: u32, + /// 오버레이 여백 (0-30px) + #[serde(default = "default_overlay_padding")] + pub overlay_padding: u32, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CounterAnimationPreset { - pub id: String, - pub name: String, - #[serde(rename = "source")] - pub source: CounterAnimationSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label_key: Option, - pub bezier: [f64; 4], - pub scale: f64, - pub duration_ms: u32, +fn default_auto_update_enabled() -> bool { + true } -impl CounterAnimationPreset { - pub fn normalize(&mut self) { - let mut animation = KeyCounterAnimationSettings { - enabled: true, - preset_id: None, - bezier: self.bezier, - scale: self.scale, - duration_ms: self.duration_ms, - }; - animation.normalize(); - self.bezier = animation.bezier; - self.scale = animation.scale; - self.duration_ms = animation.duration_ms; - self.name = self.name.trim().to_string(); - } - - pub fn is_valid_user_entry(&self) -> bool { - self.source == CounterAnimationSource::User - && !self.id.trim().is_empty() - && !self.name.trim().is_empty() +// 렌더러 백엔드 기본값, macOS는 metal 고정 그 외는 d3d11 +pub(crate) fn default_angle_mode() -> String { + if cfg!(target_os = "macos") { + "metal".to_string() + } else { + "d3d11".to_string() } } -fn builtin_counter_animation_presets() -> Vec { - vec![ - CounterAnimationPreset { - id: "builtin-ease-out".to_string(), - name: "Default".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.25, 0.46, 0.45, 0.94], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-linear".to_string(), - name: "Linear".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.0, 0.0, 1.0, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-ease-out-css".to_string(), - name: "Ease Out".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.0, 0.0, 0.58, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-ease-in".to_string(), - name: "Ease In".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.42, 0.0, 1.0, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-ease-in-out".to_string(), - name: "Ease In-Out".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.42, 0.0, 0.58, 1.0], - scale: 1.1, - duration_ms: 300, - }, - CounterAnimationPreset { - id: "builtin-overshoot".to_string(), - name: "Overshoot".to_string(), - source: CounterAnimationSource::Builtin, - label_key: None, - bezier: [0.34, 1.56, 0.64, 1.0], - scale: 1.15, - duration_ms: 360, - }, - ] -} - -pub fn default_counter_animation_preset_id() -> &'static str { - "builtin-ease-out" -} - -pub fn default_counter_animation_builtin_presets() -> Vec { - builtin_counter_animation_presets() -} - -pub fn find_builtin_counter_animation_preset_by_id(id: &str) -> Option { - builtin_counter_animation_presets() - .into_iter() - .find(|preset| preset.id == id) -} - -pub fn infer_builtin_counter_animation_preset_id( - bezier: [f64; 4], - scale: f64, - duration_ms: u32, -) -> Option { - const BEZIER_EPSILON: f64 = 0.001; - const SCALE_EPSILON: f64 = 0.001; - - builtin_counter_animation_presets() - .into_iter() - .find(|preset| { - let bezier_matches = preset - .bezier - .iter() - .zip(bezier.iter()) - .all(|(a, b)| (*a - *b).abs() <= BEZIER_EPSILON); - let scale_matches = (preset.scale - scale).abs() <= SCALE_EPSILON; - let duration_matches = preset.duration_ms == duration_ms; - bezier_matches && scale_matches && duration_matches - }) - .map(|preset| preset.id) +fn default_obs_port() -> u16 { + obs::DEFAULT_OBS_PORT } -pub fn normalize_user_counter_animation_presets( - presets: Vec, -) -> Vec { - let mut seen_ids = std::collections::HashSet::new(); - let mut normalized: Vec = presets - .into_iter() - .filter_map(|mut preset| { - preset.normalize(); - if !preset.is_valid_user_entry() { - return None; - } - if !seen_ids.insert(preset.id.clone()) { - return None; - } - preset.source = CounterAnimationSource::User; - preset.label_key = None; - Some(preset) - }) - .collect(); - - normalized.sort_by_key(|a| a.name.to_lowercase()); - normalized +fn default_grid_snap_size() -> u32 { + 5 } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct KeyCounterAnimationSettings { - #[serde(default = "default_counter_animation_enabled")] - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preset_id: Option, - #[serde(default = "default_counter_animation_bezier")] - pub bezier: [f64; 4], - #[serde(default = "default_counter_animation_scale")] - pub scale: f64, - #[serde(default = "default_counter_animation_duration_ms")] - pub duration_ms: u32, +fn default_overlay_padding() -> u32 { + 30 } -impl Default for KeyCounterAnimationSettings { +impl Default for GridSettings { fn default() -> Self { Self { - enabled: default_counter_animation_enabled(), - preset_id: Some(default_counter_animation_preset_id().to_string()), - bezier: default_counter_animation_bezier(), - scale: default_counter_animation_scale(), - duration_ms: default_counter_animation_duration_ms(), + alignment_guides: true, + spacing_guides: true, + size_match_guides: true, + minimap_enabled: true, + grid_snap_size: default_grid_snap_size(), + overlay_padding: default_overlay_padding(), } } } -impl KeyCounterAnimationSettings { - pub fn normalize(&mut self) { - let fallback = default_counter_animation_bezier(); - for i in [0, 2] { - let value = self.bezier[i]; - self.bezier[i] = if value.is_finite() { - value.clamp(0.0, 1.0) - } else { - fallback[i] - }; - } - for i in [1, 3] { - let value = self.bezier[i]; - self.bezier[i] = if value.is_finite() { - value.clamp(-2.0, 2.0) - } else { - fallback[i] - }; - } - - self.scale = if self.scale.is_finite() { - self.scale - } else { - default_counter_animation_scale() - }; - self.duration_ms = self.duration_ms.clamp(1, 5000); - - self.preset_id = self - .preset_id - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - - if self.preset_id.is_none() { - self.preset_id = infer_builtin_counter_animation_preset_id( - self.bezier, - self.scale, - self.duration_ms, - ); - } - } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct OverlayBounds { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct KeyCounterSettings { - #[serde(default = "default_counter_enabled")] - pub enabled: bool, - #[serde(default)] - pub placement: KeyCounterPlacement, - #[serde(default)] - pub align: KeyCounterAlign, - #[serde(default)] - pub align_mode: KeyCounterAlignMode, - #[serde(default)] - pub fill: KeyCounterColor, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fill_idle_gradient: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fill_active_gradient: Option, - #[serde(default = "default_gap")] - pub gap: u32, - #[serde(default = "default_counter_font_size")] - pub font_size: u32, - #[serde(default = "default_counter_font_weight")] - pub font_weight: u32, - /// 선택 굵기에 +300을 적용하는 Bold 토글 - None은 직렬화하지 않는다 - /// (IPC에서 null로 나가면 프론트 스키마가 거부해 설정 전체가 기본값으로 떨어진다) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font_bold: Option, - /// 카운터 글꼴 패밀리 (커스텀 폰트 이름) - #[serde(default)] - pub font_family: Option, - #[serde(default)] - pub font_italic: bool, - #[serde(default)] - pub font_underline: bool, - #[serde(default)] - pub font_strikethrough: bool, - #[serde(default)] - pub animation: KeyCounterAnimationSettings, +pub struct StoredOverlayNativePosition { + pub x: f64, + pub y: f64, + pub logical_echo_x: f64, + pub logical_echo_y: f64, } -impl Default for KeyCounterSettings { - fn default() -> Self { - Self { - enabled: true, - placement: KeyCounterPlacement::Inside, - align: KeyCounterAlign::Bottom, - align_mode: KeyCounterAlignMode::Center, - fill: KeyCounterColor::default(), - fill_idle_gradient: None, - fill_active_gradient: None, - gap: default_gap(), - font_size: default_counter_font_size(), - font_weight: default_counter_font_weight(), - font_bold: Some(false), - font_family: None, - font_italic: false, - font_underline: false, - font_strikethrough: false, - animation: KeyCounterAnimationSettings::default(), - } - } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StoredOverlayBounds { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_position: Option, } -impl KeyCounterSettings { - pub(crate) fn migrate_legacy_font_weight(&mut self) -> bool { - if self.font_bold.is_some() || self.font_weight != 700 { - return false; - } - - self.font_weight = 400; - self.font_bold = Some(true); - true - } - - pub fn normalize(&mut self) { - self.animation.normalize(); - } - - /// Migrate legacy defaults that were previously serialized into store.json. - /// This keeps existing user customizations intact, while fixing old defaults - /// that diverged from the renderer. - // 당시 직렬화되던 스냅샷 값 고정 - fn matches_legacy_default_snapshot(&self) -> bool { - self.fill.idle == "#FFFFFF" - && self.fill.active == "#000000" - && matches!(self.placement, KeyCounterPlacement::Inside) - && matches!(self.align, KeyCounterAlign::Top) - && matches!(self.align_mode, KeyCounterAlignMode::Center) - && self.gap == 6 - && self.font_size == 16 - && self.font_weight == 400 - && self.font_bold.is_none() - && self.font_family.is_none() - && !self.font_italic - && !self.font_underline - && !self.font_strikethrough - } - - // 직전 기본값 스냅샷(회색 카운터·16px·700·상단 배치) 전 필드 일치 검사 - fn matches_previous_default_snapshot(&self) -> bool { - self.fill.idle == "rgba(121, 121, 121, 0.9)" - && self.fill.active == "#FFFFFF" - && matches!(self.placement, KeyCounterPlacement::Inside) - && matches!(self.align, KeyCounterAlign::Top) - && matches!(self.align_mode, KeyCounterAlignMode::Center) - && self.gap == 6 - && self.font_size == 16 - && self.font_weight == 700 - && self.font_bold.is_none() - && self.font_family.is_none() - && !self.font_italic - && !self.font_underline - && !self.font_strikethrough - } - - pub fn migrate_legacy_defaults(&mut self) -> bool { - if self.fill_idle_gradient.is_some() || self.fill_active_gradient.is_some() { - self.normalize(); - return false; - } - - if self.matches_legacy_default_snapshot() { - self.fill = KeyCounterColor::default(); - self.align = KeyCounterAlign::Bottom; - self.gap = default_gap(); - self.font_size = default_counter_font_size(); - self.font_weight = default_counter_font_weight(); - self.font_bold = Some(false); - self.animation = KeyCounterAnimationSettings::default(); - self.normalize(); - return true; - } - - if self.matches_previous_default_snapshot() { - self.fill = KeyCounterColor::default(); - self.align = KeyCounterAlign::Bottom; - self.gap = default_gap(); - self.font_size = default_counter_font_size(); - self.font_weight = default_counter_font_weight(); - self.font_bold = Some(false); - self.normalize(); - return true; +impl StoredOverlayBounds { + pub fn public_bounds(&self) -> OverlayBounds { + OverlayBounds { + x: self.x, + y: self.y, + width: self.width, + height: self.height, } - - self.normalize(); - false - } - - pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { - let mut changed = false; - let mut pair_repaired = false; - - let (idle_changed, idle_pair_repaired) = - canonicalize_counter_gradient_pair(&mut self.fill.idle, &mut self.fill_idle_gradient); - changed |= idle_changed; - pair_repaired |= idle_pair_repaired; - - let (active_changed, active_pair_repaired) = canonicalize_counter_gradient_pair( - &mut self.fill.active, - &mut self.fill_active_gradient, - ); - changed |= active_changed; - pair_repaired |= active_pair_repaired; - - (changed, pair_repaired) } } -fn removed_counter_stroke_matches( - stroke: Option<&serde_json::Value>, - expected_idle: &str, - expected_active: &str, -) -> bool { - let Some(stroke) = stroke else { - return expected_idle == "transparent" && expected_active == "transparent"; - }; - let Some(stroke) = stroke.as_object() else { - return false; - }; - stroke.get("idle").and_then(serde_json::Value::as_str) == Some(expected_idle) - && stroke.get("active").and_then(serde_json::Value::as_str) == Some(expected_active) -} - -fn escape_removed_counter_stroke_collision( - counter: &mut serde_json::Map, - removed_stroke: Option<&serde_json::Value>, - had_removed_gradient: bool, -) -> bool { - let Ok(parsed) = - serde_json::from_value::(serde_json::Value::Object(counter.clone())) - else { - return false; - }; - let legacy_collision = parsed.matches_legacy_default_snapshot(); - let previous_collision = parsed.matches_previous_default_snapshot(); - let custom_stroke = (legacy_collision - && !removed_counter_stroke_matches(removed_stroke, "#000000", "#FFFFFF")) - || (previous_collision - && !removed_counter_stroke_matches(removed_stroke, "transparent", "transparent")); - if !(had_removed_gradient || custom_stroke) || !(legacy_collision || previous_collision) { - return false; - } - let Some(fill) = counter - .get_mut("fill") - .and_then(serde_json::Value::as_object_mut) - else { - return false; - }; - let mut changed = false; - for state in ["idle", "active"] { - let Some(color) = fill.get(state).and_then(serde_json::Value::as_str) else { - continue; - }; - let escaped = serde_json::Value::String(compact_canonical_rgba(color)); - if fill.get(state) != Some(&escaped) { - fill.insert(state.to_string(), escaped); - changed = true; +impl From for StoredOverlayBounds { + fn from(bounds: OverlayBounds) -> Self { + Self { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + native_position: None, } } - changed -} - -fn scrub_removed_text_outline_from_position(position: &mut serde_json::Value) -> bool { - let Some(position) = position.as_object_mut() else { - return false; - }; - let mut changed = position.remove("fontStrokeColor").is_some(); - changed |= position.remove("activeFontStrokeColor").is_some(); - - let Some(counter) = position - .get_mut("counter") - .and_then(serde_json::Value::as_object_mut) - else { - return changed; - }; - let removed_stroke = counter.remove("stroke"); - let had_removed_gradient = counter.remove("strokeIdleGradient").is_some() - | counter.remove("strokeActiveGradient").is_some(); - changed |= removed_stroke.is_some() || had_removed_gradient; - changed |= escape_removed_counter_stroke_collision( - counter, - removed_stroke.as_ref(), - had_removed_gradient, - ); - changed } -// 제거된 텍스트 외곽선 필드 정리 -pub(crate) fn scrub_removed_text_outline_fields(value: &mut serde_json::Value) -> bool { - let mut changed = false; - for collection in POSITION_COLLECTION_FIELDS { - let Some(modes) = value - .get_mut(collection) - .and_then(serde_json::Value::as_object_mut) - else { - continue; - }; - for position in modes - .values_mut() - .filter_map(serde_json::Value::as_array_mut) - .flatten() - { - changed |= scrub_removed_text_outline_from_position(position); - } - } - changed +/// 분리 패널의 마지막 기하 정보. 복원에 쓰는 값은 height뿐이고 +/// x/y는 이동 기록으로만 남는다 - 패널은 열 때마다 메인 창 옆에 다시 배치된다 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PanelBounds { + pub x: f64, + pub y: f64, + pub height: f64, } - -impl KeyPosition { - pub(crate) fn migrate_legacy_font_weight(&mut self) -> bool { - let mut changed = false; - if self.font_bold.is_none() && self.font_weight == Some(700) { - self.font_weight = Some(400); - self.font_bold = Some(true); - changed = true; - } - - changed | self.counter.migrate_legacy_font_weight() - } - - /// 본체 페인트를 글로우로 복사. 바뀐 게 있으면 true - pub(crate) fn mirror_note_body_to_glow(&mut self) -> bool { - let changed = self.note_glow_gradient != self.note_gradient - || self.note_glow_opacity != self.note_opacity - || self.note_glow_color.as_ref() != Some(&self.note_color) - || self.note_glow_opacity_top != self.note_opacity_top - || self.note_glow_opacity_bottom != self.note_opacity_bottom; - if changed { - self.note_glow_gradient = self.note_gradient.clone(); - self.note_glow_opacity = self.note_opacity; - self.note_glow_color = Some(self.note_color.clone()); - self.note_glow_opacity_top = self.note_opacity_top; - self.note_glow_opacity_bottom = self.note_opacity_bottom; - } - changed - } - - // replace는 sparse 저장(None)이 정본 - 프리셋·플러그인·frozen insert로 들어온 - // Some(Replace)를 접어 이후 ImageMode(Replace) 패치가 빈 undo 항목을 만들지 않게 - pub(crate) fn canonicalize_image_mode(&mut self) -> bool { - if self.image_mode == Some(ImageMode::Replace) { - self.image_mode = None; - true - } else { - false - } - } - - pub(crate) fn canonicalize_gradient_pairs(&mut self) -> (bool, bool) { - let mut changed = false; - let mut pair_repaired = false; - - let (note_changed, note_pair_repaired) = - canonicalize_note_gradient(&mut self.note_gradient, self.note_opacity, |shadow| { - let shadow_changed = self.note_color != shadow.color - || self.note_opacity_top != Some(shadow.opacity_top) - || self.note_opacity_bottom != Some(shadow.opacity_bottom); - self.note_color = shadow.color; - self.note_opacity_top = Some(shadow.opacity_top); - self.note_opacity_bottom = Some(shadow.opacity_bottom); - shadow_changed - }); - changed |= note_changed; - pair_repaired |= note_pair_repaired; - - if self.note_glow_sync_paint { - changed |= self.mirror_note_body_to_glow(); - } - - let (glow_changed, glow_pair_repaired) = canonicalize_note_gradient( - &mut self.note_glow_gradient, - self.note_glow_opacity, - |shadow| { - let shadow_changed = self.note_glow_color.as_ref() != Some(&shadow.color) - || self.note_glow_opacity_top != Some(shadow.opacity_top) - || self.note_glow_opacity_bottom != Some(shadow.opacity_bottom); - self.note_glow_color = Some(shadow.color); - self.note_glow_opacity_top = Some(shadow.opacity_top); - self.note_glow_opacity_bottom = Some(shadow.opacity_bottom); - shadow_changed - }, - ); - changed |= glow_changed; - pair_repaired |= glow_pair_repaired; - - let (note_border_changed, note_border_pair_repaired) = - canonicalize_note_border_gradient_pair( - &mut self.note_border_color, - &mut self.note_border_gradient, - ); - changed |= note_border_changed; - pair_repaired |= note_border_pair_repaired; - - for (base, gradient) in [ - (&mut self.background_color, &mut self.background_gradient), - ( - &mut self.active_background_color, - &mut self.active_background_gradient, - ), - (&mut self.border_color, &mut self.border_gradient), - ( - &mut self.active_border_color, - &mut self.active_border_gradient, - ), - (&mut self.font_color, &mut self.font_gradient), - (&mut self.active_font_color, &mut self.active_font_gradient), - ] { - let (pair_changed, base_repaired) = canonicalize_optional_gradient_pair(base, gradient); - changed |= pair_changed; - pair_repaired |= base_repaired; +impl OverlayResizeAnchor { + pub fn as_str(&self) -> &'static str { + match self { + OverlayResizeAnchor::TopLeft => "top-left", + OverlayResizeAnchor::TopRight => "top-right", + OverlayResizeAnchor::BottomLeft => "bottom-left", + OverlayResizeAnchor::BottomRight => "bottom-right", + OverlayResizeAnchor::Center => "center", + OverlayResizeAnchor::FixedPosition => "fixed-position", } - - let (counter_changed, counter_pair_repaired) = self.counter.canonicalize_gradient_pairs(); - changed |= counter_changed; - pair_repaired |= counter_pair_repaired; - - (changed, pair_repaired) - } -} - -fn canonicalize_optional_gradient_pair( - base: &mut Option, - gradient: &mut Option, -) -> (bool, bool) { - let Some(current) = gradient.as_mut() else { - return (false, false); - }; - - // 공백 stop 색은 대표색 동기와 공백 색 정규화가 서로 되돌려 로드 복구가 - // 수렴하지 않으므로 그라데이션 자체를 내린다 (note 계열의 invalid stop 처리와 동일) - if current - .stops - .iter() - .any(|stop| stop.color.trim().is_empty()) - { - *gradient = None; - return (true, true); - } - - let gradient = current; - let mut changed = gradient.canonicalize(); - let representative = gradient - .stops - .first() - .expect("a deserialized gradient always has at least two stops") - .color - .clone(); - let pair_repaired = base.as_deref() != Some(representative.as_str()); - if pair_repaired { - *base = Some(representative); - changed = true; } - (changed, pair_repaired) } -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct NoteGradientShadow { - pub(crate) color: NoteColor, - pub(crate) opacity_top: u32, - pub(crate) opacity_bottom: u32, -} - -pub(crate) fn note_gradient_shadow( - gradient: &GradientSpec, - opacity: u32, -) -> Option { - if gradient.note_border_invalid_stop_index().is_some() { - return None; - } - let first = gradient.stops.first()?; - let last = gradient.stops.last()?; - let (top, top_alpha) = note_gradient_stop_color(&first.color)?; - let (bottom, bottom_alpha) = note_gradient_stop_color(&last.color)?; - Some(NoteGradientShadow { - color: NoteColor::Gradient { top, bottom }, - // 배율이 검증 범위를 벗어난 저장값이어도 shadow는 0~100 계약 유지 (TS clamp 미러) - opacity_top: ((top_alpha * f64::from(opacity)).round() as u32).min(100), - opacity_bottom: ((bottom_alpha * f64::from(opacity)).round() as u32).min(100), - }) -} - -// 유효한 sibling만 배율 부재를 100으로 실체화 - 손상 sibling은 이후 canonicalize가 -// drop하므로 구형 폴백 의미(부재 = 80/70)를 변조하지 않는다 (TS 미러와 동일 순서) -fn is_materializable_note_gradient(value: &serde_json::Value) -> bool { - if value.is_null() { - return false; +pub fn overlay_resize_anchor_from_str(value: &str) -> Option { + match value { + "top-left" => Some(OverlayResizeAnchor::TopLeft), + "top-right" => Some(OverlayResizeAnchor::TopRight), + "bottom-left" => Some(OverlayResizeAnchor::BottomLeft), + "bottom-right" => Some(OverlayResizeAnchor::BottomRight), + "center" => Some(OverlayResizeAnchor::Center), + "fixed-position" => Some(OverlayResizeAnchor::FixedPosition), + _ => None, } - serde_json::from_value::(value.clone()) - .is_ok_and(|gradient| gradient.note_border_invalid_stop_index().is_none()) } - -pub(crate) fn default_missing_note_gradient_multipliers(position: &mut serde_json::Value) -> bool { - let Some(position) = position.as_object_mut() else { - return false; - }; - let mut changed = false; - if position - .get("noteGradient") - .is_some_and(is_materializable_note_gradient) - && !position.contains_key("noteOpacity") - { - position.insert("noteOpacity".to_string(), serde_json::Value::from(100)); - changed = true; - } - if position - .get("noteGlowGradient") - .is_some_and(is_materializable_note_gradient) - && !position.contains_key("noteGlowOpacity") - { - position.insert("noteGlowOpacity".to_string(), serde_json::Value::from(100)); - changed = true; - } - changed +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CustomTab { + pub id: String, + pub name: String, } -fn canonicalize_note_gradient( - gradient: &mut Option, - opacity: u32, - apply_shadow: impl FnOnce(NoteGradientShadow) -> bool, -) -> (bool, bool) { - let Some(current) = gradient.as_mut() else { - return (false, false); - }; +pub const BUILTIN_TAB_IDS: [&str; 4] = ["4key", "5key", "6key", "8key"]; - let mut changed = current.canonicalize(); - let Some(shadow) = note_gradient_shadow(current, opacity) else { - *gradient = None; - return (true, true); - }; - let pair_repaired = apply_shadow(shadow); - changed |= pair_repaired; - (changed, pair_repaired) +pub(crate) fn default_bar_count() -> u8 { + crate::state::tab_metadata::MAX_BAR_SLOTS } -fn canonicalize_counter_gradient_pair( - base: &mut String, - gradient: &mut Option, -) -> (bool, bool) { - let Some(gradient) = gradient else { - return (false, false); - }; - - let mut changed = gradient.canonicalize(); - let representative = compact_canonical_rgba( - &gradient - .stops - .first() - .expect("a deserialized gradient always has at least two stops") - .color, - ); - let pair_repaired = *base != representative; - if pair_repaired { - *base = representative; - changed = true; - } - (changed, pair_repaired) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LayerGroupDef { + pub id: String, + pub name: String, } -fn canonicalize_note_border_gradient_pair( - base: &mut Option, - gradient: &mut Option, -) -> (bool, bool) { - let Some(current) = gradient.as_mut() else { - return (false, false); - }; - - let mut changed = current.canonicalize(); - if current.note_border_invalid_stop_index().is_some() { - *gradient = None; - return (true, true); - } - - let representative = note_border_representative_hex( - ¤t - .stops - .first() - .expect("a deserialized gradient always has at least two stops") - .color, - ) - .expect("all note border stop colors were validated"); - let pair_repaired = base.as_deref() != Some(representative.as_str()); - if pair_repaired { - *base = Some(representative); - changed = true; - } - (changed, pair_repaired) -} - -pub(crate) fn note_border_representative_hex(color: &str) -> Option { - note_gradient_stop_color(color).map(|(hex, _)| hex) -} - -fn note_gradient_stop_color(color: &str) -> Option<(String, f64)> { - let trimmed = color.trim(); - if let Some(hex) = trimmed.strip_prefix('#') { - if matches!(hex.len(), 3 | 4 | 6 | 8) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { - let channels = if matches!(hex.len(), 3 | 4) { - hex.chars() - .take(3) - .flat_map(|character| [character, character]) - .collect::() - } else { - hex[..6].to_string() - }; - let alpha = match hex.len() { - 4 => { - let digit = &hex[3..4]; - u8::from_str_radix(&format!("{digit}{digit}"), 16).ok()? as f64 / 255.0 - } - 8 => u8::from_str_radix(&hex[6..8], 16).ok()? as f64 / 255.0, - _ => 1.0, - }; - return Some((format!("#{}", channels.to_ascii_uppercase()), alpha)); - } - return None; - } - - let open = trimmed.find('(')?; - if !trimmed.ends_with(')') { - return None; - } - let name = &trimmed[..open]; - let channels = trimmed[open + 1..trimmed.len() - 1] - .split(',') - .map(str::trim) - .collect::>(); - let expected_channels = if name.eq_ignore_ascii_case("rgb") { - 3 - } else if name.eq_ignore_ascii_case("rgba") { - 4 - } else { - return None; - }; - if channels.len() != expected_channels { - return None; - } - - let rgb = channels[..3] - .iter() - .map(|channel| { - if !(1..=3).contains(&channel.len()) - || !channel.bytes().all(|byte| byte.is_ascii_digit()) - { - return None; - } - channel.parse::().ok().filter(|value| *value <= 255) - }) - .collect::>>()?; - - let alpha = if expected_channels == 4 { - let alpha = channels[3]; - let mut parts = alpha.split('.'); - let whole = parts.next().unwrap_or_default(); - let fractional = parts.next(); - let decimal_syntax = parts.next().is_none() - && whole.bytes().all(|byte| byte.is_ascii_digit()) - && fractional.is_none_or(|digits| { - !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) - }) - && (!whole.is_empty() || fractional.is_some()); - if !decimal_syntax { - return None; - } - let alpha = alpha.parse::().ok()?; - if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) { - return None; - } - alpha - } else { - 1.0 - }; - - Some(( - format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]), - alpha, - )) -} - -pub(crate) fn compact_canonical_rgba(color: &str) -> String { - let trimmed = color.trim(); - if let Some(hex) = trimmed.strip_prefix('#') { - if matches!(hex.len(), 3 | 6 | 8) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { - let expanded; - let hex = if hex.len() == 3 { - expanded = hex - .chars() - .flat_map(|character| [character, character]) - .collect::(); - expanded.as_str() - } else { - hex - }; - let red = u8::from_str_radix(&hex[0..2], 16).expect("validated hex channel"); - let green = u8::from_str_radix(&hex[2..4], 16).expect("validated hex channel"); - let blue = u8::from_str_radix(&hex[4..6], 16).expect("validated hex channel"); - let alpha = if hex.len() == 8 { - f64::from(u8::from_str_radix(&hex[6..8], 16).expect("validated alpha channel")) - / 255.0 - } else { - 1.0 - }; - return format!("rgba({red},{green},{blue},{})", format_compact_alpha(alpha)); - } - } - - let functional = trimmed - .strip_prefix("rgba(") - .or_else(|| trimmed.strip_prefix("rgb(")); - if let Some(body) = functional { - if let Some(body) = body.strip_suffix(')') { - let channels = body.split(',').map(str::trim).collect::>(); - if matches!(channels.len(), 3 | 4) - && channels.iter().all(|channel| { - !channel.is_empty() - && channel - .bytes() - .all(|byte| byte.is_ascii_digit() || byte == b'.') - }) - { - let parsed = channels - .iter() - .map(|channel| channel.parse::()) - .collect::, _>>(); - if let Ok(parsed) = parsed { - let alpha = parsed.get(3).copied().unwrap_or(1.0); - return format!( - "rgba({},{},{},{})", - parsed[0].round() as i64, - parsed[1].round() as i64, - parsed[2].round() as i64, - format_compact_alpha(alpha) - ); - } - } - } - } - - trimmed.to_string() -} - -fn format_compact_alpha(alpha: f64) -> String { - let rounded = (alpha.clamp(0.0, 1.0) * 10_000.0).round() / 10_000.0; - let formatted = format!("{rounded:.4}"); - let compact = formatted.trim_end_matches('0').trim_end_matches('.'); - if compact.is_empty() { - "0".to_string() - } else { - compact.to_string() - } -} - -fn default_counter_animation_enabled() -> bool { - false -} - -fn default_counter_animation_bezier() -> [f64; 4] { - [0.25, 0.46, 0.45, 0.94] -} - -fn default_counter_animation_scale() -> f64 { - 1.1 -} - -fn default_counter_animation_duration_ms() -> u32 { - 300 -} - -fn default_gap() -> u32 { - 4 -} -fn default_counter_font_size() -> u32 { - 11 -} -fn default_counter_font_weight() -> u32 { - 500 -} - -fn default_counter_enabled() -> bool { - true -} -fn default_note_effect_enabled() -> bool { - true -} -fn default_key_height() -> f64 { - 60.0 -} -fn default_key_note_color() -> NoteColor { - NoteColor::Solid("#FFFFFF".to_string()) -} -fn default_key_note_opacity() -> u32 { - 90 -} -fn default_note_glow_enabled() -> bool { - false -} -fn default_note_glow_size() -> f64 { - 10.0 -} - -fn default_note_border_opacity() -> u32 { - 100 -} -fn default_note_glow_opacity() -> u32 { - 70 -} -fn default_note_auto_y_correction() -> bool { - true -} -fn default_note_frame_limit() -> u32 { - 0 -} - -fn default_fade_top_px() -> u32 { - 50 -} - -fn default_reverse_fade_bottom_px() -> u32 { - 50 -} +pub type LayerGroups = HashMap>; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase", default)] -pub struct NoteSettings { - // Legacy: 전역 노트 라운딩 (개별 키 noteBorderRadius로 마이그레이션됨) - #[serde(default, skip_serializing)] - pub border_radius: Option, - #[serde(default = "default_note_frame_limit")] - pub frame_limit: u32, - pub speed: u32, - pub track_height: u32, - pub reverse: bool, - pub fade_position: FadePosition, - #[serde(default = "default_fade_top_px")] - pub fade_top_px: u32, +#[serde(rename_all = "camelCase")] +pub struct AppStoreData { + pub hardware_acceleration: bool, + pub always_on_top: bool, + pub overlay_locked: bool, #[serde(default)] - pub fade_bottom_px: u32, + pub overlay_visible: bool, + pub note_effect: bool, #[serde(default)] - pub reverse_fade_top_px: u32, - #[serde(default = "default_reverse_fade_bottom_px")] - pub reverse_fade_bottom_px: u32, - pub delayed_note_enabled: bool, - pub short_note_threshold_ms: u32, - pub short_note_min_length_px: u32, + pub note_settings: NoteSettings, + pub selected_key_type: String, #[serde(default)] - pub key_display_delay_ms: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -pub enum FadePosition { - Auto, - Top, - Bottom, - None, - Both, -} - -/// 이미지 맞춤 설정 (CSS object-fit과 동일) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum ImageFit { - #[default] - Cover, - Contain, - Fill, - None, + pub custom_tabs: Vec, + #[serde(default)] + pub tab_order: Vec, + #[serde(default = "default_bar_count")] + pub bar_count: u8, + pub angle_mode: String, + pub language: String, + pub laboratory_enabled: bool, + #[serde(default)] + pub developer_mode_enabled: bool, + #[serde(default)] + pub tray_enabled: bool, + #[serde(default = "default_auto_update_enabled")] + pub auto_update_enabled: bool, + #[serde(default)] + pub main_window_hidden: bool, + #[serde(default)] + pub editor_revision: u64, + #[serde(default)] + pub keys: KeyMappings, + #[serde(default)] + pub key_positions: KeyPositions, + #[serde(default)] + pub stat_positions: StatPositions, + #[serde(default)] + pub graph_positions: GraphPositions, + #[serde(default)] + pub knob_positions: KnobPositions, + #[serde(default)] + pub layer_groups: LayerGroups, + #[serde(default)] + pub key_counters: KeyCounters, + pub background_color: String, + pub use_custom_css: bool, + #[serde(default)] + pub custom_css: CustomCss, + #[serde(default)] + pub custom_css_history: Vec, + #[serde(default)] + pub font_settings: FontSettings, + #[serde(default)] + pub counter_animation_presets: Vec, + /// 탭별 CSS 오버라이드 (전역 CSS 대신 사용) + #[serde(default)] + pub tab_css_overrides: TabCssOverrides, + /// 탭별 노트 트랙 설정 오버라이드 + #[serde(default)] + pub tab_note_overrides: TabNoteOverrides, + #[serde(default)] + pub use_custom_js: bool, + #[serde(default)] + pub custom_js: CustomJs, + pub overlay_resize_anchor: OverlayResizeAnchor, + pub overlay_bounds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub panel_bounds: Option, + /// 분리 패널 창 존재 여부 (재시작 복원용) + #[serde(default)] + pub panel_detached: bool, + pub overlay_last_content_top_offset: Option, + #[serde(default)] + pub overlay_bounds_are_logical: bool, + #[serde(default)] + pub key_counter_enabled: bool, + /// 그리드 스마트 가이드 설정 + #[serde(default)] + pub grid_settings: GridSettings, + /// 단축키 설정 + #[serde(default)] + pub shortcuts: ShortcutsState, + /// 사운드 라이브러리 메타데이터 (키: 절대 경로, 값: 메타데이터) + #[serde(default)] + pub sound_library: HashMap, + /// WAV 파일과 메타데이터 커밋 사이의 크래시 복구 저널 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_processed_wav_replacement: Option, + #[serde(default)] + pub key_sound_output_backend: Option, + /// OBS 모드 활성화 여부 + #[serde(default)] + pub obs_mode_enabled: bool, + /// OBS WebSocket 서버 포트 + #[serde(default = "default_obs_port")] + pub obs_port: u16, + /// OBS 세션 토큰 (영구 저장, 앱 재시작 시 재사용) + #[serde(default)] + pub obs_token: Option, + /// 플러그인 데이터 저장소 (plugin_data_* 키로 저장) + #[serde(default, flatten)] + pub plugin_data: HashMap, } -impl Default for NoteSettings { +impl Default for AppStoreData { fn default() -> Self { Self { - border_radius: None, - frame_limit: default_note_frame_limit(), - speed: 400, - track_height: 300, - reverse: false, - fade_position: FadePosition::Auto, - fade_top_px: 50, - fade_bottom_px: 0, - reverse_fade_top_px: 0, - reverse_fade_bottom_px: 50, - delayed_note_enabled: false, - short_note_threshold_ms: 50, - short_note_min_length_px: 30, - key_display_delay_ms: 0, - } - } -} - -impl NoteSettings { - /// Legacy migration: fadePosition enum → pixel-based fade values - /// serde defaults로 채워진 새 필드가 auto 기본값과 동일하고 - /// fadePosition이 non-auto면 레거시 store로 판단하여 변환 - pub fn migrate_fade_position(&mut self) { - let d = Self::default(); - let at_auto_defaults = self.fade_top_px == d.fade_top_px - && self.fade_bottom_px == d.fade_bottom_px - && self.reverse_fade_top_px == d.reverse_fade_top_px - && self.reverse_fade_bottom_px == d.reverse_fade_bottom_px; - - if !at_auto_defaults { - return; - } - - match self.fade_position { - FadePosition::Auto => {} - FadePosition::Top => { - // 항상 상단 페이드 - self.reverse_fade_top_px = d.fade_top_px; - self.reverse_fade_bottom_px = 0; - } - FadePosition::Bottom => { - // 항상 하단 페이드 - self.fade_top_px = 0; - self.fade_bottom_px = d.reverse_fade_bottom_px; - } - FadePosition::None => { - // 페이드 없음 - self.fade_top_px = 0; - self.reverse_fade_bottom_px = 0; - } - FadePosition::Both => { - // 양방향 페이드 - self.fade_bottom_px = d.fade_top_px; - self.reverse_fade_top_px = d.reverse_fade_bottom_px; - } + hardware_acceleration: true, + always_on_top: true, + overlay_locked: false, + overlay_visible: false, + note_effect: false, + note_settings: NoteSettings::default(), + selected_key_type: "4key".to_string(), + custom_tabs: Vec::new(), + tab_order: BUILTIN_TAB_IDS.iter().map(|id| (*id).to_string()).collect(), + bar_count: default_bar_count(), + angle_mode: default_angle_mode(), + language: "ko".to_string(), + laboratory_enabled: false, + developer_mode_enabled: false, + tray_enabled: false, + auto_update_enabled: default_auto_update_enabled(), + main_window_hidden: false, + editor_revision: 0, + keys: KeyMappings::new(), + key_positions: KeyPositions::new(), + stat_positions: StatPositions::new(), + graph_positions: GraphPositions::new(), + knob_positions: KnobPositions::new(), + layer_groups: LayerGroups::new(), + key_counters: KeyCounters::new(), + background_color: "transparent".to_string(), + use_custom_css: false, + custom_css: CustomCss::default(), + custom_css_history: Vec::new(), + font_settings: FontSettings::default(), + counter_animation_presets: Vec::new(), + tab_css_overrides: TabCssOverrides::new(), + tab_note_overrides: TabNoteOverrides::new(), + use_custom_js: false, + custom_js: CustomJs::default(), + overlay_resize_anchor: OverlayResizeAnchor::TopLeft, + overlay_bounds: None, + panel_bounds: None, + panel_detached: false, + overlay_last_content_top_offset: None, + overlay_bounds_are_logical: false, + key_counter_enabled: false, + grid_settings: GridSettings::default(), + shortcuts: ShortcutsState::default(), + sound_library: HashMap::new(), + pending_processed_wav_replacement: None, + key_sound_output_backend: None, + obs_mode_enabled: false, + obs_port: default_obs_port(), + obs_token: None, + plugin_data: HashMap::new(), } - self.fade_position = FadePosition::Auto; } } -impl TabNoteSettings { - /// Legacy migration: 탭 오버라이드의 fadePosition → pixel-based fade values - /// Option 기반이라 None = 필드 부재 확실 (heuristic 불필요) - pub fn migrate_fade_position(&mut self) { - let fp = match self.fade_position.take() { - Some(fp) => fp, - None => return, - }; - - let has_new_fields = self.fade_top_px.is_some() - || self.fade_bottom_px.is_some() - || self.reverse_fade_top_px.is_some() - || self.reverse_fade_bottom_px.is_some(); - - if has_new_fields { - return; // 이미 새 필드가 설정됨 - } - - let d = NoteSettings::default(); - match fp { - FadePosition::Auto => { - // 명시적 auto 오버라이드 → 전역과 무관하게 auto 동작 보장 - self.fade_top_px = Some(d.fade_top_px); - self.fade_bottom_px = Some(d.fade_bottom_px); - self.reverse_fade_top_px = Some(d.reverse_fade_top_px); - self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); - } - FadePosition::Top => { - self.fade_top_px = Some(d.fade_top_px); - self.fade_bottom_px = Some(0); - self.reverse_fade_top_px = Some(d.fade_top_px); - self.reverse_fade_bottom_px = Some(0); - } - FadePosition::Bottom => { - self.fade_top_px = Some(0); - self.fade_bottom_px = Some(d.reverse_fade_bottom_px); - self.reverse_fade_top_px = Some(0); - self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); - } - FadePosition::None => { - self.fade_top_px = Some(0); - self.fade_bottom_px = Some(0); - self.reverse_fade_top_px = Some(0); - self.reverse_fade_bottom_px = Some(0); - } - FadePosition::Both => { - self.fade_top_px = Some(d.fade_top_px); - self.fade_bottom_px = Some(d.fade_top_px); - self.reverse_fade_top_px = Some(d.reverse_fade_bottom_px); - self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[derive(Default)] -pub struct CustomCss { - pub path: Option, - pub content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CustomCssHistoryEntry { - pub path: String, - #[serde(default)] - pub loaded_at: i64, - pub last_used_at: i64, -} - -/// 탭별 CSS 설정 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct TabCss { - pub path: Option, - pub content: String, - /// 이 탭에서 CSS 사용 여부 (false면 전역/탭 CSS 모두 미적용) - #[serde(default = "default_tab_css_enabled")] - pub enabled: bool, -} - -fn default_tab_css_enabled() -> bool { - true -} - -impl Default for TabCss { - fn default() -> Self { - Self { - path: None, - content: String::new(), - enabled: true, - } - } -} - -/// 탭별 CSS 오버라이드 맵 (키: 탭 ID, 값: TabCss) -pub type TabCssOverrides = HashMap; - -/// 탭별 노트 트랙 설정 (전역 NoteSettings를 탭별로 오버라이드) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[derive(Default)] -pub struct TabNoteSettings { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub frame_limit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub speed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub track_height: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reverse: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fade_position: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fade_top_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fade_bottom_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reverse_fade_top_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reverse_fade_bottom_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delayed_note_enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub short_note_threshold_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub short_note_min_length_px: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_display_delay_ms: Option, -} - -/// 탭별 노트 트랙 설정 오버라이드 맵 (키: 탭 ID, 값: TabNoteSettings) -pub type TabNoteOverrides = HashMap; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct JsPlugin { - pub id: String, - pub name: String, - pub path: Option, - pub content: String, - pub enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[derive(Default)] -pub struct CustomJs { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub content: String, - #[serde(default)] - pub plugins: Vec, -} - -impl CustomJs { - pub fn normalize(&mut self) -> bool { - let mut mutated = false; - - for plugin in self.plugins.iter_mut() { - if plugin.id.trim().is_empty() { - plugin.id = Uuid::new_v4().to_string(); - mutated = true; - } - if plugin.name.trim().is_empty() { - plugin.name = plugin - .path - .as_deref() - .and_then(|value| Path::new(value).file_name()) - .and_then(|value| value.to_str()) - .map(|value| value.to_string()) - .unwrap_or_else(|| "plugin.js".to_string()); - mutated = true; - } - } - - if self.plugins.is_empty() && (self.path.is_some() || !self.content.is_empty()) { - let name = self - .path - .as_deref() - .and_then(|value| Path::new(value).file_name()) - .and_then(|value| value.to_str()) - .map(|value| value.to_string()) - .unwrap_or_else(|| "custom.js".to_string()); - let plugin = JsPlugin { - id: Uuid::new_v4().to_string(), - name, - path: self.path.clone(), - content: self.content.clone(), - enabled: true, - }; - self.plugins.push(plugin); - self.path = None; - self.content.clear(); - mutated = true; - } - - mutated - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum OverlayResizeAnchor { - #[default] - TopLeft, - TopRight, - BottomLeft, - BottomRight, - Center, - FixedPosition, -} - -/// 그리드 스마트 가이드 설정 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct GridSettings { - /// 정렬 가이드 활성화 (드래그/리사이즈 시 요소 정렬 스냅) - #[serde(default = "default_true")] - pub alignment_guides: bool, - /// 간격 일치 가이드 활성화 (요소 간 간격 일치 스냅) - #[serde(default = "default_true")] - pub spacing_guides: bool, - /// 크기 일치 가이드 활성화 (리사이즈 시 크기 일치 스냅) - #[serde(default = "default_true")] - pub size_match_guides: bool, - /// 미니맵 표시 여부 - #[serde(default = "default_true")] - pub minimap_enabled: bool, - /// 그리드 스냅 크기 (0-10px, 0은 끄기) - #[serde(default = "default_grid_snap_size")] - pub grid_snap_size: u32, - /// 오버레이 여백 (0-30px) - #[serde(default = "default_overlay_padding")] - pub overlay_padding: u32, -} - -fn default_true() -> bool { - true -} - -fn default_auto_update_enabled() -> bool { - true -} - -// 렌더러 백엔드 기본값, macOS는 metal 고정 그 외는 d3d11 -pub(crate) fn default_angle_mode() -> String { - if cfg!(target_os = "macos") { - "metal".to_string() - } else { - "d3d11".to_string() - } -} - -fn default_obs_port() -> u16 { - obs::DEFAULT_OBS_PORT -} - -fn default_grid_snap_size() -> u32 { - 5 -} - -fn default_overlay_padding() -> u32 { - 30 -} - -impl Default for GridSettings { - fn default() -> Self { - Self { - alignment_guides: true, - spacing_guides: true, - size_match_guides: true, - minimap_enabled: true, - grid_snap_size: default_grid_snap_size(), - overlay_padding: default_overlay_padding(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct OverlayBounds { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoredOverlayNativePosition { - pub x: f64, - pub y: f64, - pub logical_echo_x: f64, - pub logical_echo_y: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct StoredOverlayBounds { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native_position: Option, -} - -impl StoredOverlayBounds { - pub fn public_bounds(&self) -> OverlayBounds { - OverlayBounds { - x: self.x, - y: self.y, - width: self.width, - height: self.height, - } - } -} - -impl From for StoredOverlayBounds { - fn from(bounds: OverlayBounds) -> Self { - Self { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - native_position: None, - } - } -} - -/// 분리 패널의 마지막 기하 정보. 복원에 쓰는 값은 height뿐이고 -/// x/y는 이동 기록으로만 남는다 - 패널은 열 때마다 메인 창 옆에 다시 배치된다 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PanelBounds { - pub x: f64, - pub y: f64, - pub height: f64, -} -impl OverlayResizeAnchor { - pub fn as_str(&self) -> &'static str { - match self { - OverlayResizeAnchor::TopLeft => "top-left", - OverlayResizeAnchor::TopRight => "top-right", - OverlayResizeAnchor::BottomLeft => "bottom-left", - OverlayResizeAnchor::BottomRight => "bottom-right", - OverlayResizeAnchor::Center => "center", - OverlayResizeAnchor::FixedPosition => "fixed-position", - } - } -} - -pub fn overlay_resize_anchor_from_str(value: &str) -> Option { - match value { - "top-left" => Some(OverlayResizeAnchor::TopLeft), - "top-right" => Some(OverlayResizeAnchor::TopRight), - "bottom-left" => Some(OverlayResizeAnchor::BottomLeft), - "bottom-right" => Some(OverlayResizeAnchor::BottomRight), - "center" => Some(OverlayResizeAnchor::Center), - "fixed-position" => Some(OverlayResizeAnchor::FixedPosition), - _ => None, - } -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct CustomTab { - pub id: String, - pub name: String, -} - -pub const BUILTIN_TAB_IDS: [&str; 4] = ["4key", "5key", "6key", "8key"]; - -pub(crate) fn default_bar_count() -> u8 { - crate::state::tab_metadata::MAX_BAR_SLOTS -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct LayerGroupDef { - pub id: String, - pub name: String, -} - -pub type LayerGroups = HashMap>; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct AppStoreData { - pub hardware_acceleration: bool, - pub always_on_top: bool, - pub overlay_locked: bool, - #[serde(default)] - pub overlay_visible: bool, - pub note_effect: bool, - #[serde(default)] - pub note_settings: NoteSettings, - pub selected_key_type: String, - #[serde(default)] - pub custom_tabs: Vec, - #[serde(default)] - pub tab_order: Vec, - #[serde(default = "default_bar_count")] - pub bar_count: u8, - pub angle_mode: String, - pub language: String, - pub laboratory_enabled: bool, - #[serde(default)] - pub developer_mode_enabled: bool, - #[serde(default)] - pub tray_enabled: bool, - #[serde(default = "default_auto_update_enabled")] - pub auto_update_enabled: bool, - #[serde(default)] - pub main_window_hidden: bool, - #[serde(default)] - pub editor_revision: u64, - #[serde(default)] - pub keys: KeyMappings, - #[serde(default)] - pub key_positions: KeyPositions, - #[serde(default)] - pub stat_positions: StatPositions, - #[serde(default)] - pub graph_positions: GraphPositions, - #[serde(default)] - pub knob_positions: KnobPositions, - #[serde(default)] - pub layer_groups: LayerGroups, - #[serde(default)] - pub key_counters: KeyCounters, - pub background_color: String, - pub use_custom_css: bool, - #[serde(default)] - pub custom_css: CustomCss, - #[serde(default)] - pub custom_css_history: Vec, - #[serde(default)] - pub font_settings: FontSettings, - #[serde(default)] - pub counter_animation_presets: Vec, - /// 탭별 CSS 오버라이드 (전역 CSS 대신 사용) - #[serde(default)] - pub tab_css_overrides: TabCssOverrides, - /// 탭별 노트 트랙 설정 오버라이드 - #[serde(default)] - pub tab_note_overrides: TabNoteOverrides, - #[serde(default)] - pub use_custom_js: bool, - #[serde(default)] - pub custom_js: CustomJs, - pub overlay_resize_anchor: OverlayResizeAnchor, - pub overlay_bounds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub panel_bounds: Option, - /// 분리 패널 창 존재 여부 (재시작 복원용) - #[serde(default)] - pub panel_detached: bool, - pub overlay_last_content_top_offset: Option, - #[serde(default)] - pub overlay_bounds_are_logical: bool, - #[serde(default)] - pub key_counter_enabled: bool, - /// 그리드 스마트 가이드 설정 - #[serde(default)] - pub grid_settings: GridSettings, - /// 단축키 설정 - #[serde(default)] - pub shortcuts: ShortcutsState, - /// 사운드 라이브러리 메타데이터 (키: 절대 경로, 값: 메타데이터) - #[serde(default)] - pub sound_library: HashMap, - /// WAV 파일과 메타데이터 커밋 사이의 크래시 복구 저널 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pending_processed_wav_replacement: Option, - #[serde(default)] - pub key_sound_output_backend: Option, - /// OBS 모드 활성화 여부 - #[serde(default)] - pub obs_mode_enabled: bool, - /// OBS WebSocket 서버 포트 - #[serde(default = "default_obs_port")] - pub obs_port: u16, - /// OBS 세션 토큰 (영구 저장, 앱 재시작 시 재사용) - #[serde(default)] - pub obs_token: Option, - /// 플러그인 데이터 저장소 (plugin_data_* 키로 저장) - #[serde(default, flatten)] - pub plugin_data: HashMap, -} - -impl Default for AppStoreData { - fn default() -> Self { - Self { - hardware_acceleration: true, - always_on_top: true, - overlay_locked: false, - overlay_visible: false, - note_effect: false, - note_settings: NoteSettings::default(), - selected_key_type: "4key".to_string(), - custom_tabs: Vec::new(), - tab_order: BUILTIN_TAB_IDS.iter().map(|id| (*id).to_string()).collect(), - bar_count: default_bar_count(), - angle_mode: default_angle_mode(), - language: "ko".to_string(), - laboratory_enabled: false, - developer_mode_enabled: false, - tray_enabled: false, - auto_update_enabled: default_auto_update_enabled(), - main_window_hidden: false, - editor_revision: 0, - keys: KeyMappings::new(), - key_positions: KeyPositions::new(), - stat_positions: StatPositions::new(), - graph_positions: GraphPositions::new(), - knob_positions: KnobPositions::new(), - layer_groups: LayerGroups::new(), - key_counters: KeyCounters::new(), - background_color: "transparent".to_string(), - use_custom_css: false, - custom_css: CustomCss::default(), - custom_css_history: Vec::new(), - font_settings: FontSettings::default(), - counter_animation_presets: Vec::new(), - tab_css_overrides: TabCssOverrides::new(), - tab_note_overrides: TabNoteOverrides::new(), - use_custom_js: false, - custom_js: CustomJs::default(), - overlay_resize_anchor: OverlayResizeAnchor::TopLeft, - overlay_bounds: None, - panel_bounds: None, - panel_detached: false, - overlay_last_content_top_offset: None, - overlay_bounds_are_logical: false, - key_counter_enabled: false, - grid_settings: GridSettings::default(), - shortcuts: ShortcutsState::default(), - sound_library: HashMap::new(), - pending_processed_wav_replacement: None, - key_sound_output_backend: None, - obs_mode_enabled: false, - obs_port: default_obs_port(), - obs_token: None, - plugin_data: HashMap::new(), - } - } -} - -impl AppStoreData { - /// 설정 사영, store 필드가 설정 기본값의 단일 원천이고 SettingsState는 파생 뷰 - pub(crate) fn settings_state(&self) -> SettingsState { - let mut custom_js = self.custom_js.clone(); - let _ = custom_js.normalize(); +impl AppStoreData { + /// 설정 사영, store 필드가 설정 기본값의 단일 원천이고 SettingsState는 파생 뷰 + pub(crate) fn settings_state(&self) -> SettingsState { + let mut custom_js = self.custom_js.clone(); + let _ = custom_js.normalize(); SettingsState { hardware_acceleration: self.hardware_acceleration, @@ -2623,1614 +840,21 @@ impl AppStoreData { laboratory_enabled: self.laboratory_enabled, developer_mode_enabled: self.developer_mode_enabled, tray_enabled: self.tray_enabled, - auto_update_enabled: self.auto_update_enabled, - background_color: self.background_color.clone(), - use_custom_css: self.use_custom_css, - custom_css: self.custom_css.clone(), - font_settings: self.font_settings.clone(), - use_custom_js: self.use_custom_js, - custom_js, - overlay_resize_anchor: self.overlay_resize_anchor.clone(), - key_counter_enabled: self.key_counter_enabled, - grid_settings: self.grid_settings.clone(), - shortcuts: self.shortcuts.clone(), - obs_mode_enabled: self.obs_mode_enabled, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ShortcutBinding { - pub key: String, - #[serde(default)] - pub ctrl: bool, - #[serde(default)] - pub shift: bool, - #[serde(default)] - pub alt: bool, - #[serde(default)] - pub meta: bool, -} - -fn default_toggle_overlay_shortcut() -> ShortcutBinding { - // 기본 단축키 — Windows/Linux: Ctrl+Shift+O, macOS: Cmd+Shift+O - if cfg!(target_os = "macos") { - ShortcutBinding { - key: "KeyO".to_string(), - ctrl: false, - shift: true, - alt: false, - meta: true, - } - } else { - ShortcutBinding { - key: "KeyO".to_string(), - ctrl: true, - shift: true, - alt: false, - meta: false, - } - } -} - -fn default_switch_key_mode_shortcut() -> ShortcutBinding { - ShortcutBinding { - key: "Tab".to_string(), - ctrl: false, - shift: false, - alt: false, - meta: false, - } -} - -fn default_unbound_shortcut() -> ShortcutBinding { - ShortcutBinding { - key: "".to_string(), - ctrl: false, - shift: false, - alt: false, - meta: false, - } -} - -fn default_toggle_settings_panel_shortcut() -> ShortcutBinding { - if cfg!(target_os = "macos") { - ShortcutBinding { - key: "KeyB".to_string(), - ctrl: false, - shift: false, - alt: false, - meta: true, - } - } else { - ShortcutBinding { - key: "KeyB".to_string(), - ctrl: true, - shift: false, - alt: false, - meta: false, - } - } -} - -fn default_zoom_in_shortcut() -> ShortcutBinding { - if cfg!(target_os = "macos") { - ShortcutBinding { - key: "Equal".to_string(), - ctrl: false, - shift: false, - alt: false, - meta: true, - } - } else { - ShortcutBinding { - key: "Equal".to_string(), - ctrl: true, - shift: false, - alt: false, - meta: false, - } - } -} - -fn default_zoom_out_shortcut() -> ShortcutBinding { - if cfg!(target_os = "macos") { - ShortcutBinding { - key: "Minus".to_string(), - ctrl: false, - shift: false, - alt: false, - meta: true, - } - } else { - ShortcutBinding { - key: "Minus".to_string(), - ctrl: true, - shift: false, - alt: false, - meta: false, - } - } -} - -fn default_zoom_reset_shortcut() -> ShortcutBinding { - if cfg!(target_os = "macos") { - ShortcutBinding { - key: "Digit0".to_string(), - ctrl: false, - shift: false, - alt: false, - meta: true, - } - } else { - ShortcutBinding { - key: "Digit0".to_string(), - ctrl: true, - shift: false, - alt: false, - meta: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ShortcutsState { - #[serde(default = "default_toggle_overlay_shortcut")] - pub toggle_overlay: ShortcutBinding, - #[serde(default = "default_unbound_shortcut")] - pub toggle_overlay_lock: ShortcutBinding, - #[serde(default = "default_unbound_shortcut")] - pub toggle_always_on_top: ShortcutBinding, - #[serde(default = "default_switch_key_mode_shortcut")] - pub switch_key_mode: ShortcutBinding, - #[serde(default = "default_toggle_settings_panel_shortcut")] - pub toggle_settings_panel: ShortcutBinding, - #[serde(default = "default_zoom_in_shortcut")] - pub zoom_in: ShortcutBinding, - #[serde(default = "default_zoom_out_shortcut")] - pub zoom_out: ShortcutBinding, - #[serde(default = "default_zoom_reset_shortcut")] - pub reset_zoom: ShortcutBinding, -} - -impl Default for ShortcutsState { - fn default() -> Self { - Self { - toggle_overlay: default_toggle_overlay_shortcut(), - toggle_overlay_lock: default_unbound_shortcut(), - toggle_always_on_top: default_unbound_shortcut(), - switch_key_mode: default_switch_key_mode_shortcut(), - toggle_settings_panel: default_toggle_settings_panel_shortcut(), - zoom_in: default_zoom_in_shortcut(), - zoom_out: default_zoom_out_shortcut(), - reset_zoom: default_zoom_reset_shortcut(), + auto_update_enabled: self.auto_update_enabled, + background_color: self.background_color.clone(), + use_custom_css: self.use_custom_css, + custom_css: self.custom_css.clone(), + font_settings: self.font_settings.clone(), + use_custom_js: self.use_custom_js, + custom_js, + overlay_resize_anchor: self.overlay_resize_anchor.clone(), + key_counter_enabled: self.key_counter_enabled, + grid_settings: self.grid_settings.clone(), + shortcuts: self.shortcuts.clone(), + obs_mode_enabled: self.obs_mode_enabled, } } } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct ShortcutsPatchInput { - #[serde(skip_serializing_if = "Option::is_none")] - pub toggle_overlay: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub toggle_overlay_lock: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub toggle_always_on_top: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub switch_key_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub toggle_settings_panel: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub zoom_in: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub zoom_out: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reset_zoom: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BootstrapOverlayState { - pub visible: bool, - pub locked: bool, - pub anchor: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BootstrapPayload { - pub settings: SettingsState, - pub defaults: DefaultsPayload, - pub keys: KeyMappings, - pub positions: KeyPositions, - pub stat_positions: StatPositions, - pub graph_positions: GraphPositions, - pub knob_positions: KnobPositions, - pub custom_tabs: Vec, - pub tab_order: Vec, - pub bar_count: u8, - pub selected_key_type: String, - pub current_mode: String, - pub active_keys: Vec, - pub overlay: BootstrapOverlayState, - pub key_counters: KeyCounters, - pub key_counters_session_id: String, - pub key_counters_revision: u64, - pub layer_groups: LayerGroups, - pub tab_note_overrides: TabNoteOverrides, - pub tab_css_overrides: TabCssOverrides, - pub editor_revision: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DefaultsPayload { - pub settings: SettingsState, - pub counter_settings: KeyCounterSettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SettingsState { - pub hardware_acceleration: bool, - pub always_on_top: bool, - pub overlay_locked: bool, - pub note_effect: bool, - #[serde(default)] - pub note_settings: NoteSettings, - pub angle_mode: String, - pub language: String, - pub laboratory_enabled: bool, - #[serde(default)] - pub developer_mode_enabled: bool, - #[serde(default)] - pub tray_enabled: bool, - #[serde(default = "default_auto_update_enabled")] - pub auto_update_enabled: bool, - pub background_color: String, - #[serde(rename = "useCustomCSS")] - pub use_custom_css: bool, - #[serde(rename = "customCSS")] - #[serde(default)] - pub custom_css: CustomCss, - #[serde(default)] - pub font_settings: FontSettings, - #[serde(rename = "useCustomJS")] - pub use_custom_js: bool, - #[serde(rename = "customJS")] - #[serde(default)] - pub custom_js: CustomJs, - pub overlay_resize_anchor: OverlayResizeAnchor, - #[serde(default)] - pub key_counter_enabled: bool, - #[serde(default)] - pub grid_settings: GridSettings, - #[serde(default)] - pub shortcuts: ShortcutsState, - #[serde(default)] - pub obs_mode_enabled: bool, -} - -impl Default for SettingsState { - fn default() -> Self { - // 설정 기본값의 단일 원천은 AppStoreData::default, 빈 store 사영으로 유도 - AppStoreData::default().settings_state() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -#[derive(Default)] -pub struct NoteSettingsPatch { - pub frame_limit: Option, - pub speed: Option, - pub track_height: Option, - pub reverse: Option, - pub fade_position: Option, - pub fade_top_px: Option, - pub fade_bottom_px: Option, - pub reverse_fade_top_px: Option, - pub reverse_fade_bottom_px: Option, - pub delayed_note_enabled: Option, - pub short_note_threshold_ms: Option, - pub short_note_min_length_px: Option, - pub key_display_delay_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct SettingsPatchInput { - pub hardware_acceleration: Option, - pub always_on_top: Option, - pub overlay_locked: Option, - pub note_effect: Option, - pub note_settings: Option, - pub angle_mode: Option, - pub language: Option, - pub laboratory_enabled: Option, - pub developer_mode_enabled: Option, - pub tray_enabled: Option, - pub auto_update_enabled: Option, - pub background_color: Option, - #[serde(rename = "useCustomCSS")] - pub use_custom_css: Option, - #[serde(rename = "customCSS")] - pub custom_css: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub font_settings: Option, - #[serde(rename = "useCustomJS")] - pub use_custom_js: Option, - #[serde(rename = "customJS")] - pub custom_js: Option, - pub overlay_resize_anchor: Option, - pub key_counter_enabled: Option, - pub grid_settings: Option, - pub shortcuts: Option, - pub obs_mode_enabled: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct CustomCssPatch { - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct CustomJsPatch { - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub plugins: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SettingsDiff { - pub changed: SettingsPatch, - #[serde(skip_serializing_if = "Option::is_none")] - pub full: Option, -} - -impl SettingsDiff { - pub fn changed_count(&self) -> usize { - let p = &self.changed; - [ - p.hardware_acceleration.is_some(), - p.always_on_top.is_some(), - p.overlay_locked.is_some(), - p.note_effect.is_some(), - p.note_settings.is_some(), - p.angle_mode.is_some(), - p.language.is_some(), - p.laboratory_enabled.is_some(), - p.developer_mode_enabled.is_some(), - p.tray_enabled.is_some(), - p.auto_update_enabled.is_some(), - p.background_color.is_some(), - p.use_custom_css.is_some(), - p.custom_css.is_some(), - p.font_settings.is_some(), - p.use_custom_js.is_some(), - p.custom_js.is_some(), - p.overlay_resize_anchor.is_some(), - p.key_counter_enabled.is_some(), - p.grid_settings.is_some(), - p.shortcuts.is_some(), - p.obs_mode_enabled.is_some(), - ] - .iter() - .filter(|&&x| x) - .count() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct SettingsPatch { - #[serde(skip_serializing_if = "Option::is_none")] - pub hardware_acceleration: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub always_on_top: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub overlay_locked: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub note_effect: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub note_settings: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub angle_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub language: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub laboratory_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub developer_mode_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tray_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_update_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub background_color: Option, - #[serde(rename = "useCustomCSS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub use_custom_css: Option, - #[serde(rename = "customCSS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub custom_css: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub font_settings: Option, - #[serde(rename = "useCustomJS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub use_custom_js: Option, - #[serde(rename = "customJS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub custom_js: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub overlay_resize_anchor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub key_counter_enabled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub grid_settings: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub shortcuts: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub obs_mode_enabled: Option, -} - #[cfg(test)] -mod tests { - use super::{ - compact_canonical_rgba, note_border_representative_hex, scrub_removed_text_outline_fields, - AppStoreData, FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, - KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, - KeyCounterSettings, KeyMappings, KeyPosition, KeySlot, KnobPosition, NoteColor, - NoteSettings, SlotMatch, StatPosition, StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, - }; - use serde::Deserialize; - - const NOTE_BORDER_STOP_COLOR_FIXTURE: &str = - include_str!("../../../tests/fixtures/note-border-stop-colors.json"); - - #[derive(Deserialize)] - struct NoteBorderStopColorFixture { - valid: Vec, - invalid: Vec, - } - - #[derive(Deserialize)] - struct ValidNoteBorderStopColor { - input: String, - representative: String, - } - - #[test] - fn app_store_data_defaults_missing_bar_count_to_four() { - let mut value = serde_json::to_value(AppStoreData::default()).unwrap(); - value.as_object_mut().unwrap().remove("barCount"); - - let restored: AppStoreData = serde_json::from_value(value).unwrap(); - - assert_eq!(restored.bar_count, 4); - } - - #[test] - fn legacy_string_key_mappings_round_trip_without_loss() { - let raw = serde_json::json!({ - "4key": ["A", "A+B", "+", ""] - }); - - let mappings: KeyMappings = serde_json::from_value(raw.clone()).unwrap(); - - assert_eq!(serde_json::to_value(mappings).unwrap(), raw); - } - - #[test] - fn legacy_700_weights_migrate_to_400_with_bold_modifier() { - let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); - let object = raw.as_object_mut().unwrap(); - object.insert("fontWeight".to_string(), serde_json::json!(700)); - object.remove("fontBold"); - let counter = object["counter"].as_object_mut().unwrap(); - counter.insert("fontWeight".to_string(), serde_json::json!(700)); - counter.remove("fontBold"); - - let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); - assert!(position.migrate_legacy_font_weight()); - assert_eq!(position.font_weight, Some(400)); - assert_eq!(position.font_bold, Some(true)); - assert_eq!(position.counter.font_weight, 400); - assert_eq!(position.counter.font_bold, Some(true)); - assert!(!position.migrate_legacy_font_weight()); - } - - #[test] - fn legacy_non_bold_weights_remain_sparse() { - let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); - let object = raw.as_object_mut().unwrap(); - object.insert("fontWeight".to_string(), serde_json::json!(600)); - object.remove("fontBold"); - let counter = object["counter"].as_object_mut().unwrap(); - counter.insert("fontWeight".to_string(), serde_json::json!(500)); - counter.remove("fontBold"); - - let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); - assert!(!position.migrate_legacy_font_weight()); - assert_eq!(position.font_weight, Some(600)); - assert_eq!(position.font_bold, None); - assert_eq!(position.counter.font_weight, 500); - assert_eq!(position.counter.font_bold, None); - } - - #[test] - fn element_id_defaults_to_empty_and_flattens_into_every_position_type() { - let id = uuid::Uuid::new_v4().to_string(); - let position = KeyPosition { - id: id.clone(), - ..KeyPosition::default() - }; - let mut values = [ - serde_json::to_value(&position).unwrap(), - serde_json::to_value(StatPosition { - stat_type: StatType::Kps, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position, - }) - .unwrap(), - ]; - - assert!(values.iter().all(|value| value["id"] == id)); - for value in &mut values { - value.as_object_mut().unwrap().remove("id"); - } - let stat: StatPosition = serde_json::from_value(values[1].clone()).unwrap(); - let graph: GraphPosition = serde_json::from_value(values[2].clone()).unwrap(); - let knob: KnobPosition = serde_json::from_value(values[3].clone()).unwrap(); - assert!(stat.position.id.is_empty()); - assert!(graph.position.id.is_empty()); - assert!(knob.position.id.is_empty()); - - let missing: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0 - })) - .unwrap(); - assert!(missing.id.is_empty()); - } - - #[test] - fn new_surface_gradients_flatten_into_every_position_collection() { - let position = KeyPosition { - note_border_gradient: serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - })) - .unwrap(), - font_gradient: serde_json::from_value(serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#556677", "pos": 0 }, - { "color": "#8899AA", "pos": 1 } - ] - })) - .unwrap(), - active_font_gradient: serde_json::from_value(serde_json::json!({ - "angle": 135, - "stops": [ - { "color": "#AABBCC", "pos": 0 }, - { "color": "#DDEEFF", "pos": 1 } - ] - })) - .unwrap(), - ..KeyPosition::default() - }; - let values = [ - serde_json::to_value(&position).unwrap(), - serde_json::to_value(StatPosition { - stat_type: StatType::Kps, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(GraphPosition { - stat_type: GraphStatType::Kps, - graph_type: GraphType::Line, - graph_speed: 100, - graph_color: "#123456".to_string(), - show_avg_line: true, - position: position.clone(), - }) - .unwrap(), - serde_json::to_value(KnobPosition { - axis_id: "axis".to_string(), - sensitivity: 1.0, - reverse: false, - position, - }) - .unwrap(), - ]; - - for value in values { - assert_eq!(value["noteBorderGradient"]["angle"], 90.0); - assert_eq!(value["fontGradient"]["angle"], 45.0); - assert_eq!(value["activeFontGradient"]["angle"], 135.0); - } - } - - #[test] - fn multi_key_slot_wire_shape_and_canonical_are_stable() { - let raw = serde_json::json!({ "keys": ["LEFT CTRL", "Z"], "match": "all" }); - - let slot: KeySlot = serde_json::from_value(raw.clone()).unwrap(); - - assert_eq!( - slot, - KeySlot::Multi { - keys: vec!["LEFT CTRL".to_string(), "Z".to_string()], - match_mode: SlotMatch::All, - } - ); - assert_eq!(slot.canonical(), "LEFT CTRL+Z"); - assert_eq!(serde_json::to_value(slot).unwrap(), raw); - } - - #[test] - fn malformed_key_slots_normalize_without_deserialization_failure() { - let too_many = (0..=MAX_SLOT_KEYS) - .map(|index| serde_json::Value::String(format!("K{index}"))) - .collect::>(); - let cases = [ - (serde_json::json!({ "keys": ["Z"] }), KeySlot::default()), - ( - serde_json::json!({ - "keys": ["A", 7, "", "A", "B+C", "D|E", "B"], - "match": "any", - "ignored": true - }), - KeySlot::Multi { - keys: vec!["A".to_string(), "B".to_string()], - match_mode: SlotMatch::Any, - }, - ), - ( - serde_json::json!({ "keys": ["Z"], "match": "all" }), - KeySlot::Single("Z".to_string()), - ), - ( - serde_json::json!({ "keys": [], "match": "any" }), - KeySlot::default(), - ), - ( - serde_json::json!({ "keys": too_many, "match": "any" }), - KeySlot::Multi { - keys: (0..MAX_SLOT_KEYS) - .map(|index| format!("K{index}")) - .collect(), - match_mode: SlotMatch::Any, - }, - ), - (serde_json::json!(42), KeySlot::default()), - (serde_json::json!(["A", "B"]), KeySlot::default()), - (serde_json::Value::Null, KeySlot::default()), - ]; - - for (raw, expected) in cases { - let slot: KeySlot = serde_json::from_value(raw).unwrap(); - assert_eq!(slot, expected); - } - } - - #[test] - fn stat_type_wire_values_round_trip() { - for (stat_type, wire_value) in [ - (StatType::Kps, "kps"), - (StatType::KpsAvg, "kpsAvg"), - (StatType::KpsMax, "kpsMax"), - (StatType::Total, "total"), - ] { - let serialized = serde_json::to_value(&stat_type).unwrap(); - assert_eq!(serialized, wire_value); - - let restored: StatType = serde_json::from_value(serialized).unwrap(); - assert_eq!(restored, stat_type); - } - } - - // 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입 - fn key_position_json(visual_px: &str) -> String { - format!( - r##"{{ - "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0, - "count": 0, "noteColor": "#FFFFFF", "noteOpacity": 80, - {visual_px} - }}"## - ) - } - - // 기존 정수 저장값이 f64 필드로 그대로 역직렬화되는지 (하위 호환) - #[test] - fn visual_px_fields_accept_integer_json() { - let json = - key_position_json(r#""noteWidth": 100, "noteBorderRadius": 8, "noteGlowSize": 20"#); - let pos: KeyPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(pos.note_width, Some(100.0)); - assert_eq!(pos.note_border_radius, Some(8.0)); - assert_eq!(pos.note_glow_size, 20.0); - } - - // 소수 저장값이 정상 역직렬화되는지 - #[test] - fn visual_px_fields_accept_decimal_json() { - let json = key_position_json( - r#""noteWidth": 100.5, "noteBorderRadius": 8.5, "noteGlowSize": 20.5"#, - ); - let pos: KeyPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(pos.note_width, Some(100.5)); - assert_eq!(pos.note_border_radius, Some(8.5)); - assert_eq!(pos.note_glow_size, 20.5); - } - - // note_glow_size 미지정 시 기본값(10.0) 적용 - #[test] - fn note_glow_size_defaults_to_10() { - let json = key_position_json(r#""noteWidth": null"#); - let pos: KeyPosition = serde_json::from_str(&json).unwrap(); - assert_eq!(pos.note_glow_size, 10.0); - assert_eq!(pos.note_width, None); - } - - #[test] - fn gradient_opacity_fields_survive_serde_round_trip() { - let json = key_position_json( - r#""noteOpacityTop": 91, "noteOpacityBottom": 37, - "noteGlowOpacityTop": 64, "noteGlowOpacityBottom": 18"#, - ); - let position: KeyPosition = serde_json::from_str(&json).unwrap(); - - assert_eq!(position.note_opacity_top, Some(91)); - assert_eq!(position.note_opacity_bottom, Some(37)); - assert_eq!(position.note_glow_opacity_top, Some(64)); - assert_eq!(position.note_glow_opacity_bottom, Some(18)); - - let serialized = serde_json::to_value(&position).unwrap(); - assert_eq!(serialized["noteOpacityTop"], 91); - assert_eq!(serialized["noteOpacityBottom"], 37); - assert_eq!(serialized["noteGlowOpacityTop"], 64); - assert_eq!(serialized["noteGlowOpacityBottom"], 18); - - let restored: KeyPosition = serde_json::from_value(serialized).unwrap(); - assert_eq!(restored, position); - } - - #[test] - fn note_settings_1_3_format_still_preserves_every_field() { - // 1.3 시절 noteSettings 전체 필드 실형식 - let fixture = r#"{ - "borderRadius": 9, - "speed": 456, - "trackHeight": 222, - "reverse": true, - "fadePosition": "bottom", - "delayedNoteEnabled": true, - "shortNoteThresholdMs": 73, - "shortNoteMinLengthPx": 41 - }"#; - let settings: NoteSettings = serde_json::from_str(fixture).unwrap(); - - assert_eq!(settings.border_radius, Some(9)); - assert_eq!(settings.speed, 456); - assert_eq!(settings.track_height, 222); - assert!(settings.reverse); - assert_eq!(settings.fade_position, FadePosition::Bottom); - assert!(settings.delayed_note_enabled); - assert_eq!(settings.short_note_threshold_ms, 73); - assert_eq!(settings.short_note_min_length_px, 41); - } - - #[test] - fn key_position_1_0_missing_visual_fields_uses_legacy_defaults() { - let position: KeyPosition = - serde_json::from_str(r#"{"dx":777,"dy":12,"width":60,"count":42}"#).unwrap(); - - assert_eq!(position.dx, 777.0); - assert_eq!(position.count, 42); - assert_eq!(position.height, 60.0); - assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); - assert_eq!(position.note_opacity, 90); - assert_eq!(position.shadow, None); - assert_eq!(position.active_shadow, None); - } - - #[test] - fn key_position_visual_effects_round_trip_without_rewriting_missing_defaults() { - let fixture = serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "shadow": { - "enabled": true, - "color": "rgba(10, 20, 30, 0.45)", - "offsetX": -2.0, - "offsetY": 7.0, - "blur": 18.0 - }, - "activeShadow": { - "enabled": false, - "color": "rgba(0, 0, 0, 0.32)", - "offsetX": 0.0, - "offsetY": 3.0, - "blur": 8.0 - } - }); - - let position: KeyPosition = serde_json::from_value(fixture.clone()).unwrap(); - let serialized = serde_json::to_value(position).unwrap(); - - assert_eq!(serialized.get("shadow"), fixture.get("shadow")); - assert_eq!(serialized.get("activeShadow"), fixture.get("activeShadow")); - } - - #[test] - fn gradient_spec_tolerates_legacy_shape_and_serializes_canonically() { - let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ - "type": "linear", - "stops": [ - { "color": "c9", "pos": 1.4 }, - { "color": "c8", "pos": 0.8 }, - { "color": "c7", "pos": 0.7 }, - { "color": "c6", "pos": 0.6 }, - { "color": "c5", "pos": 0.5 }, - { "color": "c4", "pos": 0.4 }, - { "color": "c3", "pos": 0.3 }, - { "color": "c2", "pos": 0.2 }, - { "color": "c1", "pos": 0.1 }, - { "color": "c0", "pos": -0.2 } - ] - })) - .unwrap(); - - assert_eq!(gradient.angle, 90.0); - assert_eq!(gradient.stops.len(), 8); - assert_eq!(gradient.stops.first().unwrap().color, "c0"); - assert_eq!(gradient.stops.first().unwrap().pos, 0.0); - assert_eq!(gradient.stops.last().unwrap().color, "c7"); - - let canonical = serde_json::to_value(&gradient).unwrap(); - assert_eq!(canonical["angle"], 90.0); - assert_eq!(canonical["stops"].as_array().unwrap().len(), 8); - assert!(canonical.get("type").is_none()); - - let restored: GradientSpec = serde_json::from_value(canonical.clone()).unwrap(); - assert_eq!(serde_json::to_value(restored).unwrap(), canonical); - } - - #[test] - fn gradient_spec_rejects_fewer_than_two_stops() { - let error = serde_json::from_value::(serde_json::json!({ - "angle": 90, - "stops": [{ "color": "#FFFFFF", "pos": 0 }] - })) - .unwrap_err(); - - assert!(error.to_string().contains("at least two stops")); - } - - #[test] - fn gradient_spec_rejects_null_angle_but_preserves_stop_alpha_strings() { - let error = serde_json::from_value::(serde_json::json!({ - "angle": null, - "stops": [ - { "color": "rgba(1,2,3,0)", "pos": 0 }, - { "color": "rgba(1,2,3,1)", "pos": 1 } - ] - })) - .unwrap_err(); - assert!(error.to_string().contains("invalid gradient angle")); - - let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ - "stops": [ - { "color": "rgba(1,2,3,0)", "pos": 0 }, - { "color": "rgba(1,2,3,0.5)", "pos": 0.5 }, - { "color": "rgba(1,2,3,1)", "pos": 1 } - ] - })) - .unwrap(); - assert_eq!( - gradient - .stops - .iter() - .map(|stop| stop.color.as_str()) - .collect::>(), - ["rgba(1,2,3,0)", "rgba(1,2,3,0.5)", "rgba(1,2,3,1)"] - ); - } - - #[test] - fn note_border_stop_color_parser_matches_shared_fixture() { - let fixture: NoteBorderStopColorFixture = - serde_json::from_str(NOTE_BORDER_STOP_COLOR_FIXTURE).unwrap(); - - for case in fixture.valid { - assert_eq!( - note_border_representative_hex(&case.input), - Some(case.representative), - "valid fixture mismatch for {:?}", - case.input - ); - } - for input in fixture.invalid { - assert_eq!( - note_border_representative_hex(&input), - None, - "invalid fixture mismatch for {input:?}" - ); - } - } - - #[test] - fn new_gradient_fields_round_trip_and_missing_fields_remain_none() { - let legacy: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0 - })) - .unwrap(); - assert!(legacy.note_gradient.is_none()); - assert!(legacy.note_glow_gradient.is_none()); - assert!(legacy.note_border_gradient.is_none()); - assert!(legacy.font_gradient.is_none()); - assert!(legacy.active_font_gradient.is_none()); - - let value = serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteGradient": { - "angle": 15, - "stops": [ - { "color": "#1238", "pos": 0 }, - { "color": "rgba(4,5,6,.5)", "pos": 1 } - ] - }, - "noteGlowGradient": { - "angle": 25, - "stops": [ - { "color": "rgb(7,8,9)", "pos": 0 }, - { "color": "#ABC0", "pos": 1 } - ] - }, - "noteBorderColor": "#112233", - "noteBorderGradient": { - "angle": 45, - "stops": [ - { "color": "rgba(17,34,51,.5)", "pos": 0 }, - { "color": "#ABC", "pos": 1 } - ] - }, - "fontColor": "#112233", - "fontGradient": { - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }, - "activeFontColor": "#778899", - "activeFontGradient": { - "angle": 180, - "stops": [ - { "color": "#778899", "pos": 0 }, - { "color": "#AABBCC", "pos": 1 } - ] - } - }); - let position: KeyPosition = serde_json::from_value(value).unwrap(); - let serialized = serde_json::to_value(&position).unwrap(); - assert!(serialized.get("noteGradient").is_some()); - assert!(serialized.get("noteGlowGradient").is_some()); - assert!(serialized.get("noteBorderGradient").is_some()); - assert!(serialized.get("fontGradient").is_some()); - assert!(serialized.get("activeFontGradient").is_some()); - assert_eq!( - serde_json::from_value::(serialized).unwrap(), - position - ); - } - - #[test] - fn removed_text_outline_fields_scrub_every_collection_in_place() { - let position = serde_json::json!({ - "id": "keep-entry", - "fontStrokeColor": "#111111", - "activeFontStrokeColor": "#222222", - "counter": { - "stroke": { "idle": "#333333", "active": "#444444" }, - "strokeIdleGradient": { "stops": [] }, - "strokeActiveGradient": { "stops": [] }, - "fill": { "idle": "keep-idle", "active": "keep-active" } - } - }); - let mut value = serde_json::json!({ - "keyPositions": { "4key": [position.clone()] }, - "statPositions": { "4key": [position.clone()] }, - "graphPositions": { "4key": [position.clone()] }, - "knobPositions": { "4key": [position] } - }); - - assert!(scrub_removed_text_outline_fields(&mut value)); - for collection in POSITION_COLLECTION_FIELDS { - let entries = value[collection]["4key"].as_array().unwrap(); - assert_eq!(entries.len(), 1); - let position = entries[0].as_object().unwrap(); - assert_eq!(position["id"], "keep-entry"); - assert!(!position.contains_key("fontStrokeColor")); - assert!(!position.contains_key("activeFontStrokeColor")); - let counter = position["counter"].as_object().unwrap(); - assert!(!counter.contains_key("stroke")); - assert!(!counter.contains_key("strokeIdleGradient")); - assert!(!counter.contains_key("strokeActiveGradient")); - assert_eq!(counter["fill"]["idle"], "keep-idle"); - } - assert!(!scrub_removed_text_outline_fields(&mut value)); - } - - #[test] - fn note_gradients_atomically_canonicalize_color_and_alpha_shadows() { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteColor": "stale-body", - "noteOpacity": 75, - "noteOpacityTop": 1, - "noteOpacityBottom": 2, - "noteGradient": { - "angle": 15, - "stops": [ - { "color": "#1238", "pos": 0 }, - { "color": "rgba(4,5,6,.5)", "pos": 1 } - ] - }, - "noteGlowColor": "stale-glow", - "noteGlowOpacity": 60, - "noteGlowOpacityTop": 3, - "noteGlowOpacityBottom": 4, - "noteGlowGradient": { - "angle": 25, - "stops": [ - { "color": "rgb(7,8,9)", "pos": 0 }, - { "color": "#ABC0", "pos": 1 } - ] - } - })) - .unwrap(); - - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!( - position.note_color, - NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#040506".to_string(), - } - ); - assert_eq!(position.note_opacity_top, Some(40)); - assert_eq!(position.note_opacity_bottom, Some(38)); - assert_eq!( - position.note_glow_color, - Some(NoteColor::Gradient { - top: "#070809".to_string(), - bottom: "#AABBCC".to_string(), - }) - ); - assert_eq!(position.note_glow_opacity_top, Some(60)); - assert_eq!(position.note_glow_opacity_bottom, Some(0)); - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - } - - #[test] - fn synced_note_glow_canonicalization_refreshes_stale_mirror_without_pair_repair() { - let mut position = KeyPosition { - note_glow_sync_paint: true, - note_color: NoteColor::Gradient { - top: "#112233".to_string(), - bottom: "#445566".to_string(), - }, - note_gradient: serde_json::from_value(serde_json::json!({ - "angle": 180, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "rgba(68, 85, 102, 0.5)", "pos": 1 } - ] - })) - .unwrap(), - note_opacity: 80, - note_opacity_top: Some(80), - note_opacity_bottom: Some(40), - note_glow_color: Some(NoteColor::Solid("stale".to_string())), - note_glow_opacity: 70, - note_glow_opacity_top: Some(70), - note_glow_opacity_bottom: Some(70), - ..KeyPosition::default() - }; - - assert_eq!(position.canonicalize_gradient_pairs(), (true, false)); - assert_eq!(position.note_glow_gradient, position.note_gradient); - assert_eq!(position.note_glow_opacity, position.note_opacity); - assert_eq!( - position.note_glow_color.as_ref(), - Some(&position.note_color) - ); - assert_eq!(position.note_glow_opacity_top, position.note_opacity_top); - assert_eq!( - position.note_glow_opacity_bottom, - position.note_opacity_bottom - ); - } - - #[test] - fn synced_note_glow_canonicalization_is_idempotent_when_mirror_matches() { - let mut position = KeyPosition { - note_glow_sync_paint: true, - note_color: NoteColor::Solid("#112233".to_string()), - note_opacity: 80, - note_opacity_top: Some(70), - note_opacity_bottom: Some(60), - ..KeyPosition::default() - }; - assert!(position.mirror_note_body_to_glow()); - - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - } - - #[test] - fn font_gradient_pairs_are_optional_and_canonicalize_representative_colors() { - let mut position = KeyPosition { - font_color: Some("stale-idle".to_string()), - font_gradient: serde_json::from_value(serde_json::json!({ - "angle": 405, - "stops": [ - { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - })) - .unwrap(), - active_font_color: None, - active_font_gradient: serde_json::from_value(serde_json::json!({ - "angle": -45, - "stops": [ - { "color": "#778899", "pos": 0 }, - { "color": "rgb(170, 187, 204)", "pos": 1 } - ] - })) - .unwrap(), - ..KeyPosition::default() - }; - - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!(position.font_color.as_deref(), Some("rgba(17, 34, 51, .5)")); - assert_eq!(position.active_font_color.as_deref(), Some("#778899")); - assert_eq!(position.font_gradient.as_ref().unwrap().angle, 45.0); - assert_eq!(position.active_font_gradient.as_ref().unwrap().angle, 315.0); - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - - let serialized_default = serde_json::to_value(KeyPosition::default()).unwrap(); - assert!(serialized_default.get("fontGradient").is_none()); - assert!(serialized_default.get("activeFontGradient").is_none()); - } - - #[test] - fn note_gradient_rejects_invalid_original_stop_before_truncation() { - let mut position = KeyPosition { - note_color: NoteColor::Solid("preserved".to_string()), - note_gradient: serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#000000", "pos": 0.0 }, - { "color": "#111111", "pos": 0.1 }, - { "color": "#222222", "pos": 0.2 }, - { "color": "#333333", "pos": 0.3 }, - { "color": "#444444", "pos": 0.4 }, - { "color": "#555555", "pos": 0.5 }, - { "color": "#666666", "pos": 0.6 }, - { "color": "#777777", "pos": 0.7 }, - { "color": "invalid-discarded-stop", "pos": 1.0 } - ] - })) - .unwrap(), - ..KeyPosition::default() - }; - - assert_eq!(position.note_gradient.as_ref().unwrap().stops.len(), 8); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert!(position.note_gradient.is_none()); - assert_eq!( - position.note_color, - NoteColor::Solid("preserved".to_string()) - ); - } - - #[test] - fn note_border_gradient_canonicalization_uses_hex_and_drops_invalid_stop() { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteBorderColor": "#000000", - "noteBorderGradient": { - "angle": 90, - "stops": [ - { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, - { "color": "#ABC", "pos": 1 } - ] - } - })) - .unwrap(); - - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!(position.note_border_color.as_deref(), Some("#112233")); - assert!(position.note_border_gradient.is_some()); - assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); - - position.note_border_color = Some("#445566".to_string()); - position.note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "transparent", "pos": 1 } - ] - })) - .unwrap(); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert_eq!(position.note_border_color.as_deref(), Some("#445566")); - assert!(position.note_border_gradient.is_none()); - - position.note_border_gradient = serde_json::from_value(serde_json::json!({ - "angle": 90, - "stops": [ - { "color": "#000000", "pos": 0.0 }, - { "color": "#111111", "pos": 0.1 }, - { "color": "#222222", "pos": 0.2 }, - { "color": "#333333", "pos": 0.3 }, - { "color": "#444444", "pos": 0.4 }, - { "color": "#555555", "pos": 0.5 }, - { "color": "#666666", "pos": 0.6 }, - { "color": "#777777", "pos": 0.7 }, - { "color": "invalid-discarded-stop", "pos": 1.0 } - ] - })) - .unwrap(); - assert_eq!( - position.note_border_gradient.as_ref().unwrap().stops.len(), - 8 - ); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - assert!(position.note_border_gradient.is_none()); - } - - #[test] - fn counter_gradient_escape_differs_from_every_legacy_snapshot_literal() { - let legacy_literals = [ - "#FFFFFF", - "#000000", - "rgba(121, 121, 121, 0.9)", - "transparent", - ]; - let visual_pairs = [ - ("#FFFFFF", "rgba(255,255,255,1)"), - ("#000000", "rgba(0,0,0,1)"), - ("rgba(121, 121, 121, 0.9)", "rgba(121,121,121,0.9)"), - ]; - - for (input, expected) in visual_pairs { - let escaped = compact_canonical_rgba(input); - assert_eq!(escaped, expected); - assert!(legacy_literals.iter().all(|literal| escaped != *literal)); - } - } - - #[test] - fn removed_custom_stroke_evidence_blocks_legacy_default_migration_permanently() { - let legacy_counter = |stroke: serde_json::Value, gradient: bool| { - let mut counter = serde_json::json!({ - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { "idle": "#FFFFFF", "active": "#000000" }, - "stroke": stroke, - "gap": 6, - "fontSize": 16, - "fontWeight": 400, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - }); - if gradient { - counter["strokeIdleGradient"] = serde_json::json!({ "stops": [] }); - } - counter - }; - - for counter in [ - legacy_counter( - serde_json::json!({ "idle": "#123456", "active": "#FFFFFF" }), - false, - ), - legacy_counter( - serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), - true, - ), - ] { - let mut raw = - serde_json::json!({ "keyPositions": { "4key": [{ "counter": counter }] } }); - assert!(scrub_removed_text_outline_fields(&mut raw)); - let mut parsed: KeyCounterSettings = - serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); - assert_eq!(parsed.fill.idle, "rgba(255,255,255,1)"); - assert!(!parsed.migrate_legacy_defaults()); - assert_eq!(parsed.align, KeyCounterAlign::Top); - } - - let mut raw = serde_json::json!({ - "keyPositions": { "4key": [{ - "counter": legacy_counter( - serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), - false, - ) - }] } - }); - assert!(scrub_removed_text_outline_fields(&mut raw)); - let mut parsed: KeyCounterSettings = - serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); - assert!(parsed.migrate_legacy_defaults()); - assert_eq!(parsed.align, KeyCounterAlign::Bottom); - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeatureKeyPosition { - background_color: Option, - counter: PreFeatureCounterSettings, - } - - #[derive(serde::Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreUnifiedNotePosition { - dx: f64, - dy: f64, - width: f64, - count: u32, - note_color: NoteColor, - note_opacity: u32, - note_opacity_top: Option, - note_opacity_bottom: Option, - note_glow_color: Option, - note_glow_opacity: u32, - note_glow_opacity_top: Option, - note_glow_opacity_bottom: Option, - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct PreFeatureCounterSettings { - fill: KeyCounterColor, - placement: KeyCounterPlacement, - align: KeyCounterAlign, - align_mode: KeyCounterAlignMode, - gap: u32, - font_size: u32, - font_weight: u32, - font_family: Option, - font_italic: bool, - font_underline: bool, - font_strikethrough: bool, - } - - impl PreFeatureCounterSettings { - fn matches_legacy_migration_snapshot(&self) -> bool { - let shared = matches!(self.placement, KeyCounterPlacement::Inside) - && matches!(self.align, KeyCounterAlign::Top) - && matches!(self.align_mode, KeyCounterAlignMode::Center) - && self.gap == 6 - && self.font_size == 16 - && self.font_family.is_none() - && !self.font_italic - && !self.font_underline - && !self.font_strikethrough; - let oldest = self.fill.idle == "#FFFFFF" - && self.fill.active == "#000000" - && self.font_weight == 400; - let previous = self.fill.idle == "rgba(121, 121, 121, 0.9)" - && self.fill.active == "#FFFFFF" - && self.font_weight == 700; - shared && (oldest || previous) - } - } - - #[test] - fn pre_feature_shadow_downgrade_ignores_gradients_without_triggering_migration() { - for (legacy_fill, first_stop, expected_escape) in [ - ("#FFFFFF", "#FFFFFF", "rgba(255,255,255,1)"), - ( - "rgba(121, 121, 121, 0.9)", - "rgba(121, 121, 121, 0.9)", - "rgba(121,121,121,0.9)", - ), - ] { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "backgroundColor": "#102030", - "backgroundGradient": { - "angle": 90, - "stops": [ - { "color": "#102030", "pos": 0 }, - { "color": "#405060", "pos": 1 } - ] - }, - "counter": { - "enabled": true, - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { - "idle": legacy_fill, - "active": if legacy_fill == "#FFFFFF" { "#000000" } else { "#FFFFFF" } - }, - "fillIdleGradient": { - "angle": 90, - "stops": [ - { "color": first_stop, "pos": 0 }, - { "color": "#654321", "pos": 1 } - ] - }, - "gap": 6, - "fontSize": 16, - "fontWeight": if legacy_fill == "#FFFFFF" { 400 } else { 700 }, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - } - })) - .unwrap(); - - let (_, pair_repaired) = position.canonicalize_gradient_pairs(); - assert!(pair_repaired); - assert_eq!(position.counter.fill.idle, expected_escape); - assert!(!position.counter.migrate_legacy_defaults()); - - let serialized = serde_json::to_value(&position).unwrap(); - let shadow: PreFeatureKeyPosition = serde_json::from_value(serialized).unwrap(); - assert_eq!(shadow.background_color.as_deref(), Some("#102030")); - assert_eq!(shadow.counter.fill.idle, expected_escape); - assert!(!shadow.counter.matches_legacy_migration_snapshot()); - } - } - - #[test] - fn pre_unification_round_trip_drops_siblings_and_preserves_note_shadows() { - let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ - "dx": 0, - "dy": 0, - "width": 60, - "count": 0, - "noteOpacity": 80, - "noteGradient": { - "angle": 45, - "stops": [ - { "color": "rgba(17,34,51,.5)", "pos": 0 }, - { "color": "#44556640", "pos": 1 } - ] - }, - "noteGlowOpacity": 60, - "noteGlowGradient": { - "angle": 135, - "stops": [ - { "color": "#77889980", "pos": 0 }, - { "color": "rgb(170,187,204)", "pos": 1 } - ] - } - })) - .unwrap(); - assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); - let expected_note_color = position.note_color.clone(); - let expected_note_top = position.note_opacity_top; - let expected_note_bottom = position.note_opacity_bottom; - let expected_glow_color = position.note_glow_color.clone(); - let expected_glow_top = position.note_glow_opacity_top; - let expected_glow_bottom = position.note_glow_opacity_bottom; - - let new_wire = serde_json::to_value(position).unwrap(); - let old: PreUnifiedNotePosition = serde_json::from_value(new_wire).unwrap(); - let old_wire = serde_json::to_value(old).unwrap(); - assert!(old_wire.get("noteGradient").is_none()); - assert!(old_wire.get("noteGlowGradient").is_none()); - - let restored: KeyPosition = serde_json::from_value(old_wire).unwrap(); - assert!(restored.note_gradient.is_none()); - assert!(restored.note_glow_gradient.is_none()); - assert_eq!(restored.note_color, expected_note_color); - assert_eq!(restored.note_opacity_top, expected_note_top); - assert_eq!(restored.note_opacity_bottom, expected_note_bottom); - assert_eq!(restored.note_glow_color, expected_glow_color); - assert_eq!(restored.note_glow_opacity_top, expected_glow_top); - assert_eq!(restored.note_glow_opacity_bottom, expected_glow_bottom); - } - - #[test] - fn counter_migration_without_gradients_preserves_both_legacy_upgrade_branches() { - for snapshot in [ - serde_json::json!({ - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { "idle": "#FFFFFF", "active": "#000000" }, - "stroke": { "idle": "#000000", "active": "#FFFFFF" }, - "gap": 6, - "fontSize": 16, - "fontWeight": 400, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - }), - serde_json::json!({ - "placement": "inside", - "align": "top", - "alignMode": "center", - "fill": { - "idle": "rgba(121, 121, 121, 0.9)", - "active": "#FFFFFF" - }, - "stroke": { "idle": "transparent", "active": "transparent" }, - "gap": 6, - "fontSize": 16, - "fontWeight": 700, - "fontFamily": null, - "fontItalic": false, - "fontUnderline": false, - "fontStrikethrough": false - }), - ] { - let mut counter: KeyCounterSettings = serde_json::from_value(snapshot).unwrap(); - - assert!(counter.migrate_legacy_defaults()); - assert_eq!(counter, KeyCounterSettings::default()); - } - } -} +mod tests; diff --git a/src-tauri/src/models/note_settings.rs b/src-tauri/src/models/note_settings.rs new file mode 100644 index 000000000..46329f117 --- /dev/null +++ b/src-tauri/src/models/note_settings.rs @@ -0,0 +1,248 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::NoteColor; + +pub(super) fn default_note_effect_enabled() -> bool { + true +} +pub(super) fn default_key_height() -> f64 { + 60.0 +} +pub(super) fn default_key_note_color() -> NoteColor { + NoteColor::Solid("#FFFFFF".to_string()) +} +pub(super) fn default_key_note_opacity() -> u32 { + 90 +} +pub(super) fn default_note_glow_enabled() -> bool { + false +} +pub(super) fn default_note_glow_size() -> f64 { + 10.0 +} + +pub(super) fn default_note_border_opacity() -> u32 { + 100 +} +pub(super) fn default_note_glow_opacity() -> u32 { + 70 +} +pub(super) fn default_note_auto_y_correction() -> bool { + true +} +fn default_note_frame_limit() -> u32 { + 0 +} + +fn default_fade_top_px() -> u32 { + 50 +} + +fn default_reverse_fade_bottom_px() -> u32 { + 50 +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", default)] +pub struct NoteSettings { + // Legacy: 전역 노트 라운딩 (개별 키 noteBorderRadius로 마이그레이션됨) + #[serde(default, skip_serializing)] + pub border_radius: Option, + #[serde(default = "default_note_frame_limit")] + pub frame_limit: u32, + pub speed: u32, + pub track_height: u32, + pub reverse: bool, + pub fade_position: FadePosition, + #[serde(default = "default_fade_top_px")] + pub fade_top_px: u32, + #[serde(default)] + pub fade_bottom_px: u32, + #[serde(default)] + pub reverse_fade_top_px: u32, + #[serde(default = "default_reverse_fade_bottom_px")] + pub reverse_fade_bottom_px: u32, + pub delayed_note_enabled: bool, + pub short_note_threshold_ms: u32, + pub short_note_min_length_px: u32, + #[serde(default)] + pub key_display_delay_ms: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum FadePosition { + Auto, + Top, + Bottom, + None, + Both, +} + +/// 이미지 맞춤 설정 (CSS object-fit과 동일) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum ImageFit { + #[default] + Cover, + Contain, + Fill, + None, +} + +impl Default for NoteSettings { + fn default() -> Self { + Self { + border_radius: None, + frame_limit: default_note_frame_limit(), + speed: 400, + track_height: 300, + reverse: false, + fade_position: FadePosition::Auto, + fade_top_px: 50, + fade_bottom_px: 0, + reverse_fade_top_px: 0, + reverse_fade_bottom_px: 50, + delayed_note_enabled: false, + short_note_threshold_ms: 50, + short_note_min_length_px: 30, + key_display_delay_ms: 0, + } + } +} + +impl NoteSettings { + /// Legacy migration: fadePosition enum → pixel-based fade values + /// serde defaults로 채워진 새 필드가 auto 기본값과 동일하고 + /// fadePosition이 non-auto면 레거시 store로 판단하여 변환 + pub fn migrate_fade_position(&mut self) { + let d = Self::default(); + let at_auto_defaults = self.fade_top_px == d.fade_top_px + && self.fade_bottom_px == d.fade_bottom_px + && self.reverse_fade_top_px == d.reverse_fade_top_px + && self.reverse_fade_bottom_px == d.reverse_fade_bottom_px; + + if !at_auto_defaults { + return; + } + + match self.fade_position { + FadePosition::Auto => {} + FadePosition::Top => { + // 항상 상단 페이드 + self.reverse_fade_top_px = d.fade_top_px; + self.reverse_fade_bottom_px = 0; + } + FadePosition::Bottom => { + // 항상 하단 페이드 + self.fade_top_px = 0; + self.fade_bottom_px = d.reverse_fade_bottom_px; + } + FadePosition::None => { + // 페이드 없음 + self.fade_top_px = 0; + self.reverse_fade_bottom_px = 0; + } + FadePosition::Both => { + // 양방향 페이드 + self.fade_bottom_px = d.fade_top_px; + self.reverse_fade_top_px = d.reverse_fade_bottom_px; + } + } + self.fade_position = FadePosition::Auto; + } +} + +impl TabNoteSettings { + /// Legacy migration: 탭 오버라이드의 fadePosition → pixel-based fade values + /// Option 기반이라 None = 필드 부재 확실 (heuristic 불필요) + pub fn migrate_fade_position(&mut self) { + let fp = match self.fade_position.take() { + Some(fp) => fp, + None => return, + }; + + let has_new_fields = self.fade_top_px.is_some() + || self.fade_bottom_px.is_some() + || self.reverse_fade_top_px.is_some() + || self.reverse_fade_bottom_px.is_some(); + + if has_new_fields { + return; // 이미 새 필드가 설정됨 + } + + let d = NoteSettings::default(); + match fp { + FadePosition::Auto => { + // 명시적 auto 오버라이드 → 전역과 무관하게 auto 동작 보장 + self.fade_top_px = Some(d.fade_top_px); + self.fade_bottom_px = Some(d.fade_bottom_px); + self.reverse_fade_top_px = Some(d.reverse_fade_top_px); + self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); + } + FadePosition::Top => { + self.fade_top_px = Some(d.fade_top_px); + self.fade_bottom_px = Some(0); + self.reverse_fade_top_px = Some(d.fade_top_px); + self.reverse_fade_bottom_px = Some(0); + } + FadePosition::Bottom => { + self.fade_top_px = Some(0); + self.fade_bottom_px = Some(d.reverse_fade_bottom_px); + self.reverse_fade_top_px = Some(0); + self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); + } + FadePosition::None => { + self.fade_top_px = Some(0); + self.fade_bottom_px = Some(0); + self.reverse_fade_top_px = Some(0); + self.reverse_fade_bottom_px = Some(0); + } + FadePosition::Both => { + self.fade_top_px = Some(d.fade_top_px); + self.fade_bottom_px = Some(d.fade_top_px); + self.reverse_fade_top_px = Some(d.reverse_fade_bottom_px); + self.reverse_fade_bottom_px = Some(d.reverse_fade_bottom_px); + } + } + } +} + +/// 탭별 노트 트랙 설정 (전역 NoteSettings를 탭별로 오버라이드) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[derive(Default)] +pub struct TabNoteSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub frame_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub track_height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fade_position: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fade_top_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fade_bottom_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse_fade_top_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse_fade_bottom_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delayed_note_enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub short_note_threshold_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub short_note_min_length_px: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_display_delay_ms: Option, +} + +/// 탭별 노트 트랙 설정 오버라이드 맵 (키: 탭 ID, 값: TabNoteSettings) +pub type TabNoteOverrides = HashMap; diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs new file mode 100644 index 000000000..08a7d8f2d --- /dev/null +++ b/src-tauri/src/models/settings.rs @@ -0,0 +1,215 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SettingsState { + pub hardware_acceleration: bool, + pub always_on_top: bool, + pub overlay_locked: bool, + pub note_effect: bool, + #[serde(default)] + pub note_settings: NoteSettings, + pub angle_mode: String, + pub language: String, + pub laboratory_enabled: bool, + #[serde(default)] + pub developer_mode_enabled: bool, + #[serde(default)] + pub tray_enabled: bool, + #[serde(default = "default_auto_update_enabled")] + pub auto_update_enabled: bool, + pub background_color: String, + #[serde(rename = "useCustomCSS")] + pub use_custom_css: bool, + #[serde(rename = "customCSS")] + #[serde(default)] + pub custom_css: CustomCss, + #[serde(default)] + pub font_settings: FontSettings, + #[serde(rename = "useCustomJS")] + pub use_custom_js: bool, + #[serde(rename = "customJS")] + #[serde(default)] + pub custom_js: CustomJs, + pub overlay_resize_anchor: OverlayResizeAnchor, + #[serde(default)] + pub key_counter_enabled: bool, + #[serde(default)] + pub grid_settings: GridSettings, + #[serde(default)] + pub shortcuts: ShortcutsState, + #[serde(default)] + pub obs_mode_enabled: bool, +} + +impl Default for SettingsState { + fn default() -> Self { + // 설정 기본값의 단일 원천은 AppStoreData::default, 빈 store 사영으로 유도 + AppStoreData::default().settings_state() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +#[derive(Default)] +pub struct NoteSettingsPatch { + pub frame_limit: Option, + pub speed: Option, + pub track_height: Option, + pub reverse: Option, + pub fade_position: Option, + pub fade_top_px: Option, + pub fade_bottom_px: Option, + pub reverse_fade_top_px: Option, + pub reverse_fade_bottom_px: Option, + pub delayed_note_enabled: Option, + pub short_note_threshold_ms: Option, + pub short_note_min_length_px: Option, + pub key_display_delay_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettingsPatchInput { + pub hardware_acceleration: Option, + pub always_on_top: Option, + pub overlay_locked: Option, + pub note_effect: Option, + pub note_settings: Option, + pub angle_mode: Option, + pub language: Option, + pub laboratory_enabled: Option, + pub developer_mode_enabled: Option, + pub tray_enabled: Option, + pub auto_update_enabled: Option, + pub background_color: Option, + #[serde(rename = "useCustomCSS")] + pub use_custom_css: Option, + #[serde(rename = "customCSS")] + pub custom_css: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_settings: Option, + #[serde(rename = "useCustomJS")] + pub use_custom_js: Option, + #[serde(rename = "customJS")] + pub custom_js: Option, + pub overlay_resize_anchor: Option, + pub key_counter_enabled: Option, + pub grid_settings: Option, + pub shortcuts: Option, + pub obs_mode_enabled: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CustomCssPatch { + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CustomJsPatch { + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsDiff { + pub changed: SettingsPatch, + #[serde(skip_serializing_if = "Option::is_none")] + pub full: Option, +} + +impl SettingsDiff { + pub fn changed_count(&self) -> usize { + let p = &self.changed; + [ + p.hardware_acceleration.is_some(), + p.always_on_top.is_some(), + p.overlay_locked.is_some(), + p.note_effect.is_some(), + p.note_settings.is_some(), + p.angle_mode.is_some(), + p.language.is_some(), + p.laboratory_enabled.is_some(), + p.developer_mode_enabled.is_some(), + p.tray_enabled.is_some(), + p.auto_update_enabled.is_some(), + p.background_color.is_some(), + p.use_custom_css.is_some(), + p.custom_css.is_some(), + p.font_settings.is_some(), + p.use_custom_js.is_some(), + p.custom_js.is_some(), + p.overlay_resize_anchor.is_some(), + p.key_counter_enabled.is_some(), + p.grid_settings.is_some(), + p.shortcuts.is_some(), + p.obs_mode_enabled.is_some(), + ] + .iter() + .filter(|&&x| x) + .count() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettingsPatch { + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_acceleration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub always_on_top: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub overlay_locked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub note_effect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub note_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub angle_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub laboratory_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub developer_mode_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tray_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_update_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_color: Option, + #[serde(rename = "useCustomCSS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_custom_css: Option, + #[serde(rename = "customCSS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_css: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_settings: Option, + #[serde(rename = "useCustomJS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_custom_js: Option, + #[serde(rename = "customJS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_js: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub overlay_resize_anchor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_counter_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub grid_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shortcuts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub obs_mode_enabled: Option, +} diff --git a/src-tauri/src/models/shortcuts.rs b/src-tauri/src/models/shortcuts.rs new file mode 100644 index 000000000..483c13a16 --- /dev/null +++ b/src-tauri/src/models/shortcuts.rs @@ -0,0 +1,193 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ShortcutBinding { + pub key: String, + #[serde(default)] + pub ctrl: bool, + #[serde(default)] + pub shift: bool, + #[serde(default)] + pub alt: bool, + #[serde(default)] + pub meta: bool, +} + +fn default_toggle_overlay_shortcut() -> ShortcutBinding { + // 기본 단축키 — Windows/Linux: Ctrl+Shift+O, macOS: Cmd+Shift+O + if cfg!(target_os = "macos") { + ShortcutBinding { + key: "KeyO".to_string(), + ctrl: false, + shift: true, + alt: false, + meta: true, + } + } else { + ShortcutBinding { + key: "KeyO".to_string(), + ctrl: true, + shift: true, + alt: false, + meta: false, + } + } +} + +fn default_switch_key_mode_shortcut() -> ShortcutBinding { + ShortcutBinding { + key: "Tab".to_string(), + ctrl: false, + shift: false, + alt: false, + meta: false, + } +} + +fn default_unbound_shortcut() -> ShortcutBinding { + ShortcutBinding { + key: "".to_string(), + ctrl: false, + shift: false, + alt: false, + meta: false, + } +} + +fn default_toggle_settings_panel_shortcut() -> ShortcutBinding { + if cfg!(target_os = "macos") { + ShortcutBinding { + key: "KeyB".to_string(), + ctrl: false, + shift: false, + alt: false, + meta: true, + } + } else { + ShortcutBinding { + key: "KeyB".to_string(), + ctrl: true, + shift: false, + alt: false, + meta: false, + } + } +} + +fn default_zoom_in_shortcut() -> ShortcutBinding { + if cfg!(target_os = "macos") { + ShortcutBinding { + key: "Equal".to_string(), + ctrl: false, + shift: false, + alt: false, + meta: true, + } + } else { + ShortcutBinding { + key: "Equal".to_string(), + ctrl: true, + shift: false, + alt: false, + meta: false, + } + } +} + +fn default_zoom_out_shortcut() -> ShortcutBinding { + if cfg!(target_os = "macos") { + ShortcutBinding { + key: "Minus".to_string(), + ctrl: false, + shift: false, + alt: false, + meta: true, + } + } else { + ShortcutBinding { + key: "Minus".to_string(), + ctrl: true, + shift: false, + alt: false, + meta: false, + } + } +} + +fn default_zoom_reset_shortcut() -> ShortcutBinding { + if cfg!(target_os = "macos") { + ShortcutBinding { + key: "Digit0".to_string(), + ctrl: false, + shift: false, + alt: false, + meta: true, + } + } else { + ShortcutBinding { + key: "Digit0".to_string(), + ctrl: true, + shift: false, + alt: false, + meta: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ShortcutsState { + #[serde(default = "default_toggle_overlay_shortcut")] + pub toggle_overlay: ShortcutBinding, + #[serde(default = "default_unbound_shortcut")] + pub toggle_overlay_lock: ShortcutBinding, + #[serde(default = "default_unbound_shortcut")] + pub toggle_always_on_top: ShortcutBinding, + #[serde(default = "default_switch_key_mode_shortcut")] + pub switch_key_mode: ShortcutBinding, + #[serde(default = "default_toggle_settings_panel_shortcut")] + pub toggle_settings_panel: ShortcutBinding, + #[serde(default = "default_zoom_in_shortcut")] + pub zoom_in: ShortcutBinding, + #[serde(default = "default_zoom_out_shortcut")] + pub zoom_out: ShortcutBinding, + #[serde(default = "default_zoom_reset_shortcut")] + pub reset_zoom: ShortcutBinding, +} + +impl Default for ShortcutsState { + fn default() -> Self { + Self { + toggle_overlay: default_toggle_overlay_shortcut(), + toggle_overlay_lock: default_unbound_shortcut(), + toggle_always_on_top: default_unbound_shortcut(), + switch_key_mode: default_switch_key_mode_shortcut(), + toggle_settings_panel: default_toggle_settings_panel_shortcut(), + zoom_in: default_zoom_in_shortcut(), + zoom_out: default_zoom_out_shortcut(), + reset_zoom: default_zoom_reset_shortcut(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ShortcutsPatchInput { + #[serde(skip_serializing_if = "Option::is_none")] + pub toggle_overlay: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toggle_overlay_lock: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toggle_always_on_top: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub switch_key_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub toggle_settings_panel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom_in: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom_out: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reset_zoom: Option, +} diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs new file mode 100644 index 000000000..73d043d15 --- /dev/null +++ b/src-tauri/src/models/tests.rs @@ -0,0 +1,1306 @@ +use super::{ + compact_canonical_rgba, key_mappings_contain_multi, normalize_key_mappings, normalize_key_slot, + note_border_representative_hex, scrub_removed_text_outline_fields, AppStoreData, FadePosition, + GradientSpec, GraphPosition, GraphStatType, GraphType, ImageTransform, KeyCounterAlign, + KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, + KeyPosition, KeySlot, KnobPosition, NoteAlignment, NoteColor, NoteSettings, SlotMatch, + StatPosition, StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, +}; +use serde::Deserialize; + +const NOTE_BORDER_STOP_COLOR_FIXTURE: &str = + include_str!("../../../tests/fixtures/note-border-stop-colors.json"); + +#[derive(Deserialize)] +struct NoteBorderStopColorFixture { + valid: Vec, + invalid: Vec, +} + +#[derive(Deserialize)] +struct ValidNoteBorderStopColor { + input: String, + representative: String, +} + +#[test] +fn app_store_data_defaults_missing_bar_count_to_four() { + let mut value = serde_json::to_value(AppStoreData::default()).unwrap(); + value.as_object_mut().unwrap().remove("barCount"); + + let restored: AppStoreData = serde_json::from_value(value).unwrap(); + + assert_eq!(restored.bar_count, 4); +} + +#[test] +fn legacy_string_key_mappings_round_trip_without_loss() { + let raw = serde_json::json!({ + "4key": ["A", "A+B", "+", ""] + }); + + let mappings: KeyMappings = serde_json::from_value(raw.clone()).unwrap(); + + assert_eq!(serde_json::to_value(mappings).unwrap(), raw); +} + +#[test] +fn legacy_700_weights_migrate_to_400_with_bold_modifier() { + let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); + let object = raw.as_object_mut().unwrap(); + object.insert("fontWeight".to_string(), serde_json::json!(700)); + object.remove("fontBold"); + let counter = object["counter"].as_object_mut().unwrap(); + counter.insert("fontWeight".to_string(), serde_json::json!(700)); + counter.remove("fontBold"); + + let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); + assert!(position.migrate_legacy_font_weight()); + assert_eq!(position.font_weight, Some(400)); + assert_eq!(position.font_bold, Some(true)); + assert_eq!(position.counter.font_weight, 400); + assert_eq!(position.counter.font_bold, Some(true)); + assert!(!position.migrate_legacy_font_weight()); +} + +#[test] +fn legacy_non_bold_weights_remain_sparse() { + let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); + let object = raw.as_object_mut().unwrap(); + object.insert("fontWeight".to_string(), serde_json::json!(600)); + object.remove("fontBold"); + let counter = object["counter"].as_object_mut().unwrap(); + counter.insert("fontWeight".to_string(), serde_json::json!(500)); + counter.remove("fontBold"); + + let mut position: KeyPosition = serde_json::from_value(raw).unwrap(); + assert!(!position.migrate_legacy_font_weight()); + assert_eq!(position.font_weight, Some(600)); + assert_eq!(position.font_bold, None); + assert_eq!(position.counter.font_weight, 500); + assert_eq!(position.counter.font_bold, None); +} + +#[test] +fn element_id_defaults_to_empty_and_flattens_into_every_position_type() { + let id = uuid::Uuid::new_v4().to_string(); + let position = KeyPosition { + id: id.clone(), + ..KeyPosition::default() + }; + let mut values = [ + serde_json::to_value(&position).unwrap(), + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }) + .unwrap(), + ]; + + assert!(values.iter().all(|value| value["id"] == id)); + for value in &mut values { + value.as_object_mut().unwrap().remove("id"); + } + let stat: StatPosition = serde_json::from_value(values[1].clone()).unwrap(); + let graph: GraphPosition = serde_json::from_value(values[2].clone()).unwrap(); + let knob: KnobPosition = serde_json::from_value(values[3].clone()).unwrap(); + assert!(stat.position.id.is_empty()); + assert!(graph.position.id.is_empty()); + assert!(knob.position.id.is_empty()); + + let missing: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0 + })) + .unwrap(); + assert!(missing.id.is_empty()); +} + +#[test] +fn position_wrappers_preserve_legacy_missing_field_defaults_and_round_trip() { + let base = serde_json::json!({ + "dx": 1, + "dy": 2, + "width": 60, + "count": 3 + }); + let mut stat_value = base.clone(); + stat_value["statType"] = serde_json::json!("kpsAvg"); + let stat: StatPosition = serde_json::from_value(stat_value).unwrap(); + + let mut graph_value = base.clone(); + graph_value["statType"] = serde_json::json!("total"); + graph_value["graphType"] = serde_json::json!("bar"); + graph_value["graphSpeed"] = serde_json::json!(120); + graph_value["graphColor"] = serde_json::json!("#123456"); + let graph: GraphPosition = serde_json::from_value(graph_value).unwrap(); + + let knob: KnobPosition = serde_json::from_value(base).unwrap(); + + assert!(graph.show_avg_line); + assert!(knob.axis_id.is_empty()); + assert_eq!(knob.sensitivity, 1.0); + assert!(!knob.reverse); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&stat).unwrap()).unwrap(), + stat + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&graph).unwrap()).unwrap(), + graph + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&knob).unwrap()).unwrap(), + knob + ); +} + +#[test] +fn position_serialization_field_order_and_related_defaults_are_stable() { + let position = KeyPosition::default(); + assert!(serde_json::to_string(&position).unwrap().starts_with( + r#"{"dx":0.0,"dy":0.0,"width":60.0,"height":60.0,"hidden":false,"activeImage":null"# + )); + assert!(serde_json::to_string(&StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }) + .unwrap() + .starts_with(r#"{"statType":"kps","dx":0.0,"dy":0.0,"width":60.0"#)); + assert!(serde_json::to_string(&GraphPosition { + stat_type: GraphStatType::KpsAvg, + graph_type: GraphType::Bar, + graph_speed: 120, + graph_color: "#123456".to_string(), + show_avg_line: false, + position: position.clone(), + }) + .unwrap() + .starts_with( + r##"{"statType":"kpsAvg","graphType":"bar","graphSpeed":120,"graphColor":"#123456","showAvgLine":false,"dx":0.0"## + )); + assert!(serde_json::to_string(&KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 2.5, + reverse: true, + position, + }) + .unwrap() + .starts_with(r#"{"axisId":"axis","sensitivity":2.5,"reverse":true,"dx":0.0"#)); + assert_eq!( + serde_json::to_string(&ImageTransform::default()).unwrap(), + r#"{"offsetX":0.0,"offsetY":0.0,"rotation":0.0,"scale":1.0}"# + ); + assert_eq!(NoteAlignment::default(), NoteAlignment::Center); +} + +#[test] +fn new_surface_gradients_flatten_into_every_position_collection() { + let position = KeyPosition { + note_border_gradient: serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + })) + .unwrap(), + font_gradient: serde_json::from_value(serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#556677", "pos": 0 }, + { "color": "#8899AA", "pos": 1 } + ] + })) + .unwrap(), + active_font_gradient: serde_json::from_value(serde_json::json!({ + "angle": 135, + "stops": [ + { "color": "#AABBCC", "pos": 0 }, + { "color": "#DDEEFF", "pos": 1 } + ] + })) + .unwrap(), + ..KeyPosition::default() + }; + let values = [ + serde_json::to_value(&position).unwrap(), + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: position.clone(), + }) + .unwrap(), + serde_json::to_value(KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }) + .unwrap(), + ]; + + for value in values { + assert_eq!(value["noteBorderGradient"]["angle"], 90.0); + assert_eq!(value["fontGradient"]["angle"], 45.0); + assert_eq!(value["activeFontGradient"]["angle"], 135.0); + } +} + +#[test] +fn multi_key_slot_wire_shape_and_canonical_are_stable() { + let raw = serde_json::json!({ "keys": ["LEFT CTRL", "Z"], "match": "all" }); + + let slot: KeySlot = serde_json::from_value(raw.clone()).unwrap(); + + assert_eq!( + slot, + KeySlot::Multi { + keys: vec!["LEFT CTRL".to_string(), "Z".to_string()], + match_mode: SlotMatch::All, + } + ); + assert_eq!(slot.canonical(), "LEFT CTRL+Z"); + assert_eq!(serde_json::to_value(slot).unwrap(), raw); +} + +#[test] +fn malformed_key_slots_normalize_without_deserialization_failure() { + let too_many = (0..=MAX_SLOT_KEYS) + .map(|index| serde_json::Value::String(format!("K{index}"))) + .collect::>(); + let cases = [ + (serde_json::json!({ "keys": ["Z"] }), KeySlot::default()), + ( + serde_json::json!({ + "keys": ["A", 7, "", "A", "B+C", "D|E", "B"], + "match": "any", + "ignored": true + }), + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }, + ), + ( + serde_json::json!({ "keys": ["Z"], "match": "all" }), + KeySlot::Single("Z".to_string()), + ), + ( + serde_json::json!({ "keys": [], "match": "any" }), + KeySlot::default(), + ), + ( + serde_json::json!({ "keys": too_many, "match": "any" }), + KeySlot::Multi { + keys: (0..MAX_SLOT_KEYS) + .map(|index| format!("K{index}")) + .collect(), + match_mode: SlotMatch::Any, + }, + ), + (serde_json::json!(42), KeySlot::default()), + (serde_json::json!(["A", "B"]), KeySlot::default()), + (serde_json::Value::Null, KeySlot::default()), + ]; + + for (raw, expected) in cases { + let slot: KeySlot = serde_json::from_value(raw).unwrap(); + assert_eq!(slot, expected); + } +} + +#[test] +fn key_slot_wire_order_and_invalid_match_fallback_are_stable() { + let slot = KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string()], + match_mode: SlotMatch::Any, + }; + assert_eq!( + serde_json::to_string(&slot).unwrap(), + r#"{"keys":["A","B"],"match":"any"}"# + ); + + for raw in [ + serde_json::json!({ "keys": ["A", "B"], "match": "ALL" }), + serde_json::json!({ "keys": ["A", "B"], "match": null }), + serde_json::json!({ "keys": ["A", "B"] }), + ] { + assert_eq!(normalize_key_slot(raw.clone()), KeySlot::default()); + assert_eq!( + serde_json::from_value::(raw).unwrap(), + KeySlot::default() + ); + } + + for key in ["A+B", "A|B", "+"] { + let slot: KeySlot = serde_json::from_value(serde_json::json!(key)).unwrap(); + assert_eq!(slot, KeySlot::Single(key.to_string())); + assert_eq!(slot.canonical(), key); + } +} + +#[test] +fn key_mapping_normalization_preserves_first_seen_members_and_helper_semantics() { + let mut mappings = KeyMappings::from([( + "mode".to_string(), + vec![ + KeySlot::Multi { + keys: vec![ + "A".to_string(), + String::new(), + "A".to_string(), + "B+C".to_string(), + "B".to_string(), + "C|D".to_string(), + "C".to_string(), + ], + match_mode: SlotMatch::Any, + }, + KeySlot::Multi { + keys: vec!["ONLY".to_string(), "ONLY".to_string()], + match_mode: SlotMatch::All, + }, + KeySlot::Multi { + keys: Vec::new(), + match_mode: SlotMatch::Any, + }, + KeySlot::Single("A+B".to_string()), + ], + )]); + + assert!(key_mappings_contain_multi(&mappings)); + normalize_key_mappings(&mut mappings); + + let slots = &mappings["mode"]; + assert_eq!( + slots, + &[ + KeySlot::Multi { + keys: vec!["A".to_string(), "B".to_string(), "C".to_string()], + match_mode: SlotMatch::Any, + }, + KeySlot::Single("ONLY".to_string()), + KeySlot::default(), + KeySlot::Single("A+B".to_string()), + ] + ); + assert_eq!( + slots[0].members().map(String::as_str).collect::>(), + ["A", "B", "C"] + ); + assert_eq!(slots[0].canonical(), "A|B|C"); + assert!(slots[0].is_multi()); + assert!(!slots[1].is_multi()); + assert!(slots[2].is_unassigned()); + assert!(key_mappings_contain_multi(&mappings)); +} + +#[test] +fn stat_type_wire_values_round_trip() { + for (stat_type, wire_value) in [ + (StatType::Kps, "kps"), + (StatType::KpsAvg, "kpsAvg"), + (StatType::KpsMax, "kpsMax"), + (StatType::Total, "total"), + ] { + let serialized = serde_json::to_value(&stat_type).unwrap(); + assert_eq!(serialized, wire_value); + + let restored: StatType = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored, stat_type); + } +} + +// 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입 +fn key_position_json(visual_px: &str) -> String { + format!( + r##"{{ + "dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0, + "count": 0, "noteColor": "#FFFFFF", "noteOpacity": 80, + {visual_px} + }}"## + ) +} + +// 기존 정수 저장값이 f64 필드로 그대로 역직렬화되는지 (하위 호환) +#[test] +fn visual_px_fields_accept_integer_json() { + let json = key_position_json(r#""noteWidth": 100, "noteBorderRadius": 8, "noteGlowSize": 20"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_width, Some(100.0)); + assert_eq!(pos.note_border_radius, Some(8.0)); + assert_eq!(pos.note_glow_size, 20.0); +} + +// 소수 저장값이 정상 역직렬화되는지 +#[test] +fn visual_px_fields_accept_decimal_json() { + let json = + key_position_json(r#""noteWidth": 100.5, "noteBorderRadius": 8.5, "noteGlowSize": 20.5"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_width, Some(100.5)); + assert_eq!(pos.note_border_radius, Some(8.5)); + assert_eq!(pos.note_glow_size, 20.5); +} + +// note_glow_size 미지정 시 기본값(10.0) 적용 +#[test] +fn note_glow_size_defaults_to_10() { + let json = key_position_json(r#""noteWidth": null"#); + let pos: KeyPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos.note_glow_size, 10.0); + assert_eq!(pos.note_width, None); +} + +#[test] +fn gradient_opacity_fields_survive_serde_round_trip() { + let json = key_position_json( + r#""noteOpacityTop": 91, "noteOpacityBottom": 37, + "noteGlowOpacityTop": 64, "noteGlowOpacityBottom": 18"#, + ); + let position: KeyPosition = serde_json::from_str(&json).unwrap(); + + assert_eq!(position.note_opacity_top, Some(91)); + assert_eq!(position.note_opacity_bottom, Some(37)); + assert_eq!(position.note_glow_opacity_top, Some(64)); + assert_eq!(position.note_glow_opacity_bottom, Some(18)); + + let serialized = serde_json::to_value(&position).unwrap(); + assert_eq!(serialized["noteOpacityTop"], 91); + assert_eq!(serialized["noteOpacityBottom"], 37); + assert_eq!(serialized["noteGlowOpacityTop"], 64); + assert_eq!(serialized["noteGlowOpacityBottom"], 18); + + let restored: KeyPosition = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored, position); +} + +#[test] +fn note_settings_1_3_format_still_preserves_every_field() { + // 1.3 시절 noteSettings 전체 필드 실형식 + let fixture = r#"{ + "borderRadius": 9, + "speed": 456, + "trackHeight": 222, + "reverse": true, + "fadePosition": "bottom", + "delayedNoteEnabled": true, + "shortNoteThresholdMs": 73, + "shortNoteMinLengthPx": 41 + }"#; + let settings: NoteSettings = serde_json::from_str(fixture).unwrap(); + + assert_eq!(settings.border_radius, Some(9)); + assert_eq!(settings.speed, 456); + assert_eq!(settings.track_height, 222); + assert!(settings.reverse); + assert_eq!(settings.fade_position, FadePosition::Bottom); + assert!(settings.delayed_note_enabled); + assert_eq!(settings.short_note_threshold_ms, 73); + assert_eq!(settings.short_note_min_length_px, 41); +} + +#[test] +fn key_position_1_0_missing_visual_fields_uses_legacy_defaults() { + let position: KeyPosition = + serde_json::from_str(r#"{"dx":777,"dy":12,"width":60,"count":42}"#).unwrap(); + + assert_eq!(position.dx, 777.0); + assert_eq!(position.count, 42); + assert_eq!(position.height, 60.0); + assert_eq!(position.note_color, NoteColor::Solid("#FFFFFF".to_string())); + assert_eq!(position.note_opacity, 90); + assert_eq!(position.shadow, None); + assert_eq!(position.active_shadow, None); +} + +#[test] +fn key_position_visual_effects_round_trip_without_rewriting_missing_defaults() { + let fixture = serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "shadow": { + "enabled": true, + "color": "rgba(10, 20, 30, 0.45)", + "offsetX": -2.0, + "offsetY": 7.0, + "blur": 18.0 + }, + "activeShadow": { + "enabled": false, + "color": "rgba(0, 0, 0, 0.32)", + "offsetX": 0.0, + "offsetY": 3.0, + "blur": 8.0 + } + }); + + let position: KeyPosition = serde_json::from_value(fixture.clone()).unwrap(); + let serialized = serde_json::to_value(position).unwrap(); + + assert_eq!(serialized.get("shadow"), fixture.get("shadow")); + assert_eq!(serialized.get("activeShadow"), fixture.get("activeShadow")); +} + +#[test] +fn gradient_spec_tolerates_legacy_shape_and_serializes_canonically() { + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "type": "linear", + "stops": [ + { "color": "c9", "pos": 1.4 }, + { "color": "c8", "pos": 0.8 }, + { "color": "c7", "pos": 0.7 }, + { "color": "c6", "pos": 0.6 }, + { "color": "c5", "pos": 0.5 }, + { "color": "c4", "pos": 0.4 }, + { "color": "c3", "pos": 0.3 }, + { "color": "c2", "pos": 0.2 }, + { "color": "c1", "pos": 0.1 }, + { "color": "c0", "pos": -0.2 } + ] + })) + .unwrap(); + + assert_eq!(gradient.angle, 90.0); + assert_eq!(gradient.stops.len(), 8); + assert_eq!(gradient.stops.first().unwrap().color, "c0"); + assert_eq!(gradient.stops.first().unwrap().pos, 0.0); + assert_eq!(gradient.stops.last().unwrap().color, "c7"); + + let canonical = serde_json::to_value(&gradient).unwrap(); + assert_eq!(canonical["angle"], 90.0); + assert_eq!(canonical["stops"].as_array().unwrap().len(), 8); + assert!(canonical.get("type").is_none()); + + let restored: GradientSpec = serde_json::from_value(canonical.clone()).unwrap(); + assert_eq!(serde_json::to_value(restored).unwrap(), canonical); +} + +#[test] +fn gradient_spec_rejects_fewer_than_two_stops() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": 90, + "stops": [{ "color": "#FFFFFF", "pos": 0 }] + })) + .unwrap_err(); + + assert!(error.to_string().contains("at least two stops")); +} + +#[test] +fn gradient_spec_rejects_null_angle_but_preserves_stop_alpha_strings() { + let error = serde_json::from_value::(serde_json::json!({ + "angle": null, + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap_err(); + assert!(error.to_string().contains("invalid gradient angle")); + + let gradient: GradientSpec = serde_json::from_value(serde_json::json!({ + "stops": [ + { "color": "rgba(1,2,3,0)", "pos": 0 }, + { "color": "rgba(1,2,3,0.5)", "pos": 0.5 }, + { "color": "rgba(1,2,3,1)", "pos": 1 } + ] + })) + .unwrap(); + assert_eq!( + gradient + .stops + .iter() + .map(|stop| stop.color.as_str()) + .collect::>(), + ["rgba(1,2,3,0)", "rgba(1,2,3,0.5)", "rgba(1,2,3,1)"] + ); +} + +#[test] +fn note_border_stop_color_parser_matches_shared_fixture() { + let fixture: NoteBorderStopColorFixture = + serde_json::from_str(NOTE_BORDER_STOP_COLOR_FIXTURE).unwrap(); + + for case in fixture.valid { + assert_eq!( + note_border_representative_hex(&case.input), + Some(case.representative), + "valid fixture mismatch for {:?}", + case.input + ); + } + for input in fixture.invalid { + assert_eq!( + note_border_representative_hex(&input), + None, + "invalid fixture mismatch for {input:?}" + ); + } +} + +#[test] +fn new_gradient_fields_round_trip_and_missing_fields_remain_none() { + let legacy: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0 + })) + .unwrap(); + assert!(legacy.note_gradient.is_none()); + assert!(legacy.note_glow_gradient.is_none()); + assert!(legacy.note_border_gradient.is_none()); + assert!(legacy.font_gradient.is_none()); + assert!(legacy.active_font_gradient.is_none()); + + let value = serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteGradient": { + "angle": 15, + "stops": [ + { "color": "#1238", "pos": 0 }, + { "color": "rgba(4,5,6,.5)", "pos": 1 } + ] + }, + "noteGlowGradient": { + "angle": 25, + "stops": [ + { "color": "rgb(7,8,9)", "pos": 0 }, + { "color": "#ABC0", "pos": 1 } + ] + }, + "noteBorderColor": "#112233", + "noteBorderGradient": { + "angle": 45, + "stops": [ + { "color": "rgba(17,34,51,.5)", "pos": 0 }, + { "color": "#ABC", "pos": 1 } + ] + }, + "fontColor": "#112233", + "fontGradient": { + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }, + "activeFontColor": "#778899", + "activeFontGradient": { + "angle": 180, + "stops": [ + { "color": "#778899", "pos": 0 }, + { "color": "#AABBCC", "pos": 1 } + ] + } + }); + let position: KeyPosition = serde_json::from_value(value).unwrap(); + let serialized = serde_json::to_value(&position).unwrap(); + assert!(serialized.get("noteGradient").is_some()); + assert!(serialized.get("noteGlowGradient").is_some()); + assert!(serialized.get("noteBorderGradient").is_some()); + assert!(serialized.get("fontGradient").is_some()); + assert!(serialized.get("activeFontGradient").is_some()); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + position + ); +} + +#[test] +fn removed_text_outline_fields_scrub_every_collection_in_place() { + let position = serde_json::json!({ + "id": "keep-entry", + "fontStrokeColor": "#111111", + "activeFontStrokeColor": "#222222", + "counter": { + "stroke": { "idle": "#333333", "active": "#444444" }, + "strokeIdleGradient": { "stops": [] }, + "strokeActiveGradient": { "stops": [] }, + "fill": { "idle": "keep-idle", "active": "keep-active" } + } + }); + let mut value = serde_json::json!({ + "keyPositions": { "4key": [position.clone()] }, + "statPositions": { "4key": [position.clone()] }, + "graphPositions": { "4key": [position.clone()] }, + "knobPositions": { "4key": [position] } + }); + + assert!(scrub_removed_text_outline_fields(&mut value)); + for collection in POSITION_COLLECTION_FIELDS { + let entries = value[collection]["4key"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + let position = entries[0].as_object().unwrap(); + assert_eq!(position["id"], "keep-entry"); + assert!(!position.contains_key("fontStrokeColor")); + assert!(!position.contains_key("activeFontStrokeColor")); + let counter = position["counter"].as_object().unwrap(); + assert!(!counter.contains_key("stroke")); + assert!(!counter.contains_key("strokeIdleGradient")); + assert!(!counter.contains_key("strokeActiveGradient")); + assert_eq!(counter["fill"]["idle"], "keep-idle"); + } + assert!(!scrub_removed_text_outline_fields(&mut value)); +} + +#[test] +fn note_gradients_atomically_canonicalize_color_and_alpha_shadows() { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteColor": "stale-body", + "noteOpacity": 75, + "noteOpacityTop": 1, + "noteOpacityBottom": 2, + "noteGradient": { + "angle": 15, + "stops": [ + { "color": "#1238", "pos": 0 }, + { "color": "rgba(4,5,6,.5)", "pos": 1 } + ] + }, + "noteGlowColor": "stale-glow", + "noteGlowOpacity": 60, + "noteGlowOpacityTop": 3, + "noteGlowOpacityBottom": 4, + "noteGlowGradient": { + "angle": 25, + "stops": [ + { "color": "rgb(7,8,9)", "pos": 0 }, + { "color": "#ABC0", "pos": 1 } + ] + } + })) + .unwrap(); + + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!( + position.note_color, + NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#040506".to_string(), + } + ); + assert_eq!(position.note_opacity_top, Some(40)); + assert_eq!(position.note_opacity_bottom, Some(38)); + assert_eq!( + position.note_glow_color, + Some(NoteColor::Gradient { + top: "#070809".to_string(), + bottom: "#AABBCC".to_string(), + }) + ); + assert_eq!(position.note_glow_opacity_top, Some(60)); + assert_eq!(position.note_glow_opacity_bottom, Some(0)); + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); +} + +#[test] +fn synced_note_glow_canonicalization_refreshes_stale_mirror_without_pair_repair() { + let mut position = KeyPosition { + note_glow_sync_paint: true, + note_color: NoteColor::Gradient { + top: "#112233".to_string(), + bottom: "#445566".to_string(), + }, + note_gradient: serde_json::from_value(serde_json::json!({ + "angle": 180, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "rgba(68, 85, 102, 0.5)", "pos": 1 } + ] + })) + .unwrap(), + note_opacity: 80, + note_opacity_top: Some(80), + note_opacity_bottom: Some(40), + note_glow_color: Some(NoteColor::Solid("stale".to_string())), + note_glow_opacity: 70, + note_glow_opacity_top: Some(70), + note_glow_opacity_bottom: Some(70), + ..KeyPosition::default() + }; + + assert_eq!(position.canonicalize_gradient_pairs(), (true, false)); + assert_eq!(position.note_glow_gradient, position.note_gradient); + assert_eq!(position.note_glow_opacity, position.note_opacity); + assert_eq!( + position.note_glow_color.as_ref(), + Some(&position.note_color) + ); + assert_eq!(position.note_glow_opacity_top, position.note_opacity_top); + assert_eq!( + position.note_glow_opacity_bottom, + position.note_opacity_bottom + ); +} + +#[test] +fn synced_note_glow_canonicalization_is_idempotent_when_mirror_matches() { + let mut position = KeyPosition { + note_glow_sync_paint: true, + note_color: NoteColor::Solid("#112233".to_string()), + note_opacity: 80, + note_opacity_top: Some(70), + note_opacity_bottom: Some(60), + ..KeyPosition::default() + }; + assert!(position.mirror_note_body_to_glow()); + + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); +} + +#[test] +fn font_gradient_pairs_are_optional_and_canonicalize_representative_colors() { + let mut position = KeyPosition { + font_color: Some("stale-idle".to_string()), + font_gradient: serde_json::from_value(serde_json::json!({ + "angle": 405, + "stops": [ + { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + })) + .unwrap(), + active_font_color: None, + active_font_gradient: serde_json::from_value(serde_json::json!({ + "angle": -45, + "stops": [ + { "color": "#778899", "pos": 0 }, + { "color": "rgb(170, 187, 204)", "pos": 1 } + ] + })) + .unwrap(), + ..KeyPosition::default() + }; + + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!(position.font_color.as_deref(), Some("rgba(17, 34, 51, .5)")); + assert_eq!(position.active_font_color.as_deref(), Some("#778899")); + assert_eq!(position.font_gradient.as_ref().unwrap().angle, 45.0); + assert_eq!(position.active_font_gradient.as_ref().unwrap().angle, 315.0); + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); + + let serialized_default = serde_json::to_value(KeyPosition::default()).unwrap(); + assert!(serialized_default.get("fontGradient").is_none()); + assert!(serialized_default.get("activeFontGradient").is_none()); +} + +#[test] +fn note_gradient_rejects_invalid_original_stop_before_truncation() { + let mut position = KeyPosition { + note_color: NoteColor::Solid("preserved".to_string()), + note_gradient: serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#000000", "pos": 0.0 }, + { "color": "#111111", "pos": 0.1 }, + { "color": "#222222", "pos": 0.2 }, + { "color": "#333333", "pos": 0.3 }, + { "color": "#444444", "pos": 0.4 }, + { "color": "#555555", "pos": 0.5 }, + { "color": "#666666", "pos": 0.6 }, + { "color": "#777777", "pos": 0.7 }, + { "color": "invalid-discarded-stop", "pos": 1.0 } + ] + })) + .unwrap(), + ..KeyPosition::default() + }; + + assert_eq!(position.note_gradient.as_ref().unwrap().stops.len(), 8); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert!(position.note_gradient.is_none()); + assert_eq!( + position.note_color, + NoteColor::Solid("preserved".to_string()) + ); +} + +#[test] +fn note_border_gradient_canonicalization_uses_hex_and_drops_invalid_stop() { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteBorderColor": "#000000", + "noteBorderGradient": { + "angle": 90, + "stops": [ + { "color": "rgba(17, 34, 51, .5)", "pos": 0 }, + { "color": "#ABC", "pos": 1 } + ] + } + })) + .unwrap(); + + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!(position.note_border_color.as_deref(), Some("#112233")); + assert!(position.note_border_gradient.is_some()); + assert_eq!(position.canonicalize_gradient_pairs(), (false, false)); + + position.note_border_color = Some("#445566".to_string()); + position.note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "transparent", "pos": 1 } + ] + })) + .unwrap(); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert_eq!(position.note_border_color.as_deref(), Some("#445566")); + assert!(position.note_border_gradient.is_none()); + + position.note_border_gradient = serde_json::from_value(serde_json::json!({ + "angle": 90, + "stops": [ + { "color": "#000000", "pos": 0.0 }, + { "color": "#111111", "pos": 0.1 }, + { "color": "#222222", "pos": 0.2 }, + { "color": "#333333", "pos": 0.3 }, + { "color": "#444444", "pos": 0.4 }, + { "color": "#555555", "pos": 0.5 }, + { "color": "#666666", "pos": 0.6 }, + { "color": "#777777", "pos": 0.7 }, + { "color": "invalid-discarded-stop", "pos": 1.0 } + ] + })) + .unwrap(); + assert_eq!( + position.note_border_gradient.as_ref().unwrap().stops.len(), + 8 + ); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + assert!(position.note_border_gradient.is_none()); +} + +#[test] +fn counter_gradient_escape_differs_from_every_legacy_snapshot_literal() { + let legacy_literals = [ + "#FFFFFF", + "#000000", + "rgba(121, 121, 121, 0.9)", + "transparent", + ]; + let visual_pairs = [ + ("#FFFFFF", "rgba(255,255,255,1)"), + ("#000000", "rgba(0,0,0,1)"), + ("rgba(121, 121, 121, 0.9)", "rgba(121,121,121,0.9)"), + ]; + + for (input, expected) in visual_pairs { + let escaped = compact_canonical_rgba(input); + assert_eq!(escaped, expected); + assert!(legacy_literals.iter().all(|literal| escaped != *literal)); + } +} + +#[test] +fn removed_custom_stroke_evidence_blocks_legacy_default_migration_permanently() { + let legacy_counter = |stroke: serde_json::Value, gradient: bool| { + let mut counter = serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { "idle": "#FFFFFF", "active": "#000000" }, + "stroke": stroke, + "gap": 6, + "fontSize": 16, + "fontWeight": 400, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }); + if gradient { + counter["strokeIdleGradient"] = serde_json::json!({ "stops": [] }); + } + counter + }; + + for counter in [ + legacy_counter( + serde_json::json!({ "idle": "#123456", "active": "#FFFFFF" }), + false, + ), + legacy_counter( + serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), + true, + ), + ] { + let mut raw = serde_json::json!({ "keyPositions": { "4key": [{ "counter": counter }] } }); + assert!(scrub_removed_text_outline_fields(&mut raw)); + let mut parsed: KeyCounterSettings = + serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); + assert_eq!(parsed.fill.idle, "rgba(255,255,255,1)"); + assert!(!parsed.migrate_legacy_defaults()); + assert_eq!(parsed.align, KeyCounterAlign::Top); + } + + let mut raw = serde_json::json!({ + "keyPositions": { "4key": [{ + "counter": legacy_counter( + serde_json::json!({ "idle": "#000000", "active": "#FFFFFF" }), + false, + ) + }] } + }); + assert!(scrub_removed_text_outline_fields(&mut raw)); + let mut parsed: KeyCounterSettings = + serde_json::from_value(raw["keyPositions"]["4key"][0]["counter"].clone()).unwrap(); + assert!(parsed.migrate_legacy_defaults()); + assert_eq!(parsed.align, KeyCounterAlign::Bottom); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeatureKeyPosition { + background_color: Option, + counter: PreFeatureCounterSettings, +} + +#[derive(serde::Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreUnifiedNotePosition { + dx: f64, + dy: f64, + width: f64, + count: u32, + note_color: NoteColor, + note_opacity: u32, + note_opacity_top: Option, + note_opacity_bottom: Option, + note_glow_color: Option, + note_glow_opacity: u32, + note_glow_opacity_top: Option, + note_glow_opacity_bottom: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreFeatureCounterSettings { + fill: KeyCounterColor, + placement: KeyCounterPlacement, + align: KeyCounterAlign, + align_mode: KeyCounterAlignMode, + gap: u32, + font_size: u32, + font_weight: u32, + font_family: Option, + font_italic: bool, + font_underline: bool, + font_strikethrough: bool, +} + +impl PreFeatureCounterSettings { + fn matches_legacy_migration_snapshot(&self) -> bool { + let shared = matches!(self.placement, KeyCounterPlacement::Inside) + && matches!(self.align, KeyCounterAlign::Top) + && matches!(self.align_mode, KeyCounterAlignMode::Center) + && self.gap == 6 + && self.font_size == 16 + && self.font_family.is_none() + && !self.font_italic + && !self.font_underline + && !self.font_strikethrough; + let oldest = + self.fill.idle == "#FFFFFF" && self.fill.active == "#000000" && self.font_weight == 400; + let previous = self.fill.idle == "rgba(121, 121, 121, 0.9)" + && self.fill.active == "#FFFFFF" + && self.font_weight == 700; + shared && (oldest || previous) + } +} + +#[test] +fn pre_feature_shadow_downgrade_ignores_gradients_without_triggering_migration() { + for (legacy_fill, first_stop, expected_escape) in [ + ("#FFFFFF", "#FFFFFF", "rgba(255,255,255,1)"), + ( + "rgba(121, 121, 121, 0.9)", + "rgba(121, 121, 121, 0.9)", + "rgba(121,121,121,0.9)", + ), + ] { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "backgroundColor": "#102030", + "backgroundGradient": { + "angle": 90, + "stops": [ + { "color": "#102030", "pos": 0 }, + { "color": "#405060", "pos": 1 } + ] + }, + "counter": { + "enabled": true, + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": legacy_fill, + "active": if legacy_fill == "#FFFFFF" { "#000000" } else { "#FFFFFF" } + }, + "fillIdleGradient": { + "angle": 90, + "stops": [ + { "color": first_stop, "pos": 0 }, + { "color": "#654321", "pos": 1 } + ] + }, + "gap": 6, + "fontSize": 16, + "fontWeight": if legacy_fill == "#FFFFFF" { 400 } else { 700 }, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + } + })) + .unwrap(); + + let (_, pair_repaired) = position.canonicalize_gradient_pairs(); + assert!(pair_repaired); + assert_eq!(position.counter.fill.idle, expected_escape); + assert!(!position.counter.migrate_legacy_defaults()); + + let serialized = serde_json::to_value(&position).unwrap(); + let shadow: PreFeatureKeyPosition = serde_json::from_value(serialized).unwrap(); + assert_eq!(shadow.background_color.as_deref(), Some("#102030")); + assert_eq!(shadow.counter.fill.idle, expected_escape); + assert!(!shadow.counter.matches_legacy_migration_snapshot()); + } +} + +#[test] +fn pre_unification_round_trip_drops_siblings_and_preserves_note_shadows() { + let mut position: KeyPosition = serde_json::from_value(serde_json::json!({ + "dx": 0, + "dy": 0, + "width": 60, + "count": 0, + "noteOpacity": 80, + "noteGradient": { + "angle": 45, + "stops": [ + { "color": "rgba(17,34,51,.5)", "pos": 0 }, + { "color": "#44556640", "pos": 1 } + ] + }, + "noteGlowOpacity": 60, + "noteGlowGradient": { + "angle": 135, + "stops": [ + { "color": "#77889980", "pos": 0 }, + { "color": "rgb(170,187,204)", "pos": 1 } + ] + } + })) + .unwrap(); + assert_eq!(position.canonicalize_gradient_pairs(), (true, true)); + let expected_note_color = position.note_color.clone(); + let expected_note_top = position.note_opacity_top; + let expected_note_bottom = position.note_opacity_bottom; + let expected_glow_color = position.note_glow_color.clone(); + let expected_glow_top = position.note_glow_opacity_top; + let expected_glow_bottom = position.note_glow_opacity_bottom; + + let new_wire = serde_json::to_value(position).unwrap(); + let old: PreUnifiedNotePosition = serde_json::from_value(new_wire).unwrap(); + let old_wire = serde_json::to_value(old).unwrap(); + assert!(old_wire.get("noteGradient").is_none()); + assert!(old_wire.get("noteGlowGradient").is_none()); + + let restored: KeyPosition = serde_json::from_value(old_wire).unwrap(); + assert!(restored.note_gradient.is_none()); + assert!(restored.note_glow_gradient.is_none()); + assert_eq!(restored.note_color, expected_note_color); + assert_eq!(restored.note_opacity_top, expected_note_top); + assert_eq!(restored.note_opacity_bottom, expected_note_bottom); + assert_eq!(restored.note_glow_color, expected_glow_color); + assert_eq!(restored.note_glow_opacity_top, expected_glow_top); + assert_eq!(restored.note_glow_opacity_bottom, expected_glow_bottom); +} + +#[test] +fn counter_migration_without_gradients_preserves_both_legacy_upgrade_branches() { + for snapshot in [ + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { "idle": "#FFFFFF", "active": "#000000" }, + "stroke": { "idle": "#000000", "active": "#FFFFFF" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 400, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + serde_json::json!({ + "placement": "inside", + "align": "top", + "alignMode": "center", + "fill": { + "idle": "rgba(121, 121, 121, 0.9)", + "active": "#FFFFFF" + }, + "stroke": { "idle": "transparent", "active": "transparent" }, + "gap": 6, + "fontSize": 16, + "fontWeight": 700, + "fontFamily": null, + "fontItalic": false, + "fontUnderline": false, + "fontStrikethrough": false + }), + ] { + let mut counter: KeyCounterSettings = serde_json::from_value(snapshot).unwrap(); + + assert!(counter.migrate_legacy_defaults()); + assert_eq!(counter, KeyCounterSettings::default()); + } +} diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 5567833d0..e1b45f16a 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -1,74 +1,51 @@ -use std::io::Cursor; -use std::net::{IpAddr, SocketAddr}; -use std::path::PathBuf; -use std::pin::Pin; +use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; -use std::task::{Context, Poll}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use parking_lot::RwLock; use serde_json::Value; -use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; -use tauri::webview::InvokeRequest; use tauri::{AppHandle, Manager, Wry}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +#[cfg(test)] +use tokio::io::AsyncReadExt; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, oneshot}; +#[cfg(test)] +use tokio_tungstenite::tungstenite::http::{header, StatusCode}; use tokio_tungstenite::{ accept_hdr_async, tungstenite::{ - handshake::server::{ErrorResponse, Request as WsRequest, Response as WsResponse}, - http::{header, StatusCode}, + handshake::server::{Request as WsRequest, Response as WsResponse}, Message, }, WebSocketStream, }; use crate::models::obs::{ - make_envelope, HelloAckPayload, InvokeRequestPayload, ObsBroadcast, ObsEnvelope, ObsStatus, - OBS_PROTOCOL_VERSION, + make_envelope, ObsBroadcast, ObsEnvelope, ObsStatus, OBS_PROTOCOL_VERSION, }; -const MAX_HTTP_HEADER_SIZE: usize = 16 * 1024; - -/// OBS 클라이언트에서 실행 가능한 커맨드 목록 -const ALLOWED_WS_COMMANDS: &[&str] = &[ - "app_bootstrap", - "settings_get", - "editor_get", - "layer_groups_get", - "note_tab_get_all", - "note_tab_get", - "css_get", - "css_get_use", - "css_tab_get_all", - "css_tab_get", - "js_get", - "js_get_use", - "get_cursor_settings", - "keys_get", - "keys_get_counters", - "positions_get", - "stat_positions_get", - "graph_positions_get", - "knob_positions_get", - "custom_tabs_list", - "sound_list", - "sound_load_original", - "counter_animation_list", - "plugin_bridge_send", - "plugin_bridge_send_to", - "raw_input_subscribe", - "raw_input_unsubscribe", - "plugin_storage_get", - "plugin_storage_set", - "plugin_storage_remove", - "plugin_storage_keys", - "plugin_storage_has_data", - // 파괴적 bulk 삭제는 plugin_storage_clear와 동일하게 원격 차단 -]; +mod media; +mod rpc; +mod transport; +mod websocket; + +#[cfg(test)] +use media::{guess_mime, percent_decode}; + +use rpc::is_allowed_command; +#[cfg(test)] +use rpc::{build_allowed_list, ALLOWED_WS_COMMANDS}; +use transport::{ + bind_address, has_allowed_http_host, http_header_end, is_local_machine_ip, + is_websocket_upgrade_request, read_http_request_headers, validate_websocket_request, + websocket_forbidden_response, write_empty_http_response, PrefixedStream, +}; +#[cfg(test)] +use transport::{http_header_values, is_allowed_host_header}; +use websocket::WebSocketSessionProtocol; // OBS 브라우저 소스는 overlay 창을 대신한다 - main만을 향한 브릿지 메시지는 전달하지 않는다 fn is_forwarded_to_obs(event: &str, data: &Value) -> bool { @@ -114,238 +91,6 @@ const FORWARDED_EVENTS: &[&str] = &[ "plugin-bridge:message", ]; -fn is_allowed_command(command: &str) -> bool { - ALLOWED_WS_COMMANDS.contains(&command) -} - -fn build_allowed_list() -> Vec { - ALLOWED_WS_COMMANDS - .iter() - .map(|command| command.to_string()) - .collect() -} - -struct PrefixedStream { - prefix: Cursor>, - stream: TcpStream, -} - -impl PrefixedStream { - fn new(prefix: Vec, stream: TcpStream) -> Self { - Self { - prefix: Cursor::new(prefix), - stream, - } - } -} - -impl AsyncRead for PrefixedStream { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buffer: &mut ReadBuf<'_>, - ) -> Poll> { - let this = self.get_mut(); - let position = this.prefix.position() as usize; - let prefix = this.prefix.get_ref(); - if position < prefix.len() { - let count = (prefix.len() - position).min(buffer.remaining()); - buffer.put_slice(&prefix[position..position + count]); - this.prefix.set_position((position + count) as u64); - return Poll::Ready(Ok(())); - } - Pin::new(&mut this.stream).poll_read(cx, buffer) - } -} - -impl AsyncWrite for PrefixedStream { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buffer: &[u8], - ) -> Poll> { - Pin::new(&mut self.get_mut().stream).poll_write(cx, buffer) - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.get_mut().stream).poll_flush(cx) - } - - fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) - } -} - -async fn read_http_request_headers(stream: &mut TcpStream) -> std::io::Result> { - let mut request = Vec::with_capacity(1024); - let mut chunk = [0u8; 1024]; - - loop { - if request.len() == MAX_HTTP_HEADER_SIZE { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "HTTP request headers too large", - )); - } - - let remaining = MAX_HTTP_HEADER_SIZE - request.len(); - let chunk_len = remaining.min(chunk.len()); - let read = stream.read(&mut chunk[..chunk_len]).await?; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "incomplete HTTP request headers", - )); - } - request.extend_from_slice(&chunk[..read]); - - if http_header_end(&request).is_some() { - return Ok(request); - } - } -} - -fn http_header_end(request: &[u8]) -> Option { - request - .windows(4) - .position(|window| window == b"\r\n\r\n") - .map(|position| position + 4) -} - -fn http_header_values<'a>(request: &'a str, name: &str) -> Vec<&'a str> { - request - .lines() - .skip(1) - .take_while(|line| !line.trim_end_matches('\r').is_empty()) - .filter_map(|line| line.trim_end_matches('\r').split_once(':')) - .filter_map(|(header_name, value)| { - header_name - .eq_ignore_ascii_case(name) - .then_some(value.trim()) - }) - .collect() -} - -fn is_websocket_upgrade_request(request: &str) -> bool { - http_header_values(request, "upgrade").iter().any(|value| { - value - .split(',') - .any(|token| token.trim().eq_ignore_ascii_case("websocket")) - }) -} - -fn is_allowed_host_name(host: &str) -> bool { - if host.eq_ignore_ascii_case("localhost") { - return true; - } - - let ip_literal = host - .strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .unwrap_or(host); - ip_literal.parse::().is_ok() -} - -fn is_allowed_host_header(value: &str) -> bool { - let value = value.trim(); - if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) { - return false; - } - if is_allowed_host_name(value) { - return true; - } - - if let Some(closing_bracket) = value.find(']') { - let (host, suffix) = value.split_at(closing_bracket + 1); - return is_allowed_host_name(host) - && suffix - .strip_prefix(':') - .is_some_and(|port| port.parse::().is_ok()); - } - - value - .rsplit_once(':') - .is_some_and(|(host, port)| is_allowed_host_name(host) && port.parse::().is_ok()) -} - -/// 이 머신 자신의 IP인지 판정 (loopback 또는 로컬 인터페이스에 실제 할당된 IP) -fn is_local_machine_ip(ip: &IpAddr) -> bool { - if ip.is_loopback() { - return true; - } - local_ip_address::list_afinet_netifas() - .map(|interfaces| { - interfaces - .iter() - .any(|(_, interface_ip)| interface_ip == ip) - }) - .unwrap_or(false) -} - -/// Origin은 브라우저가 강제하는 헤더 — 이 머신이 서빙한 페이지만 신뢰 -/// (localhost/loopback 또는 로컬 인터페이스에 실제 할당된 IP만 허용, OWASP 권고 allowlist) -fn is_local_machine_host(host: &str) -> bool { - if host.eq_ignore_ascii_case("localhost") { - return true; - } - - let ip_literal = host - .strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .unwrap_or(host); - let Ok(ip) = ip_literal.parse::() else { - return false; - }; - is_local_machine_ip(&ip) -} - -fn is_allowed_origin(value: &str) -> bool { - tauri::Url::parse(value) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - .is_some_and(|host| is_local_machine_host(&host)) -} - -fn has_allowed_http_host(request: &str) -> bool { - let hosts = http_header_values(request, "host"); - hosts.len() == 1 && is_allowed_host_header(hosts[0]) -} - -fn validate_websocket_request(request: &WsRequest) -> Result<(), &'static str> { - let hosts: Vec<_> = request.headers().get_all(header::HOST).iter().collect(); - if hosts.len() != 1 || !hosts[0].to_str().ok().is_some_and(is_allowed_host_header) { - return Err("Invalid Host header"); - } - - let origins: Vec<_> = request.headers().get_all(header::ORIGIN).iter().collect(); - if origins.len() > 1 - || origins - .first() - .is_some_and(|origin| !origin.to_str().ok().is_some_and(is_allowed_origin)) - { - return Err("Invalid Origin header"); - } - - Ok(()) -} - -fn websocket_forbidden_response(reason: &str) -> ErrorResponse { - let mut response = ErrorResponse::new(Some(reason.to_string())); - *response.status_mut() = StatusCode::FORBIDDEN; - response -} - -// OBS 모드는 같은 네트워크의 다른 PC 접속이 제품 계약 — 항상 전체 인터페이스에 바인딩 -// (WS/미디어는 토큰 인증, 커맨드는 allowlist로 보호) -fn bind_address(port: u16) -> SocketAddr { - SocketAddr::from(([0, 0, 0, 0], port)) -} - -async fn write_empty_http_response(stream: &mut TcpStream, status: &str) { - let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); - let _ = stream.write_all(response.as_bytes()).await; -} - /// 임베딩 에셋 조회 함수 타입 (path → Option<(bytes, mime_type)>) pub type AssetFetcher = Arc Option<(Vec, String)> + Send + Sync>; @@ -780,12 +525,7 @@ impl ObsBridgeService { let mut broadcast_rx = self.broadcast_tx.subscribe(); // 클라이언트별 시퀀스 카운터 - let mut client_seq: u64 = 0; - let mut next_seq = || { - let s = client_seq; - client_seq += 1; - s - }; + let mut protocol = WebSocketSessionProtocol::new(); // hello 핸드셰이크 대기 (5초 타임아웃) let hello_result = tokio::time::timeout(Duration::from_secs(5), async { @@ -865,13 +605,7 @@ impl ObsBridgeService { self.client_count.fetch_sub(1, Ordering::Relaxed); return; } - let ack_payload = serde_json::to_value(HelloAckPayload { - server_version: self.server_version.clone(), - obs_mode: true, - allowed_list: build_allowed_list(), - }) - .unwrap_or_default(); - let ack_msg = make_envelope("hello_ack", next_seq(), ack_payload); + let ack_msg = protocol.hello_ack(self.server_version.clone()); if ws_tx .send(Message::Text(ack_msg.to_string())) .await @@ -887,7 +621,7 @@ impl ObsBridgeService { return; } let snapshot = self.cached_snapshot.read().clone(); - let snapshot_msg = make_envelope("snapshot", next_seq(), snapshot); + let snapshot_msg = protocol.snapshot(snapshot); if ws_tx .send(Message::Text(snapshot_msg.to_string())) .await @@ -914,7 +648,7 @@ impl ObsBridgeService { match result { Ok(ObsBroadcast::Shutdown) => break, Ok(broadcast) => { - let msg = broadcast_to_envelope(&broadcast, next_seq()); + let msg = protocol.broadcast(&broadcast); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -922,7 +656,7 @@ impl ObsBridgeService { Err(broadcast::error::RecvError::Lagged(n)) => { log::warn!("[ObsBridge] {addr}: {n}개 메시지 누락, 스냅샷 재전송"); let snapshot = self.cached_snapshot.read().clone(); - let msg = make_envelope("snapshot", next_seq(), snapshot); + let msg = protocol.snapshot(snapshot); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -941,14 +675,14 @@ impl ObsBridgeService { if let Ok(envelope) = serde_json::from_str::(&text) { match envelope.msg_type.as_str() { "ping" => { - let pong = make_envelope("pong", next_seq(), Value::Null); + let pong = protocol.pong(); if ws_tx.send(Message::Text(pong.to_string())).await.is_err() { break; } } "resync_request" => { let snapshot = self.cached_snapshot.read().clone(); - let msg = make_envelope("snapshot", next_seq(), snapshot); + let msg = protocol.snapshot(snapshot); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -976,11 +710,7 @@ impl ObsBridgeService { if !self.is_current_session_token(&expected_token) { break; } - let payload = match result { - Ok(data) => serde_json::json!({ "requestId": request_id, "result": data }), - Err(err) => serde_json::json!({ "requestId": request_id, "error": err }), - }; - let msg = make_envelope("invoke_response", next_seq(), payload); + let msg = protocol.invoke_response(request_id, result); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } @@ -990,7 +720,7 @@ impl ObsBridgeService { if !self.is_current_session_token(&expected_token) { break; } - let ping_msg = make_envelope("ping", next_seq(), Value::Null); + let ping_msg = protocol.ping(); if ws_tx.send(Message::Text(ping_msg.to_string())).await.is_err() { break; } @@ -1006,22 +736,18 @@ impl ObsBridgeService { } /// invoke_request 처리: webview.on_message()로 Tauri 커맨드 파이프라인에 주입 - fn handle_invoke_request( - &self, - payload: &Value, - addr: &SocketAddr, - rpc_tx: tokio::sync::mpsc::UnboundedSender<(String, Result)>, - ) { - let req: InvokeRequestPayload = match serde_json::from_value(payload.clone()) { + fn handle_invoke_request(&self, payload: &Value, addr: &SocketAddr, rpc_tx: rpc::RpcSender) { + let req = match rpc::parse_invoke_request(payload) { Ok(r) => r, Err(e) => { log::warn!("[ObsBridge] {addr}: invoke_request 파싱 실패: {e}"); // requestId를 추출 시도하여 에러 응답 전송 (파싱 실패여도 클라이언트 대기 방지) if let Some(request_id) = payload.get("requestId").and_then(|v| v.as_str()) { - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, request_id.to_string(), - Err(serde_json::json!(format!("Invalid invoke_request: {e}"))), - )); + Err(rpc::invalid_invoke_request_error(&e)), + ); } return; } @@ -1030,13 +756,11 @@ impl ObsBridgeService { // allowlist 검사 (클라이언트 검사와 별도인 백엔드 안전망) if !is_allowed_command(&req.command) { log::debug!("[ObsBridge] {addr}: 허용되지 않은 cmd={}", req.command); - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, req.request_id, - Err(serde_json::json!(format!( - "Command not allowed: {}", - req.command - ))), - )); + Err(rpc::command_not_allowed_error(&req.command)), + ); return; } @@ -1045,47 +769,37 @@ impl ObsBridgeService { Some(h) => h, None => { log::warn!("[ObsBridge] {addr}: AppHandle 미설정"); - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, req.request_id, - Err(serde_json::json!("AppHandle not available")), - )); + Err(serde_json::json!(rpc::APP_HANDLE_NOT_AVAILABLE)), + ); return; } }; // OBS 모드에서 오버레이가 destroy된 상태일 수 있으므로 main window로 fallback - let webview_window = match app_handle - .get_webview_window("overlay") - .or_else(|| app_handle.get_webview_window("main")) - { + let webview_window = match rpc::select_overlay_or_main( + || app_handle.get_webview_window("overlay"), + || app_handle.get_webview_window("main"), + ) { Some(w) => w, None => { log::warn!("[ObsBridge] {addr}: webview 없음 (overlay/main 모두)"); - let _ = rpc_tx.send(( + rpc::send_rpc_response( + &rpc_tx, req.request_id, - Err(serde_json::json!("No webview window available")), - )); + Err(serde_json::json!(rpc::NO_WEBVIEW_AVAILABLE)), + ); return; } }; // InvokeRequest 구성 - // 플랫폼별 로컬 URL (Windows: http://tauri.localhost, macOS/Linux: tauri://localhost) - let local_url = if cfg!(windows) || cfg!(target_os = "android") { - tauri::Url::parse("http://tauri.localhost").unwrap() - } else { - tauri::Url::parse("tauri://localhost").unwrap() - }; + let local_url = rpc::local_invoke_url(); let invoke_key = app_handle.invoke_key().to_string(); - let request = InvokeRequest { - cmd: req.command.clone(), - callback: CallbackFn(0), - error: CallbackFn(1), - url: local_url, - body: InvokeBody::Json(req.args), - headers: Default::default(), - invoke_key, - }; + let request = + rpc::build_invoke_request(req.command.clone(), req.args, local_url, invoke_key); let request_id = req.request_id; let cmd = req.command.clone(); @@ -1094,25 +808,8 @@ impl ObsBridgeService { // OwnedInvokeResponder: 응답을 rpc_tx 채널로 전송 let responder: Box> = Box::new(move |_webview, _cmd, response, _callback, _error| { - let result = match response { - InvokeResponse::Ok(body) => { - let value = match body { - InvokeResponseBody::Json(json_str) => { - serde_json::from_str(&json_str).unwrap_or(Value::Null) - } - InvokeResponseBody::Raw(bytes) => { - // Raw bytes → base64 인코딩 - use base64::Engine; - Value::String( - base64::engine::general_purpose::STANDARD.encode(&bytes), - ) - } - }; - Ok(value) - } - InvokeResponse::Err(err) => Err(err.0), - }; - let _ = rpc_tx.send((request_id, result)); + let result = rpc::project_invoke_response(response); + rpc::send_rpc_response(&rpc_tx, request_id, result); }); log::debug!("[ObsBridge] {addr_clone}: invoke cmd={cmd}"); @@ -1121,855 +818,20 @@ impl ObsBridgeService { /// /media/?token=xxx — 사용자 로컬 미디어 파일 서빙 async fn handle_media_request(&self, stream: &mut TcpStream, rest: &str) { - use base64::Engine; - - // 경로와 쿼리 분리: "base64path?token=xxx" - let (encoded, query) = rest.split_once('?').unwrap_or((rest, "")); - - // 토큰 검증 - let expected_token = self.session_token.read().clone(); - if !expected_token.is_empty() { - let client_token = query - .split('&') - .find_map(|pair| pair.strip_prefix("token=")) - .unwrap_or(""); - if client_token != expected_token { - let _ = stream - .write_all( - b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } - } - - // URL 디코딩 (%2F 등) + base64url → 절대 파일 경로 - let decoded_url = percent_decode(encoded); - let file_path = match base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(decoded_url.as_bytes()) - { - Ok(bytes) => match String::from_utf8(bytes) { - Ok(path) => PathBuf::from(path), - Err(_) => { - let _ = stream - .write_all( - b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } + media::handle_media_request( + stream, + rest, + || self.session_token.read().clone(), + || { + self.app_handle + .read() + .clone() + .and_then(|app| app.path().app_data_dir().ok()) }, - Err(_) => { - let _ = stream - .write_all( - b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } - }; - - if !file_path.is_absolute() { - write_empty_http_response(stream, "403 Forbidden").await; - return; - } - - let app_data_dir = self - .app_handle - .read() - .clone() - .and_then(|app| app.path().app_data_dir().ok()); - let Some(app_data_dir) = app_data_dir else { - write_empty_http_response(stream, "403 Forbidden").await; - return; - }; - let canonical_app_data = match tokio::fs::canonicalize(app_data_dir).await { - Ok(path) => path, - Err(error) => { - log::warn!("[ObsBridge] app data 경로 확인 실패: {error}"); - write_empty_http_response(stream, "403 Forbidden").await; - return; - } - }; - let canonical_file_path = match tokio::fs::canonicalize(&file_path).await { - Ok(path) => path, - Err(_) => { - write_empty_http_response(stream, "404 Not Found").await; - return; - } - }; - if !canonical_file_path.starts_with(&canonical_app_data) { - log::warn!( - "[ObsBridge] app data 밖 media 요청 차단: {}", - file_path.display() - ); - write_empty_http_response(stream, "403 Forbidden").await; - return; - } - - // 허용 확장자 화이트리스트 (미디어/폰트 파일만) - let ext = canonical_file_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - if !matches!( - ext.as_str(), - "png" - | "jpg" - | "jpeg" - | "gif" - | "webp" - | "svg" - | "mp4" - | "webm" - | "ogg" - | "woff" - | "woff2" - | "ttf" - | "otf" - ) { - let _ = stream - .write_all( - b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } - - // 파일 읽기 및 서빙 - match tokio::fs::read(&canonical_file_path).await { - Ok(content) => { - let mime = guess_mime(&canonical_file_path.to_string_lossy()); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: max-age=3600\r\nConnection: close\r\n\r\n", - content.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.write_all(&content).await; - } - Err(_) => { - let _ = stream - .write_all( - b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - } - } - } -} - -/// 파일 확장자로 MIME 타입 추정 -fn guess_mime(path: &str) -> &'static str { - match path - .rsplit('.') - .next() - .unwrap_or("") - .to_ascii_lowercase() - .as_str() - { - "html" | "htm" => "text/html; charset=utf-8", - "js" | "mjs" => "application/javascript; charset=utf-8", - "css" => "text/css; charset=utf-8", - "json" => "application/json; charset=utf-8", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "mp4" => "video/mp4", - "webm" => "video/webm", - "ogg" => "video/ogg", - "woff2" => "font/woff2", - "woff" => "font/woff", - "ttf" => "font/ttf", - "otf" => "font/otf", - "wasm" => "application/wasm", - _ => "application/octet-stream", - } -} - -/// 간단한 percent-decoding (%XX → 바이트) -fn percent_decode(input: &str) -> String { - let mut result = Vec::with_capacity(input.len()); - let bytes = input.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - if let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16) { - result.push(byte); - i += 3; - continue; - } - } - result.push(bytes[i]); - i += 1; - } - String::from_utf8_lossy(&result).into_owned() -} - -/// ObsBroadcast → JSON envelope 변환 -fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { - match broadcast { - ObsBroadcast::Snapshot(snapshot) => make_envelope("snapshot", seq, snapshot.clone()), - ObsBroadcast::TauriEvent { event, data } => make_envelope( - "tauri_event", - seq, - serde_json::json!({ "event": event, "data": data }), - ), - ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"), + ) + .await; } } #[cfg(test)] -mod tests { - use super::*; - use tokio_tungstenite::{connect_async, MaybeTlsStream}; - - type TestWebSocket = WebSocketStream>; - - async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnvelope { - tokio::time::timeout(Duration::from_secs(2), async { - loop { - match ws.next().await { - Some(Ok(Message::Text(text))) => { - let envelope = serde_json::from_str::(&text) - .expect("OBS envelope 파싱 실패"); - if envelope.msg_type == expected_type { - return envelope; - } - } - Some(Ok(_)) => {} - Some(Err(error)) => panic!("WS 메시지 수신 실패: {error}"), - None => panic!("WS 연결이 예기치 않게 종료됨"), - } - } - }) - .await - .expect("WS 메시지 수신 타임아웃") - } - - async fn connect_authenticated(port: u16, token: &str) -> TestWebSocket { - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) - .await - .expect("WS 연결 실패"); - let hello = make_envelope( - "hello", - 0, - serde_json::json!({ "token": token, "protocol": OBS_PROTOCOL_VERSION }), - ); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - - receive_envelope(&mut ws, "hello_ack").await; - receive_envelope(&mut ws, "snapshot").await; - ws - } - - async fn assert_no_tauri_event(ws: &mut TestWebSocket) { - let result = tokio::time::timeout(Duration::from_millis(100), async { - loop { - match ws.next().await { - Some(Ok(Message::Text(text))) => { - let envelope = serde_json::from_str::(&text) - .expect("OBS envelope 파싱 실패"); - if envelope.msg_type == "tauri_event" { - return true; - } - } - Some(Ok(_)) => {} - Some(Err(_)) | None => return false, - } - } - }) - .await; - - assert!( - matches!(result, Err(_) | Ok(false)), - "예상하지 않은 tauri_event 중복 수신" - ); - } - - #[test] - fn websocket_allowlist_uses_exact_matching() { - assert_eq!(ALLOWED_WS_COMMANDS.len(), 32); - assert!(is_allowed_command("app_bootstrap")); - assert!(is_allowed_command("editor_get")); - assert!(!is_allowed_command("css_history_get")); - assert!(is_allowed_command("sound_list")); - assert!(is_allowed_command("sound_load_original")); - assert!(!is_allowed_command("plugin_storage_clear")); - assert!(!is_allowed_command("plugin_storage_clear_by_prefix")); - assert!(!is_allowed_command("editor_commit")); - assert!(!is_allowed_command("settings_update")); - assert!(!is_allowed_command("keys_update")); - assert!(!is_allowed_command("keys_update_with_positions")); - assert!(!is_allowed_command("plugin_rpc_send")); - assert!(!is_allowed_command("plugin_rpc_respond")); - assert!(!is_allowed_command("plugin_instances_commit")); - assert!(!is_allowed_command("plugin_instances_reconcile")); - assert!(!is_allowed_command("plugin_authority_reset")); - assert!(!is_allowed_command("app_bootstrap_extra")); - assert!(!is_allowed_command("plugin:window|close")); - assert_eq!(build_allowed_list().len(), ALLOWED_WS_COMMANDS.len()); - } - - #[test] - fn bridge_messages_targeting_main_are_not_forwarded_to_obs() { - let broadcast = serde_json::json!({ "type": "PING", "data": null }); - assert!(is_forwarded_to_obs("plugin-bridge:message", &broadcast)); - let to_overlay = serde_json::json!({ "type": "PING", "target": "overlay" }); - assert!(is_forwarded_to_obs("plugin-bridge:message", &to_overlay)); - let to_main = serde_json::json!({ "type": "PING", "target": "main" }); - assert!(!is_forwarded_to_obs("plugin-bridge:message", &to_main)); - assert!(!is_forwarded_to_obs("app:close-requested", &broadcast)); - } - - #[test] - fn public_overlay_events_are_forwarded_to_obs_clients() { - assert_eq!(FORWARDED_EVENTS.len(), 32); - for event in [ - "customTabs:changed", - "overlay:resized", - "counterAnimation:changed", - "preset:snapshot", - ] { - assert!(FORWARDED_EVENTS.contains(&event), "missing event: {event}"); - } - } - - #[tokio::test] - async fn publish_is_noop_while_server_is_stopped() { - let bridge = ObsBridgeService::new("test"); - let mut receiver = bridge.broadcast_tx.subscribe(); - - bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); - - assert!( - tokio::time::timeout(Duration::from_millis(50), receiver.recv()) - .await - .is_err() - ); - } - - #[tokio::test] - async fn publish_with_no_receivers_keeps_running_server_healthy() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - assert_ne!(port, 0); - assert_eq!(bridge.broadcast_tx.receiver_count(), 0); - bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); - assert!(bridge.is_running()); - - bridge.stop(); - } - - #[tokio::test] - async fn publish_forwards_supported_events_to_authenticated_client() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut ws = connect_authenticated(port, "token").await; - - for (event_name, data) in [ - ("settings:changed", serde_json::json!({ "theme": "dark" })), - ("overlay:lock", serde_json::json!({ "locked": true })), - ("css:content", serde_json::json!({ "content": "body {}" })), - ] { - bridge.publish(event_name, data.clone()); - let event = receive_envelope(&mut ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ "event": event_name, "data": data }) - ); - } - - bridge.stop(); - } - - #[tokio::test] - async fn publish_ignores_events_outside_forwarded_allowlist() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut ws = connect_authenticated(port, "token").await; - - bridge.publish("history:status", serde_json::json!({ "canUndo": true })); - bridge.publish("settings:changed", serde_json::json!({ "marker": true })); - - let event = receive_envelope(&mut ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ - "event": "settings:changed", - "data": { "marker": true } - }) - ); - assert_no_tauri_event(&mut ws).await; - - bridge.stop(); - } - - #[tokio::test] - async fn publish_reaches_multiple_authenticated_clients_once_each() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut first = connect_authenticated(port, "token").await; - let mut second = connect_authenticated(port, "token").await; - - bridge.publish("overlay:lock", serde_json::json!({ "locked": false })); - - for ws in [&mut first, &mut second] { - let event = receive_envelope(ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ - "event": "overlay:lock", - "data": { "locked": false } - }) - ); - assert_no_tauri_event(ws).await; - } - - bridge.stop(); - } - - #[tokio::test] - async fn publish_after_stop_start_has_no_missing_or_duplicate_event() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let first_port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut first = connect_authenticated(first_port, "token").await; - - bridge.publish("settings:changed", serde_json::json!({ "cycle": 1 })); - let first_event = receive_envelope(&mut first, "tauri_event").await; - assert_eq!(first_event.payload["data"]["cycle"], 1); - - bridge.stop(); - drop(first); - - let second_port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 재시작 실패"); - let mut second = connect_authenticated(second_port, "token").await; - - bridge.publish("settings:changed", serde_json::json!({ "cycle": 2 })); - let second_event = receive_envelope(&mut second, "tauri_event").await; - assert_eq!(second_event.payload["data"]["cycle"], 2); - assert_no_tauri_event(&mut second).await; - - bridge.stop(); - } - - #[tokio::test] - async fn lagged_publish_burst_recovers_with_latest_snapshot() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let expected_snapshot = serde_json::json!({ "revision": 300 }); - bridge.update_snapshot(expected_snapshot.clone()); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut ws = connect_authenticated(port, "token").await; - - for revision in 0..300 { - bridge.publish( - "settings:changed", - serde_json::json!({ "revision": revision }), - ); - } - - let snapshot = receive_envelope(&mut ws, "snapshot").await; - assert_eq!(snapshot.payload, expected_snapshot); - - bridge.stop(); - } - - #[test] - fn host_header_allows_only_ip_literals_and_localhost() { - for allowed in [ - "localhost", - "LOCALHOST:34891", - "127.0.0.1", - "127.0.0.1:34891", - "192.168.0.10:80", - "::1", - "[::1]", - "[2001:db8::1]:34891", - ] { - assert!(is_allowed_host_header(allowed), "{allowed}"); - } - - for rejected in [ - "", - "example.com", - "example.com:34891", - "localhost.example.com", - "localhost:invalid", - "[localhost]:34891", - "127.0.0.1:99999", - ] { - assert!(!is_allowed_host_header(rejected), "{rejected}"); - } - } - - #[test] - fn http_host_validation_rejects_missing_or_duplicate_headers() { - assert!(has_allowed_http_host( - "GET / HTTP/1.1\r\nHost: localhost:34891\r\n\r\n" - )); - assert!(!has_allowed_http_host("GET / HTTP/1.1\r\n\r\n")); - assert!(!has_allowed_http_host( - "GET / HTTP/1.1\r\nHost: localhost\r\nHost: example.com\r\n\r\n" - )); - } - - #[test] - fn websocket_origin_allows_absence_or_local_machine_hosts_only() { - let no_origin = WsRequest::builder() - .header(header::HOST, "127.0.0.1:34891") - .body(()) - .unwrap(); - assert!(validate_websocket_request(&no_origin).is_ok()); - - let local_origin = WsRequest::builder() - .header(header::HOST, "localhost:34891") - .header(header::ORIGIN, "http://[::1]:3400") - .body(()) - .unwrap(); - assert!(validate_websocket_request(&local_origin).is_ok()); - - // 이 머신에 할당된 LAN IP는 허용 (LAN 클라이언트가 접속에 쓰는 페이지 origin) - if let Ok(lan_ip) = local_ip_address::local_ip() { - let lan_origin = WsRequest::builder() - .header(header::HOST, format!("{lan_ip}:34891")) - .header(header::ORIGIN, format!("http://{lan_ip}:34891")) - .body(()) - .unwrap(); - assert!(validate_websocket_request(&lan_origin).is_ok()); - } - - let domain_origin = WsRequest::builder() - .header(header::HOST, "127.0.0.1:34891") - .header(header::ORIGIN, "https://example.com") - .body(()) - .unwrap(); - assert_eq!( - validate_websocket_request(&domain_origin), - Err("Invalid Origin header") - ); - - // 이 머신의 인터페이스가 아닌 임의 숫자 IP는 거부 (TEST-NET-3) - let foreign_ip_origin = WsRequest::builder() - .header(header::HOST, "127.0.0.1:34891") - .header(header::ORIGIN, "http://203.0.113.7") - .body(()) - .unwrap(); - assert_eq!( - validate_websocket_request(&foreign_ip_origin), - Err("Invalid Origin header") - ); - } - - async fn http_get(host: &str, port: u16, path: &str) -> String { - let mut stream = TcpStream::connect((host, port)) - .await - .expect("TCP 연결 실패"); - let request = - format!("GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); - stream - .write_all(request.as_bytes()) - .await - .expect("요청 전송 실패"); - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .await - .expect("응답 수신 실패"); - String::from_utf8_lossy(&response).into_owned() - } - - #[test] - fn local_machine_ip_rejects_foreign_addresses() { - assert!(is_local_machine_ip(&"127.0.0.1".parse().unwrap())); - assert!(is_local_machine_ip(&"::1".parse().unwrap())); - // TEST-NET-3 — 로컬 인터페이스에 할당될 일 없는 주소 - assert!(!is_local_machine_ip(&"203.0.113.7".parse().unwrap())); - if let Ok(lan_ip) = local_ip_address::local_ip() { - assert!(is_local_machine_ip(&lan_ip)); - } - } - - // dev 리다이렉트(token 쿼리 포함)는 이 머신 자신의 peer에만 허용 — - // 같은 PC가 자기 LAN IP로 접속하는 URL 복사 경로는 유지되어야 함 - #[tokio::test] - async fn dev_redirect_serves_only_local_machine_peers() { - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.set_dev_url("http://localhost:3400".to_string()); - let port = bridge - .start(0, "dev-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - // loopback peer → 기존 dev 부트스트랩(302 + token) 유지 - let response = http_get("127.0.0.1", port, "/").await; - assert!(response.starts_with("HTTP/1.1 302"), "{response}"); - assert!(response.contains("token=dev-token"), "{response}"); - - // 같은 머신의 LAN IP 경유 — peer도 로컬 인터페이스 IP이므로 유지되어야 함 - if let Ok(lan_ip) = local_ip_address::local_ip() { - let response = http_get(&lan_ip.to_string(), port, "/").await; - assert!( - response.starts_with("HTTP/1.1 302"), - "같은 PC의 LAN IP 접속이 깨지면 안 됨: {response}" - ); - } - - bridge.stop(); - } - - // Game Bar 엔드포인트 제거 검증 — 어떤 경로도 무인증 HTTP 응답으로 토큰을 내주면 안 됨 - #[tokio::test] - async fn gamebar_paths_no_longer_leak_token() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "secret-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - for path in ["/gamebar", "/gamebar/bootstrap.json"] { - let mut stream = TcpStream::connect(("127.0.0.1", port)) - .await - .expect("TCP 연결 실패"); - let request = - format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); - stream - .write_all(request.as_bytes()) - .await - .expect("요청 전송 실패"); - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .await - .expect("응답 수신 실패"); - let response = String::from_utf8_lossy(&response); - assert!( - !response.contains("secret-token"), - "{path} 응답에 토큰 노출: {response}" - ); - } - - bridge.stop(); - } - - // dev 모드에서도 /media는 Vite 리다이렉트가 아니라 브릿지가 직접 처리해야 함 - #[tokio::test] - async fn media_requests_bypass_dev_redirect() { - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.set_dev_url("http://localhost:3400".to_string()); - let port = bridge - .start(0, "media-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - let mut stream = TcpStream::connect(("127.0.0.1", port)) - .await - .expect("TCP 연결 실패"); - stream - .write_all( - b"GET /media/abc?token=wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", - ) - .await - .expect("요청 전송 실패"); - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .await - .expect("응답 수신 실패"); - let response = String::from_utf8_lossy(&response); - assert!( - response.starts_with("HTTP/1.1 403"), - "302 리다이렉트가 아닌 미디어 핸들러 403이어야 함: {response}" - ); - - bridge.stop(); - } - - #[tokio::test] - async fn protocol_mismatch_is_rejected_before_auth() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) - .await - .expect("WS 연결 실패"); - // 올바른 토큰이라도 프로토콜 버전이 다르면 거부되어야 함 - let hello = serde_json::json!({ - "v": 999, - "type": "hello", - "seq": 0, - "payload": { "token": "token", "protocol": 999 }, - }); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - - let error = receive_envelope(&mut ws, "error").await; - assert_eq!( - error.payload.get("code").and_then(Value::as_str), - Some("PROTOCOL_MISMATCH") - ); - - bridge.stop(); - } - - // v1 번들은 KeySlot union 와이어 형식을 소비할 수 없으므로 handshake에서 결정적으로 거부 - #[tokio::test] - async fn legacy_protocol_v1_hello_is_rejected() { - let bridge = Arc::new(ObsBridgeService::new("test")); - let port = bridge - .start(0, "token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) - .await - .expect("WS 연결 실패"); - let hello = serde_json::json!({ - "v": 1, - "type": "hello", - "seq": 0, - "payload": { "token": "token", "protocol": 1 }, - }); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - - let error = receive_envelope(&mut ws, "error").await; - assert_eq!( - error.payload.get("code").and_then(Value::as_str), - Some("PROTOCOL_MISMATCH") - ); - - bridge.stop(); - } - - #[test] - fn binding_listens_on_all_interfaces_for_lan_access() { - assert!(bind_address(34891).ip().is_unspecified()); - assert_eq!(bind_address(34891).port(), 34891); - } - - // 같은 네트워크의 다른 PC 접속은 제품 계약 — LAN IP 경유 인증 접속이 실제로 성립해야 함 - #[tokio::test] - async fn lan_address_accepts_authenticated_clients() { - let Ok(lan_ip) = local_ip_address::local_ip() else { - eprintln!("LAN IP 조회 불가 환경, 테스트 스킵"); - return; - }; - - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.update_snapshot(serde_json::json!({ "source": "lan" })); - let port = bridge - .start(0, "lan-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - - assert_eq!(bridge.status().local_ip, Some(lan_ip.to_string())); - - let (mut ws, _) = connect_async(format!("ws://{lan_ip}:{port}")) - .await - .expect("LAN IP로 WS 연결 실패"); - let hello = make_envelope( - "hello", - 0, - serde_json::json!({ "token": "lan-token", "protocol": OBS_PROTOCOL_VERSION }), - ); - ws.send(Message::Text(hello.to_string())) - .await - .expect("hello 전송 실패"); - receive_envelope(&mut ws, "hello_ack").await; - let snapshot = receive_envelope(&mut ws, "snapshot").await; - assert_eq!(snapshot.payload, serde_json::json!({ "source": "lan" })); - - bridge.stop(); - } - - #[tokio::test] - async fn token_rotation_disconnects_existing_sessions_and_keeps_server_running() { - let bridge = Arc::new(ObsBridgeService::new("test")); - bridge.update_snapshot(serde_json::json!({ "source": "initial" })); - let port = bridge - .start(0, "old-token".to_string()) - .await - .expect("OBS bridge 시작 실패"); - let mut old_ws = connect_authenticated(port, "old-token").await; - - bridge.set_token("new-token".to_string()); - assert!(bridge.is_running()); - assert_eq!(bridge.status().port, port); - - let resync = make_envelope("resync_request", 1, Value::Null); - let _ = old_ws.send(Message::Text(resync.to_string())).await; - bridge.publish( - "settings:changed", - serde_json::json!({ "authenticated": false }), - ); - - tokio::time::timeout(Duration::from_secs(2), async { - loop { - match old_ws.next().await { - Some(Ok(Message::Text(text))) => { - let envelope: ObsEnvelope = - serde_json::from_str(&text).expect("OBS envelope 파싱 실패"); - assert_ne!(envelope.msg_type, "snapshot"); - if envelope.msg_type == "tauri_event" { - assert_ne!( - envelope.payload.get("event").and_then(Value::as_str), - Some("settings:changed") - ); - } - } - Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, - Some(Ok(_)) => {} - } - } - }) - .await - .expect("구 토큰 세션이 종료되지 않음"); - - let mut new_ws = connect_authenticated(port, "new-token").await; - bridge.publish( - "settings:changed", - serde_json::json!({ "authenticated": true }), - ); - let event = receive_envelope(&mut new_ws, "tauri_event").await; - assert_eq!( - event.payload, - serde_json::json!({ - "event": "settings:changed", - "data": { "authenticated": true } - }) - ); - - bridge.stop(); - } -} +mod tests; diff --git a/src-tauri/src/services/obs_bridge/media.rs b/src-tauri/src/services/obs_bridge/media.rs new file mode 100644 index 000000000..2fe3eff46 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/media.rs @@ -0,0 +1,197 @@ +use std::path::PathBuf; + +use base64::Engine; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; + +use super::transport::write_empty_http_response; + +pub(super) async fn handle_media_request( + stream: &mut TcpStream, + rest: &str, + expected_token: ExpectedToken, + app_data_dir: AppDataDir, +) where + ExpectedToken: FnOnce() -> String, + AppDataDir: FnOnce() -> Option, +{ + // 경로와 쿼리 분리: "base64path?token=xxx" + let (encoded, query) = rest.split_once('?').unwrap_or((rest, "")); + + // 토큰 검증 + let expected_token = expected_token(); + if !expected_token.is_empty() { + let client_token = query + .split('&') + .find_map(|pair| pair.strip_prefix("token=")) + .unwrap_or(""); + if client_token != expected_token { + let _ = stream + .write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + } + + // URL 디코딩 (%2F 등) + base64url → 절대 파일 경로 + let decoded_url = percent_decode(encoded); + let file_path = match base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(decoded_url.as_bytes()) + { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(path) => PathBuf::from(path), + Err(_) => { + let _ = stream + .write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + }, + Err(_) => { + let _ = stream + .write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + }; + + if !file_path.is_absolute() { + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + + let app_data_dir = app_data_dir(); + let Some(app_data_dir) = app_data_dir else { + write_empty_http_response(stream, "403 Forbidden").await; + return; + }; + let canonical_app_data = match tokio::fs::canonicalize(app_data_dir).await { + Ok(path) => path, + Err(error) => { + log::warn!("[ObsBridge] app data 경로 확인 실패: {error}"); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + }; + let canonical_file_path = match tokio::fs::canonicalize(&file_path).await { + Ok(path) => path, + Err(_) => { + write_empty_http_response(stream, "404 Not Found").await; + return; + } + }; + if !canonical_file_path.starts_with(&canonical_app_data) { + log::warn!( + "[ObsBridge] app data 밖 media 요청 차단: {}", + file_path.display() + ); + write_empty_http_response(stream, "403 Forbidden").await; + return; + } + + // 허용 확장자 화이트리스트 (미디어/폰트 파일만) + let ext = canonical_file_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !matches!( + ext.as_str(), + "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "svg" + | "mp4" + | "webm" + | "ogg" + | "woff" + | "woff2" + | "ttf" + | "otf" + ) { + let _ = stream + .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await; + return; + } + + // 파일 읽기 및 서빙 + match tokio::fs::read(&canonical_file_path).await { + Ok(content) => { + let mime = guess_mime(&canonical_file_path.to_string_lossy()); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: max-age=3600\r\nConnection: close\r\n\r\n", + content.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&content).await; + } + Err(_) => { + let _ = stream + .write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + } + } +} + +/// 파일 확장자로 MIME 타입 추정 +pub(super) fn guess_mime(path: &str) -> &'static str { + match path + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "html" | "htm" => "text/html; charset=utf-8", + "js" | "mjs" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "mp4" => "video/mp4", + "webm" => "video/webm", + "ogg" => "video/ogg", + "woff2" => "font/woff2", + "woff" => "font/woff", + "ttf" => "font/ttf", + "otf" => "font/otf", + "wasm" => "application/wasm", + _ => "application/octet-stream", + } +} + +/// 간단한 percent-decoding (%XX → 바이트) +pub(super) fn percent_decode(input: &str) -> String { + let mut result = Vec::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16) { + result.push(byte); + i += 3; + continue; + } + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&result).into_owned() +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/services/obs_bridge/media/tests.rs b/src-tauri/src/services/obs_bridge/media/tests.rs new file mode 100644 index 000000000..1feebafe7 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/media/tests.rs @@ -0,0 +1,253 @@ +use std::cell::Cell; +use std::path::{Path, PathBuf}; + +use tokio::io::AsyncReadExt; +use tokio::net::{TcpListener, TcpStream}; + +use super::*; + +async fn tcp_pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("TCP listener 바인딩 실패"); + let address = listener.local_addr().expect("TCP listener 주소 조회 실패"); + let (client, server) = tokio::join!(TcpStream::connect(address), listener.accept()); + ( + client.expect("TCP client 연결 실패"), + server.expect("TCP server accept 실패").0, + ) +} + +fn encode_path(path: &Path) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(path.to_string_lossy().as_bytes()) +} + +async fn media_response( + expected_token: &str, + rest: &str, + app_data_dir: Option, +) -> Vec { + let (mut client, mut server) = tcp_pair().await; + + handle_media_request( + &mut server, + rest, + || expected_token.to_string(), + || app_data_dir, + ) + .await; + drop(server); + + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + response +} + +fn empty_response(status: &str) -> Vec { + format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").into_bytes() +} + +#[tokio::test] +async fn success_response_preserves_mime_cache_header_and_body_order() { + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + let file_path = app_data_dir.join("images/Preview.SvG"); + tokio::fs::create_dir_all(file_path.parent().expect("상위 경로 없음")) + .await + .expect("media 디렉터리 생성 실패"); + let content = b""; + tokio::fs::write(&file_path, content) + .await + .expect("media 파일 생성 실패"); + + let encoded = encode_path(&file_path); + let response = media_response( + "secret", + &format!("{encoded}?other=value&token=secret"), + Some(app_data_dir), + ) + .await; + let mut expected = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nCache-Control: max-age=3600\r\nConnection: close\r\n\r\n", + content.len() + ) + .into_bytes(); + expected.extend_from_slice(content); + + assert_eq!(response, expected); +} + +#[tokio::test] +async fn canonical_extension_allowlist_is_case_insensitive_and_fail_closed() { + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + tokio::fs::create_dir_all(&app_data_dir) + .await + .expect("app data 생성 실패"); + + for extension in [ + "PNG", "jpg", "jpeg", "gif", "webp", "svg", "mp4", "webm", "ogg", "woff", "WOFF2", "ttf", + "otf", + ] { + let file_path = app_data_dir.join(format!("asset.{extension}")); + tokio::fs::write(&file_path, b"x") + .await + .expect("허용 media 파일 생성 실패"); + let response = + media_response("", &encode_path(&file_path), Some(app_data_dir.clone())).await; + assert!( + response.starts_with(b"HTTP/1.1 200 OK"), + "extension={extension}, response={}", + String::from_utf8_lossy(&response) + ); + } + + for extension in ["html", "wasm", "exe", ""] { + let file_name = if extension.is_empty() { + "asset".to_string() + } else { + format!("asset.{extension}") + }; + let file_path = app_data_dir.join(file_name); + tokio::fs::write(&file_path, b"x") + .await + .expect("거부 media 파일 생성 실패"); + let response = + media_response("", &encode_path(&file_path), Some(app_data_dir.clone())).await; + assert_eq!( + response, + empty_response("403 Forbidden"), + "extension={extension}" + ); + } +} + +#[tokio::test] +async fn app_data_and_file_failures_preserve_status_mapping() { + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + tokio::fs::create_dir_all(&app_data_dir) + .await + .expect("app data 생성 실패"); + let missing_file = app_data_dir.join("missing.png"); + let encoded_missing_file = encode_path(&missing_file); + + assert_eq!( + media_response("", &encoded_missing_file, None).await, + empty_response("403 Forbidden") + ); + assert_eq!( + media_response( + "", + &encoded_missing_file, + Some(temp.path().join("missing-app-data")), + ) + .await, + empty_response("403 Forbidden") + ); + assert_eq!( + media_response("", &encoded_missing_file, Some(app_data_dir.clone())).await, + empty_response("404 Not Found") + ); + + let unreadable_directory = app_data_dir.join("directory.png"); + tokio::fs::create_dir(&unreadable_directory) + .await + .expect("읽기 실패용 디렉터리 생성 실패"); + assert_eq!( + media_response("", &encode_path(&unreadable_directory), Some(app_data_dir)).await, + empty_response("404 Not Found") + ); +} + +#[tokio::test] +async fn app_data_lookup_remains_after_auth_decode_and_absolute_path_gates() { + let app_data_calls = Cell::new(0); + + for (rest, expected_status) in [ + ("*?token=wrong", "403 Forbidden"), + ("*?token=secret", "400 Bad Request"), + ("aW1hZ2VzL3ByZXZpZXcucG5n?token=secret", "403 Forbidden"), + ] { + let (mut client, mut server) = tcp_pair().await; + handle_media_request( + &mut server, + rest, + || "secret".to_string(), + || { + app_data_calls.set(app_data_calls.get() + 1); + None + }, + ) + .await; + drop(server); + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + assert_eq!(response, empty_response(expected_status)); + assert_eq!(app_data_calls.get(), 0, "rest={rest}"); + } + + let absolute_path = std::env::temp_dir().join("preview.png"); + let (mut client, mut server) = tcp_pair().await; + handle_media_request( + &mut server, + &format!("{}?token=secret", encode_path(&absolute_path)), + || "secret".to_string(), + || { + app_data_calls.set(app_data_calls.get() + 1); + None + }, + ) + .await; + drop(server); + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + assert_eq!(response, empty_response("403 Forbidden")); + assert_eq!(app_data_calls.get(), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn canonical_containment_follows_symlinks_before_authorization() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("임시 경로 생성 실패"); + let app_data_dir = temp.path().join("app-data"); + let outside_dir = temp.path().join("outside"); + tokio::fs::create_dir_all(&app_data_dir) + .await + .expect("app data 생성 실패"); + tokio::fs::create_dir_all(&outside_dir) + .await + .expect("외부 경로 생성 실패"); + + let outside_file = outside_dir.join("outside.png"); + tokio::fs::write(&outside_file, b"outside") + .await + .expect("외부 파일 생성 실패"); + let inside_link = app_data_dir.join("inside-link.png"); + symlink(&outside_file, &inside_link).expect("외부 파일 symlink 생성 실패"); + assert_eq!( + media_response("", &encode_path(&inside_link), Some(app_data_dir.clone())).await, + empty_response("403 Forbidden") + ); + + let inside_file = app_data_dir.join("inside.png"); + tokio::fs::write(&inside_file, b"inside") + .await + .expect("내부 파일 생성 실패"); + let outside_link = outside_dir.join("outside-link.png"); + symlink(&inside_file, &outside_link).expect("내부 파일 symlink 생성 실패"); + let response = media_response("", &encode_path(&outside_link), Some(app_data_dir)).await; + assert!(response.starts_with(b"HTTP/1.1 200 OK")); + assert!(response.ends_with(b"inside")); +} diff --git a/src-tauri/src/services/obs_bridge/rpc.rs b/src-tauri/src/services/obs_bridge/rpc.rs new file mode 100644 index 000000000..04e555e9e --- /dev/null +++ b/src-tauri/src/services/obs_bridge/rpc.rs @@ -0,0 +1,132 @@ +use base64::Engine; +use serde_json::Value; +use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; +use tauri::webview::InvokeRequest; + +use crate::models::obs::InvokeRequestPayload; + +pub(super) type RpcResult = Result; +pub(super) type RpcSender = tokio::sync::mpsc::UnboundedSender<(String, RpcResult)>; + +pub(super) const APP_HANDLE_NOT_AVAILABLE: &str = "AppHandle not available"; +pub(super) const NO_WEBVIEW_AVAILABLE: &str = "No webview window available"; + +pub(super) const ALLOWED_WS_COMMANDS: &[&str] = &[ + "app_bootstrap", + "settings_get", + "editor_get", + "layer_groups_get", + "note_tab_get_all", + "note_tab_get", + "css_get", + "css_get_use", + "css_tab_get_all", + "css_tab_get", + "js_get", + "js_get_use", + "get_cursor_settings", + "keys_get", + "keys_get_counters", + "positions_get", + "stat_positions_get", + "graph_positions_get", + "knob_positions_get", + "custom_tabs_list", + "sound_list", + "sound_load_original", + "counter_animation_list", + "plugin_bridge_send", + "plugin_bridge_send_to", + "raw_input_subscribe", + "raw_input_unsubscribe", + "plugin_storage_get", + "plugin_storage_set", + "plugin_storage_remove", + "plugin_storage_keys", + "plugin_storage_has_data", + // 파괴적 bulk 삭제는 plugin_storage_clear와 동일하게 원격 차단 +]; + +pub(super) fn is_allowed_command(command: &str) -> bool { + ALLOWED_WS_COMMANDS.contains(&command) +} + +pub(super) fn build_allowed_list() -> Vec { + ALLOWED_WS_COMMANDS + .iter() + .map(|command| command.to_string()) + .collect() +} + +pub(super) fn parse_invoke_request( + payload: &Value, +) -> Result { + serde_json::from_value(payload.clone()) +} + +pub(super) fn invalid_invoke_request_error(error: &serde_json::Error) -> Value { + serde_json::json!(format!("Invalid invoke_request: {error}")) +} + +pub(super) fn command_not_allowed_error(command: &str) -> Value { + serde_json::json!(format!("Command not allowed: {command}")) +} + +pub(super) fn select_overlay_or_main(overlay: Overlay, main: Main) -> Option +where + Overlay: FnOnce() -> Option, + Main: FnOnce() -> Option, +{ + overlay().or_else(main) +} + +pub(super) fn local_invoke_url() -> tauri::Url { + // 플랫폼별 로컬 URL (Windows: http://tauri.localhost, macOS/Linux: tauri://localhost) + if cfg!(windows) || cfg!(target_os = "android") { + tauri::Url::parse("http://tauri.localhost").unwrap() + } else { + tauri::Url::parse("tauri://localhost").unwrap() + } +} + +pub(super) fn build_invoke_request( + command: String, + args: Value, + local_url: tauri::Url, + invoke_key: String, +) -> InvokeRequest { + InvokeRequest { + cmd: command, + callback: CallbackFn(0), + error: CallbackFn(1), + url: local_url, + body: InvokeBody::Json(args), + headers: Default::default(), + invoke_key, + } +} + +pub(super) fn project_invoke_response(response: InvokeResponse) -> RpcResult { + match response { + InvokeResponse::Ok(body) => { + let value = match body { + InvokeResponseBody::Json(json_str) => { + serde_json::from_str(&json_str).unwrap_or(Value::Null) + } + InvokeResponseBody::Raw(bytes) => { + // Raw bytes → base64 인코딩 + Value::String(base64::engine::general_purpose::STANDARD.encode(&bytes)) + } + }; + Ok(value) + } + InvokeResponse::Err(err) => Err(err.0), + } +} + +pub(super) fn send_rpc_response(rpc_tx: &RpcSender, request_id: String, result: RpcResult) { + let _ = rpc_tx.send((request_id, result)); +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/services/obs_bridge/rpc/tests.rs b/src-tauri/src/services/obs_bridge/rpc/tests.rs new file mode 100644 index 000000000..6a6c750ed --- /dev/null +++ b/src-tauri/src/services/obs_bridge/rpc/tests.rs @@ -0,0 +1,136 @@ +use std::cell::RefCell; + +use tauri::ipc::{InvokeBody, InvokeError, InvokeResponse, InvokeResponseBody}; + +use super::*; + +#[test] +fn request_parser_preserves_camel_case_defaults_and_unknown_fields() { + let request = parse_invoke_request(&serde_json::json!({ + "requestId": "request-1", + "command": "settings_get", + "unknown": true, + })) + .expect("invoke_request 파싱 실패"); + assert_eq!(request.request_id, "request-1"); + assert_eq!(request.command, "settings_get"); + assert_eq!(request.args, Value::Null); + + let error = parse_invoke_request(&serde_json::json!({ + "request_id": "request-1", + "command": "settings_get", + })) + .expect_err("snake_case request_id가 허용되면 안 됨"); + assert_eq!(error.to_string(), "missing field `requestId`"); +} + +#[test] +fn webview_selection_prefers_overlay_and_lazily_falls_back_to_main() { + let calls = RefCell::new(Vec::new()); + let selected = select_overlay_or_main( + || { + calls.borrow_mut().push("overlay"); + Some("overlay-window") + }, + || { + calls.borrow_mut().push("main"); + Some("main-window") + }, + ); + assert_eq!(selected, Some("overlay-window")); + assert_eq!(calls.into_inner(), vec!["overlay"]); + + let calls = RefCell::new(Vec::new()); + let selected = select_overlay_or_main( + || { + calls.borrow_mut().push("overlay"); + None + }, + || { + calls.borrow_mut().push("main"); + Some("main-window") + }, + ); + assert_eq!(selected, Some("main-window")); + assert_eq!(calls.into_inner(), vec!["overlay", "main"]); + + assert_eq!(select_overlay_or_main(|| None::<&str>, || None), None); + assert_eq!(NO_WEBVIEW_AVAILABLE, "No webview window available"); +} + +#[test] +fn invoke_request_projection_preserves_platform_and_tauri_fields() { + let args = serde_json::json!({ "mode": "default", "value": 3 }); + let local_url = local_invoke_url(); + let request = build_invoke_request( + "settings_get".to_string(), + args.clone(), + local_url, + "invoke-key".to_string(), + ); + + assert_eq!(request.cmd, "settings_get"); + assert_eq!(request.callback.0, 0); + assert_eq!(request.error.0, 1); + if cfg!(windows) || cfg!(target_os = "android") { + assert_eq!(request.url.as_str(), "http://tauri.localhost/"); + } else { + assert_eq!(request.url.as_str(), "tauri://localhost"); + } + match request.body { + InvokeBody::Json(body) => assert_eq!(body, args), + InvokeBody::Raw(_) => panic!("invoke body는 JSON이어야 함"), + } + assert!(request.headers.is_empty()); + assert_eq!(request.invoke_key, "invoke-key"); +} + +#[test] +fn invoke_response_projection_preserves_json_raw_error_and_null_fallback() { + assert_eq!( + project_invoke_response(InvokeResponse::Ok(InvokeResponseBody::Json( + r#"{"ok":true,"value":3}"#.to_string(), + ))), + Ok(serde_json::json!({ "ok": true, "value": 3 })) + ); + assert_eq!( + project_invoke_response(InvokeResponse::Ok(InvokeResponseBody::Json( + "not-json".to_string(), + ))), + Ok(Value::Null) + ); + assert_eq!( + project_invoke_response(InvokeResponse::Ok(InvokeResponseBody::Raw(vec![ + 0, 1, 2, 253, 254, 255, + ]))), + Ok(Value::String("AAEC/f7/".to_string())) + ); + assert_eq!( + project_invoke_response(InvokeResponse::Err(InvokeError(serde_json::json!({ + "code": "FAILED", + "retryable": false, + })))), + Err(serde_json::json!({ + "code": "FAILED", + "retryable": false, + })) + ); +} + +#[test] +fn rpc_sender_preserves_fifo_and_ignores_closed_receiver() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + send_rpc_response(&tx, "first".to_string(), Ok(serde_json::json!(1))); + send_rpc_response(&tx, "second".to_string(), Err(serde_json::json!("failed"))); + assert_eq!( + rx.try_recv().expect("첫 RPC 응답 누락"), + ("first".to_string(), Ok(serde_json::json!(1))) + ); + assert_eq!( + rx.try_recv().expect("두 번째 RPC 응답 누락"), + ("second".to_string(), Err(serde_json::json!("failed"))) + ); + + drop(rx); + send_rpc_response(&tx, "closed".to_string(), Ok(Value::Null)); +} diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs new file mode 100644 index 000000000..5698b7d21 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/tests.rs @@ -0,0 +1,1185 @@ +use super::*; +use tokio_tungstenite::{connect_async, MaybeTlsStream}; + +type TestWebSocket = WebSocketStream>; + +async fn tcp_pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("TCP listener 바인딩 실패"); + let address = listener.local_addr().expect("TCP listener 주소 조회 실패"); + let (client, server) = tokio::join!(TcpStream::connect(address), listener.accept()); + ( + client.expect("TCP client 연결 실패"), + server.expect("TCP server accept 실패").0, + ) +} + +async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnvelope { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope = + serde_json::from_str::(&text).expect("OBS envelope 파싱 실패"); + if envelope.msg_type == expected_type { + return envelope; + } + } + Some(Ok(_)) => {} + Some(Err(error)) => panic!("WS 메시지 수신 실패: {error}"), + None => panic!("WS 연결이 예기치 않게 종료됨"), + } + } + }) + .await + .expect("WS 메시지 수신 타임아웃") +} + +async fn receive_envelope_value(ws: &mut TestWebSocket, expected_type: &str) -> Value { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope = + serde_json::from_str::(&text).expect("OBS envelope 파싱 실패"); + if envelope.get("type").and_then(Value::as_str) == Some(expected_type) { + return envelope; + } + } + Some(Ok(_)) => {} + Some(Err(error)) => panic!("WS 메시지 수신 실패: {error}"), + None => panic!("WS 연결이 예기치 않게 종료됨"), + } + } + }) + .await + .expect("WS 메시지 수신 타임아웃") +} + +async fn wait_for_client_count(bridge: &ObsBridgeService, expected: u32, timeout: Duration) { + tokio::time::timeout(timeout, async { + while bridge.client_count() != expected { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "OBS client count 대기 실패: expected={expected}, actual={}", + bridge.client_count() + ) + }); +} + +async fn connect_authenticated(port: u16, token: &str) -> TestWebSocket { + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + let hello = make_envelope( + "hello", + 0, + serde_json::json!({ "token": token, "protocol": OBS_PROTOCOL_VERSION }), + ); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + receive_envelope(&mut ws, "hello_ack").await; + receive_envelope(&mut ws, "snapshot").await; + ws +} + +async fn assert_no_tauri_event(ws: &mut TestWebSocket) { + let result = tokio::time::timeout(Duration::from_millis(100), async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope = + serde_json::from_str::(&text).expect("OBS envelope 파싱 실패"); + if envelope.msg_type == "tauri_event" { + return true; + } + } + Some(Ok(_)) => {} + Some(Err(_)) | None => return false, + } + } + }) + .await; + + assert!( + matches!(result, Err(_) | Ok(false)), + "예상하지 않은 tauri_event 중복 수신" + ); +} + +#[test] +fn websocket_allowlist_uses_exact_matching() { + assert_eq!(ALLOWED_WS_COMMANDS.len(), 32); + assert!(is_allowed_command("app_bootstrap")); + assert!(is_allowed_command("editor_get")); + assert!(!is_allowed_command("css_history_get")); + assert!(is_allowed_command("sound_list")); + assert!(is_allowed_command("sound_load_original")); + assert!(!is_allowed_command("plugin_storage_clear")); + assert!(!is_allowed_command("plugin_storage_clear_by_prefix")); + assert!(!is_allowed_command("editor_commit")); + assert!(!is_allowed_command("settings_update")); + assert!(!is_allowed_command("keys_update")); + assert!(!is_allowed_command("keys_update_with_positions")); + assert!(!is_allowed_command("plugin_rpc_send")); + assert!(!is_allowed_command("plugin_rpc_respond")); + assert!(!is_allowed_command("plugin_instances_commit")); + assert!(!is_allowed_command("plugin_instances_reconcile")); + assert!(!is_allowed_command("plugin_authority_reset")); + assert!(!is_allowed_command("app_bootstrap_extra")); + assert!(!is_allowed_command("plugin:window|close")); + assert_eq!(build_allowed_list().len(), ALLOWED_WS_COMMANDS.len()); +} + +fn invoke_test_address() -> SocketAddr { + "127.0.0.1:34891".parse().expect("테스트 주소 파싱 실패") +} + +#[test] +fn invoke_parse_failure_responds_only_for_a_string_request_id() { + let bridge = ObsBridgeService::new("test"); + let address = invoke_test_address(); + + let payload = serde_json::json!({ + "requestId": "parse-1", + "command": 42, + }); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + bridge.handle_invoke_request(&payload, &address, tx); + assert_eq!( + rx.try_recv().expect("파싱 오류 RPC 응답 누락"), + ( + "parse-1".to_string(), + Err(serde_json::json!( + "Invalid invoke_request: invalid type: integer `42`, expected a string" + )), + ) + ); + + for payload in [ + serde_json::json!({ "command": 42 }), + serde_json::json!({ "requestId": 1, "command": 42 }), + serde_json::json!({ "requestId": null, "command": 42 }), + ] { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + bridge.handle_invoke_request(&payload, &address, tx); + assert!( + matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) + ), + "문자열 requestId가 없으면 응답하지 않아야 함: {payload}" + ); + } +} + +#[test] +fn invoke_admission_preserves_error_bytes_and_channel_order() { + let bridge = ObsBridgeService::new("test"); + let address = invoke_test_address(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + bridge.handle_invoke_request( + &serde_json::json!({ + "requestId": "denied", + "command": "app_bootstrap_extra", + "args": { "ignored": true }, + }), + &address, + tx.clone(), + ); + bridge.handle_invoke_request( + &serde_json::json!({ + "requestId": "allowed", + "command": "app_bootstrap", + "unknown": "ignored", + }), + &address, + tx, + ); + + assert_eq!( + rx.try_recv().expect("allowlist 거부 응답 누락"), + ( + "denied".to_string(), + Err(serde_json::json!( + "Command not allowed: app_bootstrap_extra" + )), + ) + ); + assert_eq!( + rx.try_recv().expect("AppHandle 부재 응답 누락"), + ( + "allowed".to_string(), + Err(serde_json::json!("AppHandle not available")), + ) + ); +} + +#[test] +fn invoke_response_send_is_best_effort_after_receiver_closes() { + let bridge = ObsBridgeService::new("test"); + let address = invoke_test_address(); + + for payload in [ + serde_json::json!({ "requestId": "parse", "command": 42 }), + serde_json::json!({ "requestId": "denied", "command": "not-allowed" }), + serde_json::json!({ "requestId": "no-app", "command": "settings_get" }), + ] { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + drop(rx); + bridge.handle_invoke_request(&payload, &address, tx); + } +} + +#[test] +fn bridge_messages_targeting_main_are_not_forwarded_to_obs() { + let broadcast = serde_json::json!({ "type": "PING", "data": null }); + assert!(is_forwarded_to_obs("plugin-bridge:message", &broadcast)); + let to_overlay = serde_json::json!({ "type": "PING", "target": "overlay" }); + assert!(is_forwarded_to_obs("plugin-bridge:message", &to_overlay)); + let to_main = serde_json::json!({ "type": "PING", "target": "main" }); + assert!(!is_forwarded_to_obs("plugin-bridge:message", &to_main)); + assert!(!is_forwarded_to_obs("app:close-requested", &broadcast)); +} + +#[test] +fn public_overlay_events_are_forwarded_to_obs_clients() { + assert_eq!(FORWARDED_EVENTS.len(), 32); + for event in [ + "customTabs:changed", + "overlay:resized", + "counterAnimation:changed", + "preset:snapshot", + ] { + assert!(FORWARDED_EVENTS.contains(&event), "missing event: {event}"); + } +} + +#[tokio::test] +async fn publish_is_noop_while_server_is_stopped() { + let bridge = ObsBridgeService::new("test"); + let mut receiver = bridge.broadcast_tx.subscribe(); + + bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); + + assert!( + tokio::time::timeout(Duration::from_millis(50), receiver.recv()) + .await + .is_err() + ); +} + +#[tokio::test] +async fn publish_with_no_receivers_keeps_running_server_healthy() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + assert_ne!(port, 0); + assert_eq!(bridge.broadcast_tx.receiver_count(), 0); + bridge.publish("settings:changed", serde_json::json!({ "enabled": true })); + assert!(bridge.is_running()); + + bridge.stop(); +} + +#[tokio::test] +async fn publish_forwards_supported_events_to_authenticated_client() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut ws = connect_authenticated(port, "token").await; + + for (event_name, data) in [ + ("settings:changed", serde_json::json!({ "theme": "dark" })), + ("overlay:lock", serde_json::json!({ "locked": true })), + ("css:content", serde_json::json!({ "content": "body {}" })), + ] { + bridge.publish(event_name, data.clone()); + let event = receive_envelope(&mut ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ "event": event_name, "data": data }) + ); + } + + bridge.stop(); +} + +#[tokio::test] +async fn publish_ignores_events_outside_forwarded_allowlist() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut ws = connect_authenticated(port, "token").await; + + bridge.publish("history:status", serde_json::json!({ "canUndo": true })); + bridge.publish("settings:changed", serde_json::json!({ "marker": true })); + + let event = receive_envelope(&mut ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ + "event": "settings:changed", + "data": { "marker": true } + }) + ); + assert_no_tauri_event(&mut ws).await; + + bridge.stop(); +} + +#[tokio::test] +async fn publish_reaches_multiple_authenticated_clients_once_each() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut first = connect_authenticated(port, "token").await; + let mut second = connect_authenticated(port, "token").await; + + bridge.publish("overlay:lock", serde_json::json!({ "locked": false })); + + for ws in [&mut first, &mut second] { + let event = receive_envelope(ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ + "event": "overlay:lock", + "data": { "locked": false } + }) + ); + assert_no_tauri_event(ws).await; + } + + bridge.stop(); +} + +#[tokio::test] +async fn publish_after_stop_start_has_no_missing_or_duplicate_event() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let first_port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut first = connect_authenticated(first_port, "token").await; + + bridge.publish("settings:changed", serde_json::json!({ "cycle": 1 })); + let first_event = receive_envelope(&mut first, "tauri_event").await; + assert_eq!(first_event.payload["data"]["cycle"], 1); + + bridge.stop(); + drop(first); + + let second_port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 재시작 실패"); + let mut second = connect_authenticated(second_port, "token").await; + + bridge.publish("settings:changed", serde_json::json!({ "cycle": 2 })); + let second_event = receive_envelope(&mut second, "tauri_event").await; + assert_eq!(second_event.payload["data"]["cycle"], 2); + assert_no_tauri_event(&mut second).await; + + bridge.stop(); +} + +#[tokio::test] +async fn lagged_publish_burst_recovers_with_latest_snapshot() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let expected_snapshot = serde_json::json!({ "revision": 300 }); + bridge.update_snapshot(expected_snapshot.clone()); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut ws = connect_authenticated(port, "token").await; + + for revision in 0..300 { + bridge.publish( + "settings:changed", + serde_json::json!({ "revision": revision }), + ); + } + + let snapshot = receive_envelope(&mut ws, "snapshot").await; + assert_eq!(snapshot.payload, expected_snapshot); + + bridge.stop(); +} + +#[tokio::test] +async fn websocket_session_preserves_handshake_projection_and_client_sequence() { + let bridge = Arc::new(ObsBridgeService::new("session-test")); + let expected_snapshot = serde_json::json!({ "revision": 7 }); + bridge.update_snapshot(expected_snapshot.clone()); + let port = bridge + .start(0, "session-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + + // hello 전의 malformed/다른 text envelope 무시 + ws.send(Message::Text("not-json".to_string())) + .await + .expect("malformed text 전송 실패"); + ws.send(Message::Text( + make_envelope("ping", 10, Value::Null).to_string(), + )) + .await + .expect("handshake 이전 ping 전송 실패"); + ws.send(Message::Text( + make_envelope( + "hello", + 11, + serde_json::json!({ + "token": "session-token", + "protocol": OBS_PROTOCOL_VERSION, + }), + ) + .to_string(), + )) + .await + .expect("hello 전송 실패"); + + let ack = receive_envelope_value(&mut ws, "hello_ack").await; + assert_eq!(ack["v"], OBS_PROTOCOL_VERSION); + assert_eq!(ack["seq"], 0); + assert_eq!(ack["payload"]["serverVersion"], "session-test"); + assert_eq!(ack["payload"]["obsMode"], true); + assert_eq!( + ack["payload"]["allowedList"], + serde_json::to_value(build_allowed_list()).expect("allowlist 직렬화 실패") + ); + + let snapshot = receive_envelope_value(&mut ws, "snapshot").await; + assert_eq!(snapshot["seq"], 1); + assert_eq!(snapshot["payload"], expected_snapshot); + assert_eq!(bridge.client_count(), 1); + + // tokio interval의 기존 첫 immediate tick + let server_ping = receive_envelope_value(&mut ws, "ping").await; + assert_eq!(server_ping["seq"], 2); + assert_eq!(server_ping["payload"], Value::Null); + + ws.send(Message::Text( + make_envelope("ping", 12, Value::Null).to_string(), + )) + .await + .expect("client ping 전송 실패"); + let pong = receive_envelope_value(&mut ws, "pong").await; + assert_eq!(pong["seq"], 3); + assert_eq!(pong["payload"], Value::Null); + + ws.send(Message::Text( + make_envelope("resync_request", 13, Value::Null).to_string(), + )) + .await + .expect("resync_request 전송 실패"); + let resync = receive_envelope_value(&mut ws, "snapshot").await; + assert_eq!(resync["seq"], 4); + assert_eq!(resync["payload"], serde_json::json!({ "revision": 7 })); + + ws.send(Message::Text( + make_envelope( + "invoke_request", + 14, + serde_json::json!({ + "requestId": "rpc-1", + "command": "not-allowed", + }), + ) + .to_string(), + )) + .await + .expect("invoke_request 전송 실패"); + let rpc = receive_envelope_value(&mut ws, "invoke_response").await; + assert_eq!(rpc["seq"], 5); + assert_eq!(rpc["payload"]["requestId"], "rpc-1"); + assert_eq!(rpc["payload"]["error"], "Command not allowed: not-allowed"); + + bridge.publish("settings:changed", serde_json::json!({ "revision": 8 })); + let broadcast = receive_envelope_value(&mut ws, "tauri_event").await; + assert_eq!(broadcast["seq"], 6); + assert_eq!(broadcast["payload"]["event"], "settings:changed"); + assert_eq!(broadcast["payload"]["data"]["revision"], 8); + + ws.close(None).await.expect("WS 종료 실패"); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; + bridge.stop(); +} + +#[test] +fn host_header_allows_only_ip_literals_and_localhost() { + for allowed in [ + "localhost", + "LOCALHOST:34891", + "127.0.0.1", + "127.0.0.1:34891", + "192.168.0.10:80", + "::1", + "[::1]", + "[2001:db8::1]:34891", + ] { + assert!(is_allowed_host_header(allowed), "{allowed}"); + } + + for rejected in [ + "", + "example.com", + "example.com:34891", + "localhost.example.com", + "localhost:invalid", + "[localhost]:34891", + "127.0.0.1:99999", + ] { + assert!(!is_allowed_host_header(rejected), "{rejected}"); + } +} + +#[test] +fn http_host_validation_rejects_missing_or_duplicate_headers() { + assert!(has_allowed_http_host( + "GET / HTTP/1.1\r\nHost: localhost:34891\r\n\r\n" + )); + assert!(!has_allowed_http_host("GET / HTTP/1.1\r\n\r\n")); + assert!(!has_allowed_http_host( + "GET / HTTP/1.1\r\nHost: localhost\r\nHost: example.com\r\n\r\n" + )); +} + +#[test] +fn http_header_parser_stops_at_the_first_empty_line_and_preserves_upgrade_tokens() { + let headers = + "GET / HTTP/1.1\r\nhOsT: localhost:34891\r\nUpgrade: keep-alive, WebSocket\r\n\r\n"; + let request = format!("{headers}Host: example.com\r\n"); + + assert_eq!(http_header_end(request.as_bytes()), Some(headers.len())); + assert_eq!( + http_header_values(&request, "host"), + vec!["localhost:34891"] + ); + assert!(is_websocket_upgrade_request(&request)); + assert_eq!(http_header_end(b"GET / HTTP/1.1\n\n"), None); +} + +#[test] +fn websocket_validation_reports_host_before_origin_and_uses_forbidden_status() { + let invalid_host_and_origin = WsRequest::builder() + .header(header::HOST, "example.com") + .header(header::ORIGIN, "https://example.com") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&invalid_host_and_origin), + Err("Invalid Host header") + ); + + let duplicate_origin = WsRequest::builder() + .header(header::HOST, "localhost:34891") + .header(header::ORIGIN, "http://localhost:3400") + .header(header::ORIGIN, "http://127.0.0.1:3400") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&duplicate_origin), + Err("Invalid Origin header") + ); + + let response = websocket_forbidden_response("Invalid Host header"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response.body().as_deref(), Some("Invalid Host header")); +} + +#[tokio::test] +async fn prefixed_stream_replays_partial_prefix_before_socket_bytes() { + let (mut client, server) = tcp_pair().await; + client + .write_all(b"socket") + .await + .expect("socket 바이트 전송 실패"); + + let mut stream = PrefixedStream::new(b"header".to_vec(), server); + let mut first = [0u8; 2]; + stream + .read_exact(&mut first) + .await + .expect("prefix 첫 조각 수신 실패"); + assert_eq!(&first, b"he"); + + let mut remaining = [0u8; 10]; + stream + .read_exact(&mut remaining) + .await + .expect("prefix와 socket 바이트 수신 실패"); + assert_eq!(&remaining, b"adersocket"); +} + +#[tokio::test] +async fn transport_io_preserves_incomplete_header_error_and_empty_response_bytes() { + let (mut incomplete_client, mut incomplete_server) = tcp_pair().await; + incomplete_client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost") + .await + .expect("미완성 header 전송 실패"); + incomplete_client + .shutdown() + .await + .expect("미완성 header 연결 종료 실패"); + + let error = read_http_request_headers(&mut incomplete_server) + .await + .expect_err("미완성 header가 허용됨"); + assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof); + assert_eq!(error.to_string(), "incomplete HTTP request headers"); + + let (mut response_client, mut response_server) = tcp_pair().await; + write_empty_http_response(&mut response_server, "400 Bad Request").await; + drop(response_server); + + let mut response = Vec::new(); + response_client + .read_to_end(&mut response) + .await + .expect("빈 HTTP 응답 수신 실패"); + assert_eq!( + response, + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); +} + +#[test] +fn websocket_origin_allows_absence_or_local_machine_hosts_only() { + let no_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .body(()) + .unwrap(); + assert!(validate_websocket_request(&no_origin).is_ok()); + + let local_origin = WsRequest::builder() + .header(header::HOST, "localhost:34891") + .header(header::ORIGIN, "http://[::1]:3400") + .body(()) + .unwrap(); + assert!(validate_websocket_request(&local_origin).is_ok()); + + // 이 머신에 할당된 LAN IP는 허용 (LAN 클라이언트가 접속에 쓰는 페이지 origin) + if let Ok(lan_ip) = local_ip_address::local_ip() { + let lan_origin = WsRequest::builder() + .header(header::HOST, format!("{lan_ip}:34891")) + .header(header::ORIGIN, format!("http://{lan_ip}:34891")) + .body(()) + .unwrap(); + assert!(validate_websocket_request(&lan_origin).is_ok()); + } + + let domain_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .header(header::ORIGIN, "https://example.com") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&domain_origin), + Err("Invalid Origin header") + ); + + // 이 머신의 인터페이스가 아닌 임의 숫자 IP는 거부 (TEST-NET-3) + let foreign_ip_origin = WsRequest::builder() + .header(header::HOST, "127.0.0.1:34891") + .header(header::ORIGIN, "http://203.0.113.7") + .body(()) + .unwrap(); + assert_eq!( + validate_websocket_request(&foreign_ip_origin), + Err("Invalid Origin header") + ); +} + +async fn http_get(host: &str, port: u16, path: &str) -> String { + let mut stream = TcpStream::connect((host, port)) + .await + .expect("TCP 연결 실패"); + let request = + format!("GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + String::from_utf8_lossy(&response).into_owned() +} + +#[test] +fn local_machine_ip_rejects_foreign_addresses() { + assert!(is_local_machine_ip(&"127.0.0.1".parse().unwrap())); + assert!(is_local_machine_ip(&"::1".parse().unwrap())); + // TEST-NET-3 — 로컬 인터페이스에 할당될 일 없는 주소 + assert!(!is_local_machine_ip(&"203.0.113.7".parse().unwrap())); + if let Ok(lan_ip) = local_ip_address::local_ip() { + assert!(is_local_machine_ip(&lan_ip)); + } +} + +async fn media_response_without_app_handle(expected_token: &str, rest: &str) -> Vec { + let bridge = ObsBridgeService::new("test"); + bridge.set_token(expected_token.to_string()); + let (mut client, mut server) = tcp_pair().await; + + bridge.handle_media_request(&mut server, rest).await; + drop(server); + + let mut response = Vec::new(); + client + .read_to_end(&mut response) + .await + .expect("media 응답 수신 실패"); + response +} + +#[tokio::test] +async fn media_token_query_uses_the_first_exact_token_parameter() { + for (rest, expected_status) in [ + ("*?token=secret", "400 Bad Request"), + ("*?other=value&token=secret", "400 Bad Request"), + ("*?token=wrong&token=secret", "403 Forbidden"), + ("*?not-token=secret", "403 Forbidden"), + ("*?token=secret%20", "403 Forbidden"), + ("*?TOKEN=secret", "403 Forbidden"), + ] { + let response = media_response_without_app_handle("secret", rest).await; + assert!( + response.starts_with(format!("HTTP/1.1 {expected_status}").as_bytes()), + "rest={rest}, response={}", + String::from_utf8_lossy(&response) + ); + } + + let response = media_response_without_app_handle("", "*").await; + assert!(response.starts_with(b"HTTP/1.1 400 Bad Request")); +} + +#[tokio::test] +async fn media_path_decode_preserves_malformed_input_status_order() { + use base64::Engine as _; + + let invalid_utf8 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xff]); + let relative_path = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("images/preview.PNG"); + let absolute_path = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + std::env::temp_dir() + .join("preview.PNG") + .to_string_lossy() + .as_bytes(), + ); + + for (rest, expected_status) in [ + ("%GG", "400 Bad Request"), + ("%", "400 Bad Request"), + (invalid_utf8.as_str(), "400 Bad Request"), + (relative_path.as_str(), "403 Forbidden"), + (absolute_path.as_str(), "403 Forbidden"), + ] { + let response = media_response_without_app_handle("", rest).await; + assert!( + response.starts_with(format!("HTTP/1.1 {expected_status}").as_bytes()), + "rest={rest}, response={}", + String::from_utf8_lossy(&response) + ); + } +} + +#[test] +fn media_percent_decoding_preserves_lossy_and_incomplete_sequences() { + assert_eq!(percent_decode("abc%2Fdef%20ghi"), "abc/def ghi"); + assert_eq!(percent_decode("%2f%41"), "/A"); + assert_eq!(percent_decode("%GG%2"), "%GG%2"); + assert_eq!(percent_decode("%FF"), "\u{fffd}"); + assert_eq!(percent_decode("plus+sign"), "plus+sign"); +} + +#[test] +fn media_mime_mapping_is_case_insensitive_and_preserves_fallbacks() { + for (path, expected) in [ + ("index.HTML", "text/html; charset=utf-8"), + ("bundle.MJS", "application/javascript; charset=utf-8"), + ("theme.Css", "text/css; charset=utf-8"), + ("data.JSON", "application/json; charset=utf-8"), + ("image.JPEG", "image/jpeg"), + ("clip.OgG", "video/ogg"), + ("font.WOFF2", "font/woff2"), + ("module.WASM", "application/wasm"), + ("no-extension", "application/octet-stream"), + ("archive.exe", "application/octet-stream"), + ] { + assert_eq!(guess_mime(path), expected, "path={path}"); + } +} + +// dev 리다이렉트(token 쿼리 포함)는 이 머신 자신의 peer에만 허용 — +// 같은 PC가 자기 LAN IP로 접속하는 URL 복사 경로는 유지되어야 함 +#[tokio::test] +async fn dev_redirect_serves_only_local_machine_peers() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.set_dev_url("http://localhost:3400".to_string()); + let port = bridge + .start(0, "dev-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + // loopback peer → 기존 dev 부트스트랩(302 + token) 유지 + let response = http_get("127.0.0.1", port, "/").await; + assert!(response.starts_with("HTTP/1.1 302"), "{response}"); + assert!(response.contains("token=dev-token"), "{response}"); + + // 같은 머신의 LAN IP 경유 — peer도 로컬 인터페이스 IP이므로 유지되어야 함 + if let Ok(lan_ip) = local_ip_address::local_ip() { + let response = http_get(&lan_ip.to_string(), port, "/").await; + assert!( + response.starts_with("HTTP/1.1 302"), + "같은 PC의 LAN IP 접속이 깨지면 안 됨: {response}" + ); + } + + bridge.stop(); +} + +// Game Bar 엔드포인트 제거 검증 — 어떤 경로도 무인증 HTTP 응답으로 토큰을 내주면 안 됨 +#[tokio::test] +async fn gamebar_paths_no_longer_leak_token() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "secret-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + for path in ["/gamebar", "/gamebar/bootstrap.json"] { + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP 연결 실패"); + let request = + format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + let response = String::from_utf8_lossy(&response); + assert!( + !response.contains("secret-token"), + "{path} 응답에 토큰 노출: {response}" + ); + } + + bridge.stop(); +} + +// dev 모드에서도 /media는 Vite 리다이렉트가 아니라 브릿지가 직접 처리해야 함 +#[tokio::test] +async fn media_requests_bypass_dev_redirect() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.set_dev_url("http://localhost:3400".to_string()); + let port = bridge + .start(0, "media-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .await + .expect("TCP 연결 실패"); + stream + .write_all( + b"GET /media/abc?token=wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + ) + .await + .expect("요청 전송 실패"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("응답 수신 실패"); + let response = String::from_utf8_lossy(&response); + assert!( + response.starts_with("HTTP/1.1 403"), + "302 리다이렉트가 아닌 미디어 핸들러 403이어야 함: {response}" + ); + + bridge.stop(); +} + +#[tokio::test] +async fn protocol_mismatch_is_rejected_before_auth() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + // 토큰까지 틀려도 프로토콜 오류가 먼저 결정되어야 함 + let hello = serde_json::json!({ + "v": 999, + "type": "hello", + "seq": 0, + "payload": { "token": "wrong-token", "protocol": 999 }, + }); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + let error = receive_envelope(&mut ws, "error").await; + assert_eq!( + error.payload.get("code").and_then(Value::as_str), + Some("PROTOCOL_MISMATCH") + ); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; + + bridge.stop(); +} + +#[tokio::test] +async fn auth_failure_follows_protocol_validation_and_empty_server_token_disables_auth() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "secret".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + ws.send(Message::Text( + make_envelope( + "hello", + 0, + serde_json::json!({ + "token": "wrong", + "protocol": OBS_PROTOCOL_VERSION, + }), + ) + .to_string(), + )) + .await + .expect("hello 전송 실패"); + let error = receive_envelope_value(&mut ws, "error").await; + assert_eq!(error["seq"], 0); + assert_eq!(error["payload"]["code"], "AUTH_FAILED"); + assert_eq!(error["payload"]["message"], "Invalid token"); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; + bridge.stop(); + + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, String::new()) + .await + .expect("빈 토큰 OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("빈 토큰 WS 연결 실패"); + ws.send(Message::Text( + make_envelope( + "hello", + 0, + serde_json::json!({ "protocol": OBS_PROTOCOL_VERSION }), + ) + .to_string(), + )) + .await + .expect("빈 토큰 hello 전송 실패"); + let ack = receive_envelope_value(&mut ws, "hello_ack").await; + assert_eq!(ack["seq"], 0); + let snapshot = receive_envelope_value(&mut ws, "snapshot").await; + assert_eq!(snapshot["seq"], 1); + bridge.stop(); +} + +#[tokio::test] +async fn hello_close_and_timeout_both_decrement_client_count() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + wait_for_client_count(&bridge, 1, Duration::from_secs(2)).await; + ws.close(None).await.expect("hello 이전 WS 종료 실패"); + wait_for_client_count(&bridge, 0, Duration::from_secs(2)).await; + bridge.stop(); + + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let (_ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + wait_for_client_count(&bridge, 1, Duration::from_secs(2)).await; + let started_at = tokio::time::Instant::now(); + wait_for_client_count(&bridge, 0, Duration::from_secs(7)).await; + assert!( + started_at.elapsed() >= Duration::from_secs(4), + "hello timeout이 기존 5초보다 지나치게 빨라지면 안 됨" + ); + bridge.stop(); +} + +// v1 번들은 KeySlot union 와이어 형식을 소비할 수 없으므로 handshake에서 결정적으로 거부 +#[tokio::test] +async fn legacy_protocol_v1_hello_is_rejected() { + let bridge = Arc::new(ObsBridgeService::new("test")); + let port = bridge + .start(0, "token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}")) + .await + .expect("WS 연결 실패"); + let hello = serde_json::json!({ + "v": 1, + "type": "hello", + "seq": 0, + "payload": { "token": "token", "protocol": 1 }, + }); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + + let error = receive_envelope(&mut ws, "error").await; + assert_eq!( + error.payload.get("code").and_then(Value::as_str), + Some("PROTOCOL_MISMATCH") + ); + + bridge.stop(); +} + +#[test] +fn binding_listens_on_all_interfaces_for_lan_access() { + assert!(bind_address(34891).ip().is_unspecified()); + assert_eq!(bind_address(34891).port(), 34891); +} + +// 같은 네트워크의 다른 PC 접속은 제품 계약 — LAN IP 경유 인증 접속이 실제로 성립해야 함 +#[tokio::test] +async fn lan_address_accepts_authenticated_clients() { + let Ok(lan_ip) = local_ip_address::local_ip() else { + eprintln!("LAN IP 조회 불가 환경, 테스트 스킵"); + return; + }; + + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.update_snapshot(serde_json::json!({ "source": "lan" })); + let port = bridge + .start(0, "lan-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + + assert_eq!(bridge.status().local_ip, Some(lan_ip.to_string())); + + let (mut ws, _) = connect_async(format!("ws://{lan_ip}:{port}")) + .await + .expect("LAN IP로 WS 연결 실패"); + let hello = make_envelope( + "hello", + 0, + serde_json::json!({ "token": "lan-token", "protocol": OBS_PROTOCOL_VERSION }), + ); + ws.send(Message::Text(hello.to_string())) + .await + .expect("hello 전송 실패"); + receive_envelope(&mut ws, "hello_ack").await; + let snapshot = receive_envelope(&mut ws, "snapshot").await; + assert_eq!(snapshot.payload, serde_json::json!({ "source": "lan" })); + + bridge.stop(); +} + +#[tokio::test] +async fn token_rotation_disconnects_existing_sessions_and_keeps_server_running() { + let bridge = Arc::new(ObsBridgeService::new("test")); + bridge.update_snapshot(serde_json::json!({ "source": "initial" })); + let port = bridge + .start(0, "old-token".to_string()) + .await + .expect("OBS bridge 시작 실패"); + let mut old_ws = connect_authenticated(port, "old-token").await; + + bridge.set_token("new-token".to_string()); + assert!(bridge.is_running()); + assert_eq!(bridge.status().port, port); + + let resync = make_envelope("resync_request", 1, Value::Null); + let _ = old_ws.send(Message::Text(resync.to_string())).await; + bridge.publish( + "settings:changed", + serde_json::json!({ "authenticated": false }), + ); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match old_ws.next().await { + Some(Ok(Message::Text(text))) => { + let envelope: ObsEnvelope = + serde_json::from_str(&text).expect("OBS envelope 파싱 실패"); + assert_ne!(envelope.msg_type, "snapshot"); + if envelope.msg_type == "tauri_event" { + assert_ne!( + envelope.payload.get("event").and_then(Value::as_str), + Some("settings:changed") + ); + } + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + Some(Ok(_)) => {} + } + } + }) + .await + .expect("구 토큰 세션이 종료되지 않음"); + + let mut new_ws = connect_authenticated(port, "new-token").await; + bridge.publish( + "settings:changed", + serde_json::json!({ "authenticated": true }), + ); + let event = receive_envelope(&mut new_ws, "tauri_event").await; + assert_eq!( + event.payload, + serde_json::json!({ + "event": "settings:changed", + "data": { "authenticated": true } + }) + ); + + bridge.stop(); +} diff --git a/src-tauri/src/services/obs_bridge/transport.rs b/src-tauri/src/services/obs_bridge/transport.rs new file mode 100644 index 000000000..fc641cc0f --- /dev/null +++ b/src-tauri/src/services/obs_bridge/transport.rs @@ -0,0 +1,234 @@ +use std::io::Cursor; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::{ + handshake::server::{ErrorResponse, Request as WsRequest}, + http::{header, StatusCode}, +}; + +const MAX_HTTP_HEADER_SIZE: usize = 16 * 1024; + +pub(super) struct PrefixedStream { + prefix: Cursor>, + stream: TcpStream, +} + +impl PrefixedStream { + pub(super) fn new(prefix: Vec, stream: TcpStream) -> Self { + Self { + prefix: Cursor::new(prefix), + stream, + } + } +} + +impl AsyncRead for PrefixedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let position = this.prefix.position() as usize; + let prefix = this.prefix.get_ref(); + if position < prefix.len() { + let count = (prefix.len() - position).min(buffer.remaining()); + buffer.put_slice(&prefix[position..position + count]); + this.prefix.set_position((position + count) as u64); + return Poll::Ready(Ok(())); + } + Pin::new(&mut this.stream).poll_read(cx, buffer) + } +} + +impl AsyncWrite for PrefixedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_write(cx, buffer) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) + } +} + +pub(super) async fn read_http_request_headers(stream: &mut TcpStream) -> std::io::Result> { + let mut request = Vec::with_capacity(1024); + let mut chunk = [0u8; 1024]; + + loop { + if request.len() == MAX_HTTP_HEADER_SIZE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP request headers too large", + )); + } + + let remaining = MAX_HTTP_HEADER_SIZE - request.len(); + let chunk_len = remaining.min(chunk.len()); + let read = stream.read(&mut chunk[..chunk_len]).await?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete HTTP request headers", + )); + } + request.extend_from_slice(&chunk[..read]); + + if http_header_end(&request).is_some() { + return Ok(request); + } + } +} + +pub(super) fn http_header_end(request: &[u8]) -> Option { + request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) +} + +pub(super) fn http_header_values<'a>(request: &'a str, name: &str) -> Vec<&'a str> { + request + .lines() + .skip(1) + .take_while(|line| !line.trim_end_matches('\r').is_empty()) + .filter_map(|line| line.trim_end_matches('\r').split_once(':')) + .filter_map(|(header_name, value)| { + header_name + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) + .collect() +} + +pub(super) fn is_websocket_upgrade_request(request: &str) -> bool { + http_header_values(request, "upgrade").iter().any(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("websocket")) + }) +} + +fn is_allowed_host_name(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let ip_literal = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + ip_literal.parse::().is_ok() +} + +pub(super) fn is_allowed_host_header(value: &str) -> bool { + let value = value.trim(); + if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) { + return false; + } + if is_allowed_host_name(value) { + return true; + } + + if let Some(closing_bracket) = value.find(']') { + let (host, suffix) = value.split_at(closing_bracket + 1); + return is_allowed_host_name(host) + && suffix + .strip_prefix(':') + .is_some_and(|port| port.parse::().is_ok()); + } + + value + .rsplit_once(':') + .is_some_and(|(host, port)| is_allowed_host_name(host) && port.parse::().is_ok()) +} + +/// 이 머신 자신의 IP인지 판정 (loopback 또는 로컬 인터페이스에 실제 할당된 IP) +pub(super) fn is_local_machine_ip(ip: &IpAddr) -> bool { + if ip.is_loopback() { + return true; + } + local_ip_address::list_afinet_netifas() + .map(|interfaces| { + interfaces + .iter() + .any(|(_, interface_ip)| interface_ip == ip) + }) + .unwrap_or(false) +} + +/// Origin은 브라우저가 강제하는 헤더 — 이 머신이 서빙한 페이지만 신뢰 +/// (localhost/loopback 또는 로컬 인터페이스에 실제 할당된 IP만 허용, OWASP 권고 allowlist) +fn is_local_machine_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + let ip_literal = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + let Ok(ip) = ip_literal.parse::() else { + return false; + }; + is_local_machine_ip(&ip) +} + +fn is_allowed_origin(value: &str) -> bool { + tauri::Url::parse(value) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .is_some_and(|host| is_local_machine_host(&host)) +} + +pub(super) fn has_allowed_http_host(request: &str) -> bool { + let hosts = http_header_values(request, "host"); + hosts.len() == 1 && is_allowed_host_header(hosts[0]) +} + +pub(super) fn validate_websocket_request(request: &WsRequest) -> Result<(), &'static str> { + let hosts: Vec<_> = request.headers().get_all(header::HOST).iter().collect(); + if hosts.len() != 1 || !hosts[0].to_str().ok().is_some_and(is_allowed_host_header) { + return Err("Invalid Host header"); + } + + let origins: Vec<_> = request.headers().get_all(header::ORIGIN).iter().collect(); + if origins.len() > 1 + || origins + .first() + .is_some_and(|origin| !origin.to_str().ok().is_some_and(is_allowed_origin)) + { + return Err("Invalid Origin header"); + } + + Ok(()) +} + +pub(super) fn websocket_forbidden_response(reason: &str) -> ErrorResponse { + let mut response = ErrorResponse::new(Some(reason.to_string())); + *response.status_mut() = StatusCode::FORBIDDEN; + response +} + +// OBS 모드는 같은 네트워크의 다른 PC 접속이 제품 계약 — 항상 전체 인터페이스에 바인딩 +// (WS/미디어는 토큰 인증, 커맨드는 allowlist로 보호) +pub(super) fn bind_address(port: u16) -> SocketAddr { + SocketAddr::from(([0, 0, 0, 0], port)) +} + +pub(super) async fn write_empty_http_response(stream: &mut TcpStream, status: &str) { + let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + let _ = stream.write_all(response.as_bytes()).await; +} diff --git a/src-tauri/src/services/obs_bridge/websocket.rs b/src-tauri/src/services/obs_bridge/websocket.rs new file mode 100644 index 000000000..494a2b835 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/websocket.rs @@ -0,0 +1,69 @@ +use serde_json::Value; + +use crate::models::obs::{make_envelope, HelloAckPayload, ObsBroadcast}; + +use super::rpc::{build_allowed_list, RpcResult}; + +pub(super) struct WebSocketSessionProtocol { + next_sequence: u64, +} + +impl WebSocketSessionProtocol { + pub(super) fn new() -> Self { + Self { next_sequence: 0 } + } + + fn next_seq(&mut self) -> u64 { + let seq = self.next_sequence; + self.next_sequence += 1; + seq + } + + fn envelope(&mut self, msg_type: &str, payload: Value) -> Value { + make_envelope(msg_type, self.next_seq(), payload) + } + + pub(super) fn hello_ack(&mut self, server_version: String) -> Value { + let payload = serde_json::to_value(HelloAckPayload { + server_version, + obs_mode: true, + allowed_list: build_allowed_list(), + }) + .unwrap_or_default(); + self.envelope("hello_ack", payload) + } + + pub(super) fn snapshot(&mut self, snapshot: Value) -> Value { + self.envelope("snapshot", snapshot) + } + + pub(super) fn broadcast(&mut self, broadcast: &ObsBroadcast) -> Value { + match broadcast { + ObsBroadcast::Snapshot(snapshot) => self.envelope("snapshot", snapshot.clone()), + ObsBroadcast::TauriEvent { event, data } => self.envelope( + "tauri_event", + serde_json::json!({ "event": event, "data": data }), + ), + ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"), + } + } + + pub(super) fn pong(&mut self) -> Value { + self.envelope("pong", Value::Null) + } + + pub(super) fn invoke_response(&mut self, request_id: String, result: RpcResult) -> Value { + let payload = match result { + Ok(data) => serde_json::json!({ "requestId": request_id, "result": data }), + Err(err) => serde_json::json!({ "requestId": request_id, "error": err }), + }; + self.envelope("invoke_response", payload) + } + + pub(super) fn ping(&mut self) -> Value { + self.envelope("ping", Value::Null) + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/services/obs_bridge/websocket/tests.rs b/src-tauri/src/services/obs_bridge/websocket/tests.rs new file mode 100644 index 000000000..fa95dc180 --- /dev/null +++ b/src-tauri/src/services/obs_bridge/websocket/tests.rs @@ -0,0 +1,85 @@ +use super::*; + +fn assert_envelope(envelope: &Value, msg_type: &str, sequence: u64, payload: &Value) { + assert_eq!(envelope["type"], msg_type); + assert_eq!(envelope["seq"], sequence); + assert_eq!(&envelope["payload"], payload); +} + +#[test] +fn session_protocol_projects_every_envelope_with_one_monotonic_sequence() { + let mut protocol = WebSocketSessionProtocol::new(); + + let ack = protocol.hello_ack("2.0.1".to_string()); + assert_eq!(ack["type"], "hello_ack"); + assert_eq!(ack["seq"], 0); + assert_eq!(ack["payload"]["serverVersion"], "2.0.1"); + assert_eq!(ack["payload"]["obsMode"], true); + assert_eq!( + ack["payload"]["allowedList"], + serde_json::to_value(build_allowed_list()).expect("allowlist 직렬화 실패") + ); + + let snapshot_payload = serde_json::json!({ "revision": 1 }); + let snapshot = protocol.snapshot(snapshot_payload.clone()); + assert_envelope(&snapshot, "snapshot", 1, &snapshot_payload); + + let broadcast_snapshot_payload = serde_json::json!({ "revision": 2 }); + let broadcast_snapshot = + protocol.broadcast(&ObsBroadcast::Snapshot(broadcast_snapshot_payload.clone())); + assert_envelope( + &broadcast_snapshot, + "snapshot", + 2, + &broadcast_snapshot_payload, + ); + + let event = protocol.broadcast(&ObsBroadcast::TauriEvent { + event: "settings:changed".to_string(), + data: serde_json::json!({ "theme": "dark" }), + }); + assert_envelope( + &event, + "tauri_event", + 3, + &serde_json::json!({ + "event": "settings:changed", + "data": { "theme": "dark" }, + }), + ); + + let pong = protocol.pong(); + assert_envelope(&pong, "pong", 4, &Value::Null); + + let success = protocol.invoke_response("success".to_string(), Ok(serde_json::json!(3))); + assert_envelope( + &success, + "invoke_response", + 5, + &serde_json::json!({ "requestId": "success", "result": 3 }), + ); + + let error = protocol.invoke_response( + "error".to_string(), + Err(serde_json::json!({ "code": "FAILED" })), + ); + assert_envelope( + &error, + "invoke_response", + 6, + &serde_json::json!({ + "requestId": "error", + "error": { "code": "FAILED" }, + }), + ); + + let ping = protocol.ping(); + assert_envelope(&ping, "ping", 7, &Value::Null); +} + +#[test] +#[should_panic(expected = "Shutdown은 직접 처리됨")] +fn shutdown_broadcast_remains_unreachable_in_projection() { + let mut protocol = WebSocketSessionProtocol::new(); + let _ = protocol.broadcast(&ObsBroadcast::Shutdown); +} diff --git a/src-tauri/src/services/overlay_hit.rs b/src-tauri/src/services/overlay_hit.rs index e47eafe26..82f70a893 100644 --- a/src-tauri/src/services/overlay_hit.rs +++ b/src-tauri/src/services/overlay_hit.rs @@ -520,1304 +520,16 @@ fn clip_hit_rect_to_bounds( } #[cfg(target_os = "macos")] -mod platform { - use std::{ - os::raw::c_void, - panic::{catch_unwind, AssertUnwindSafe}, - }; - - use anyhow::{anyhow, Context, Result}; - use cocoa::{ - appkit::{NSBackingStoreBuffered, NSEvent, NSEventType}, - base::{id, nil, BOOL, NO, YES}, - foundation::{NSInteger, NSPoint, NSRect, NSSize}, - }; - use objc::{ - class, - declare::ClassDecl, - msg_send, - runtime::{Class, Object, Sel}, - sel, sel_impl, - }; - use once_cell::sync::Lazy; - use tauri::{AppHandle, Emitter, WebviewWindow}; - - use super::{ - clip_hit_rect_to_bounds, hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, - OverlayHitDesiredState, OverlayHitRect, OVERLAY_LABEL, - }; - - const HIT_CONTEXT_IVAR: &str = "dmNoteOverlayHitContext"; - const NS_NONACTIVATING_PANEL_MASK: u64 = 1 << 7; - const NS_WINDOW_ABOVE: i64 = 1; - - #[derive(Default)] - pub(super) struct NativeState { - panels: Vec, - } - - struct HitPanel { - panel: usize, - view: usize, - context: Box, - } - - struct HitContext { - app: AppHandle, - parent: usize, - rect: OverlayHitRect, - active: bool, - } - - struct ObjectiveCClass(&'static Class); - - unsafe impl Send for ObjectiveCClass {} - unsafe impl Sync for ObjectiveCClass {} - - static HIT_PANEL_CLASS: Lazy = Lazy::new(|| unsafe { - if let Some(class) = Class::get("DmNoteOverlayHitPanel") { - return ObjectiveCClass(class); - } - let superclass = class!(NSPanel); - let mut declaration = - ClassDecl::new("DmNoteOverlayHitPanel", superclass).expect("hit panel class"); - declaration.add_method( - sel!(canBecomeKeyWindow), - never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, - ); - declaration.add_method( - sel!(canBecomeMainWindow), - never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, - ); - ObjectiveCClass(declaration.register()) - }); - - static HIT_VIEW_CLASS: Lazy = Lazy::new(|| unsafe { - if let Some(class) = Class::get("DmNoteOverlayHitView") { - return ObjectiveCClass(class); - } - let superclass = class!(NSView); - let mut declaration = - ClassDecl::new("DmNoteOverlayHitView", superclass).expect("hit view class"); - declaration.add_method( - sel!(acceptsFirstMouse:), - accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, - ); - declaration.add_method( - sel!(acceptsFirstResponder), - never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, - ); - declaration.add_method( - sel!(mouseDown:), - mouse_down as extern "C" fn(&Object, Sel, id), - ); - declaration.add_method( - sel!(rightMouseUp:), - right_mouse_up as extern "C" fn(&Object, Sel, id), - ); - declaration.add_method( - sel!(resetCursorRects), - reset_cursor_rects as extern "C" fn(&Object, Sel), - ); - declaration.add_ivar::<*mut c_void>(HIT_CONTEXT_IVAR); - ObjectiveCClass(declaration.register()) - }); - - extern "C" fn never_becomes_key(_this: &Object, _selector: Sel) -> BOOL { - NO - } - - extern "C" fn accepts_first_mouse(_this: &Object, _selector: Sel, _event: id) -> BOOL { - YES - } - - extern "C" fn mouse_down(this: &Object, _selector: Sel, event: id) { - let _ = catch_unwind(AssertUnwindSafe(|| unsafe { - let cursor: id = msg_send![class!(NSCursor), closedHandCursor]; - let _: () = msg_send![cursor, set]; - // performWindowDragWithEvent는 모달 이벤트 루프를 돈다 - 그 사이 창 이동이 - // reconcile을 재진입시켜 이 컨텍스트를 덮어쓰거나 패널을 해제할 수 있으므로 - // 필요한 값만 복사해 참조를 놓고 호출한다 - let drag_target = hit_context(this) - .filter(|context| context.active) - .map(|context| (context.parent, context.rect)); - if let Some((parent, rect)) = drag_target { - if let Err(error) = start_parent_drag(parent, rect, event) { - log::warn!("failed to start macOS overlay hit drag: {error:#}"); - } - } - let cursor: id = msg_send![class!(NSCursor), openHandCursor]; - let _: () = msg_send![cursor, set]; - })); - } - - extern "C" fn right_mouse_up(this: &Object, _selector: Sel, event: id) { - let _ = catch_unwind(AssertUnwindSafe(|| unsafe { - let Some(context) = hit_context(this) else { - return; - }; - if !context.active { - return; - } - let location: NSPoint = msg_send![event, locationInWindow]; - let payload = OverlayHitContextMenuPayload { - x: context.rect.x + location.x, - y: context.rect.y + context.rect.height - location.y, - }; - if let Err(error) = - context - .app - .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) - { - log::warn!("failed to emit macOS overlay hit context menu: {error}"); - } - })); - } - - extern "C" fn reset_cursor_rects(this: &Object, _selector: Sel) { - unsafe { - let bounds: NSRect = msg_send![this, bounds]; - let cursor: id = msg_send![class!(NSCursor), openHandCursor]; - let _: () = msg_send![this, addCursorRect: bounds cursor: cursor]; - } - } - - unsafe fn hit_context(this: &Object) -> Option<&HitContext> { - let context: *mut c_void = *this.get_ivar(HIT_CONTEXT_IVAR); - context.cast::().as_ref() - } - - unsafe fn start_parent_drag( - parent: usize, - rect: OverlayHitRect, - source_event: id, - ) -> Result<()> { - if source_event.is_null() || parent == 0 { - return Err(anyhow!("overlay hit drag context is unavailable")); - } - let parent = parent as id; - let content_view: id = msg_send![parent, contentView]; - if content_view.is_null() { - return Err(anyhow!("overlay content view is unavailable")); - } - - let panel_location: NSPoint = msg_send![source_event, locationInWindow]; - let client_x = rect.x + panel_location.x; - let client_y = rect.y + rect.height - panel_location.y; - let content_bounds: NSRect = msg_send![content_view, bounds]; - let content_location = NSPoint::new(client_x, content_bounds.size.height - client_y); - let window_location: NSPoint = - msg_send![content_view, convertPoint: content_location toView: nil]; - let target_window_number: NSInteger = msg_send![parent, windowNumber]; - let modifier_flags = NSEvent::modifierFlags(source_event); - let timestamp = NSEvent::timestamp(source_event); - let event_number = NSEvent::eventNumber(source_event); - let drag_event = NSEvent::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( - nil, - NSEventType::NSLeftMouseDown, - window_location, - modifier_flags, - timestamp, - target_window_number, - nil, - event_number, - 1, - 1.0, - ); - if drag_event.is_null() { - return Err(anyhow!("failed to synthesize overlay drag event")); - } - let _: () = msg_send![parent, performWindowDragWithEvent: drag_event]; - Ok(()) - } - - pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { - let Some(overlay) = overlay else { - return Ok(None); - }; - let parent = overlay - .ns_window() - .context("failed to get overlay NSWindow")? as id; - if parent.is_null() { - return Err(anyhow!("overlay NSWindow is unavailable")); - } - Ok(Some(parent as usize)) - } - - pub(super) fn reconcile( - app: &AppHandle, - overlay: Option<&WebviewWindow>, - desired: &OverlayHitDesiredState, - native: &mut NativeState, - ) -> Result { - let Some(overlay) = overlay else { - hide_panels(native); - return Ok(HitRegionStatus::Applied); - }; - let parent = overlay - .ns_window() - .context("failed to get overlay NSWindow")? as id; - if parent.is_null() { - hide_panels(native); - return Ok(HitRegionStatus::Applied); - } - - let parent_visible: BOOL = unsafe { msg_send![parent, isVisible] }; - // 프론트가 "키 0개"를 확정 보고했으면 콘텐츠 전체를 잡는다 - 모든 키를 숨기거나 - // 커스텀 CSS로 지웠을 때 창을 옮길 수도 우클릭할 수도 없게 되는 것을 막는다. - // 첫 측정 전(last_revision == None)에는 그대로 클릭 통과 - let clipped_rects = if desired.rects.is_empty() { - if desired.last_revision.is_some() { - let (content_width, content_height) = content_size(parent)?; - vec![OverlayHitRect { - x: 0.0, - y: 0.0, - width: content_width, - height: content_height, - }] - } else { - Vec::new() - } - } else { - let (content_width, content_height) = content_size(parent)?; - desired - .rects - .iter() - .filter_map(|rect| clip_hit_rect_to_bounds(*rect, content_width, content_height)) - .collect::>() - }; - let active = - desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_visible != NO; - if !active { - hide_panels(native); - if clipped_rects.is_empty() { - resize_panel_pool(app, parent, &clipped_rects, native)?; - } - if desired.visible && !desired.locked && parent_visible != NO { - return Ok(hit_region_status( - desired.rects.is_empty(), - clipped_rects.len(), - )); - } - return Ok(HitRegionStatus::Applied); - } - resize_panel_pool(app, parent, &clipped_rects, native)?; - - for (panel, rect) in native.panels.iter_mut().zip(&clipped_rects) { - panel.context.parent = parent as usize; - panel.context.rect = *rect; - let frame = panel_frame(parent, rect)?; - unsafe { - let panel_id = panel.panel as id; - let view_id = panel.view as id; - let current_parent: id = msg_send![panel_id, parentWindow]; - if current_parent != parent { - if !current_parent.is_null() { - let _: () = msg_send![current_parent, removeChildWindow: panel_id]; - } - let _: () = - msg_send![parent, addChildWindow: panel_id ordered: NS_WINDOW_ABOVE]; - } - let parent_level: NSInteger = msg_send![parent, level]; - let _: () = msg_send![panel_id, setLevel: parent_level]; - let _: () = msg_send![panel_id, setFrame: frame display: NO]; - let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO]; - let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id]; - panel.context.active = true; - let _: () = msg_send![panel_id, orderFront: nil]; - } - } - Ok(HitRegionStatus::Applied) - } - - fn content_size(parent: id) -> Result<(f64, f64)> { - unsafe { - let content_view: id = msg_send![parent, contentView]; - if content_view.is_null() { - return Err(anyhow!("overlay content view is unavailable")); - } - let content_bounds: NSRect = msg_send![content_view, bounds]; - Ok((content_bounds.size.width, content_bounds.size.height)) - } - } - - fn resize_panel_pool( - app: &AppHandle, - parent: id, - rects: &[OverlayHitRect], - native: &mut NativeState, - ) -> Result<()> { - while native.panels.len() > rects.len() { - if let Some(panel) = native.panels.pop() { - destroy_panel(panel); - } - } - while native.panels.len() < rects.len() { - let rect = rects[native.panels.len()]; - native.panels.push(create_panel(app, parent, rect)?); - } - Ok(()) - } - - fn create_panel(app: &AppHandle, parent: id, rect: OverlayHitRect) -> Result { - let frame = panel_frame(parent, &rect)?; - let mut context = Box::new(HitContext { - app: app.clone(), - parent: parent as usize, - rect, - active: false, - }); - unsafe { - let panel: id = msg_send![HIT_PANEL_CLASS.0, alloc]; - let panel: id = msg_send![panel, - initWithContentRect: frame - styleMask: NS_NONACTIVATING_PANEL_MASK - backing: NSBackingStoreBuffered - defer: NO - ]; - if panel.is_null() { - return Err(anyhow!("failed to create overlay hit NSPanel")); - } - - let view_frame = NSRect::new(NSPoint::new(0.0, 0.0), frame.size); - let view: id = msg_send![HIT_VIEW_CLASS.0, alloc]; - let view: id = msg_send![view, initWithFrame: view_frame]; - if view.is_null() { - let _: () = msg_send![panel, release]; - return Err(anyhow!("failed to create overlay hit NSView")); - } - let context_pointer = (&mut *context as *mut HitContext).cast::(); - (*view).set_ivar(HIT_CONTEXT_IVAR, context_pointer); - - let clear_color: id = msg_send![class!(NSColor), clearColor]; - let _: () = msg_send![panel, setOpaque: NO]; - let _: () = msg_send![panel, setBackgroundColor: clear_color]; - let _: () = msg_send![panel, setHasShadow: NO]; - let _: () = msg_send![panel, setReleasedWhenClosed: NO]; - let _: () = msg_send![panel, setHidesOnDeactivate: NO]; - let _: () = msg_send![panel, setIgnoresMouseEvents: NO]; - let _: () = msg_send![view, setAlphaValue: 0.0_f64]; - let _: () = msg_send![panel, setContentView: view]; - let _: () = msg_send![view, release]; - let _: () = msg_send![parent, addChildWindow: panel ordered: NS_WINDOW_ABOVE]; - - Ok(HitPanel { - panel: panel as usize, - view: view as usize, - context, - }) - } - } - - fn panel_frame(parent: id, rect: &OverlayHitRect) -> Result { - unsafe { - let content_view: id = msg_send![parent, contentView]; - if content_view.is_null() { - return Err(anyhow!("overlay content view is unavailable")); - } - let content_bounds: NSRect = msg_send![content_view, bounds]; - let local_frame = NSRect::new( - NSPoint::new(rect.x, content_bounds.size.height - rect.y - rect.height), - NSSize::new(rect.width, rect.height), - ); - let window_frame: NSRect = - msg_send![content_view, convertRect: local_frame toView: nil]; - let screen_frame: NSRect = msg_send![parent, convertRectToScreen: window_frame]; - Ok(screen_frame) - } - } - - fn hide_panels(native: &mut NativeState) { - for panel in &mut native.panels { - panel.context.parent = 0; - panel.context.active = false; - unsafe { - let panel_id = panel.panel as id; - let _: () = msg_send![panel_id, setIgnoresMouseEvents: YES]; - let _: () = msg_send![panel_id, orderOut: nil]; - } - } - } - - fn destroy_panel(panel: HitPanel) { - unsafe { - let panel_id = panel.panel as id; - let view_id = panel.view as id; - (*view_id).set_ivar(HIT_CONTEXT_IVAR, std::ptr::null_mut::()); - let parent: id = msg_send![panel_id, parentWindow]; - if !parent.is_null() { - let _: () = msg_send![parent, removeChildWindow: panel_id]; - } - let _: () = msg_send![panel_id, orderOut: nil]; - let _: () = msg_send![panel_id, close]; - let _: () = msg_send![panel_id, release]; - } - } - - #[cfg(test)] - pub(super) fn register_classes_for_test() { - Lazy::force(&HIT_PANEL_CLASS); - Lazy::force(&HIT_VIEW_CLASS); - } -} +#[path = "overlay_hit/platform/macos.rs"] +mod platform; #[cfg(target_os = "windows")] -mod platform { - use std::{ - ffi::c_void, - mem::size_of, - panic::{catch_unwind, AssertUnwindSafe}, - sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, - }; - - use anyhow::{anyhow, Context, Result}; - use parking_lot::RwLock; - use tauri::{AppHandle, Emitter, WebviewWindow}; - use windows::{ - core::{w, Error as WindowsError}, - Win32::{ - Foundation::{ - GetLastError, ERROR_CLASS_ALREADY_EXISTS, HINSTANCE, HWND, LPARAM, LRESULT, POINT, - RECT, WPARAM, - }, - Graphics::Gdi::{ - ClientToScreen, CombineRgn, CreateRectRgn, DeleteObject, SetWindowRgn, - ValidateRect, HGDIOBJ, RGN_OR, - }, - System::LibraryLoader::GetModuleHandleW, - UI::{ - HiDpi::GetDpiForWindow, - Input::KeyboardAndMouse::ReleaseCapture, - Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}, - WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DestroyWindow, GetClientRect, GetCursorPos, - GetWindowLongPtrW, IsWindow, IsWindowVisible, LoadCursorW, PostMessageW, - RegisterClassExW, SetCursor, SetWindowLongPtrW, SetWindowPos, ShowWindow, - CREATESTRUCTW, GWLP_USERDATA, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, - IDC_SIZEALL, MA_NOACTIVATE, SWP_NOACTIVATE, SWP_NOOWNERZORDER, SWP_SHOWWINDOW, - SW_HIDE, WM_CLOSE, WM_DPICHANGED, WM_ERASEBKGND, WM_LBUTTONDOWN, - WM_MOUSEACTIVATE, WM_NCCREATE, WM_NCDESTROY, WM_NCLBUTTONDOWN, WM_PAINT, - WM_RBUTTONUP, WM_SETCURSOR, WM_WINDOWPOSCHANGED, WNDCLASSEXW, WS_EX_NOACTIVATE, - WS_EX_NOREDIRECTIONBITMAP, WS_EX_TOOLWINDOW, WS_POPUP, - }, - }, - }, - }; - - use super::{ - hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, OverlayHitDesiredState, - OverlayHitRect, OVERLAY_LABEL, - }; - - const PARENT_SUBCLASS_ID: usize = 0x444d_4849; - #[derive(Default)] - pub(super) struct NativeState { - context: Option>, - } - - struct HitContext { - app: AppHandle, - parent: AtomicUsize, - hit: AtomicUsize, - rects: RwLock>, - // CSS px -> 물리 px 배율. f64 원자값이 없어 비트 패턴으로 보관 - device_pixel_ratio: AtomicU64, - active: AtomicBool, - always_on_top: AtomicBool, - } - - pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { - let Some(overlay) = overlay else { - return Ok(None); - }; - let parent = overlay.hwnd().context("failed to get overlay HWND")?; - Ok(Some(parent.0 as usize)) - } - - pub(super) fn reconcile( - app: &AppHandle, - overlay: Option<&WebviewWindow>, - desired: &OverlayHitDesiredState, - native: &mut NativeState, - ) -> Result { - let Some(overlay) = overlay else { - hide_native(native); - return Ok(HitRegionStatus::Applied); - }; - let parent = overlay.hwnd().context("failed to get overlay HWND")?; - ensure_native(app, parent, native)?; - let context = native - .context - .as_ref() - .ok_or_else(|| anyhow!("overlay hit HWND context is unavailable"))?; - *context.rects.write() = desired.rects.clone(); - context - .device_pixel_ratio - .store(desired.device_pixel_ratio.to_bits(), Ordering::Release); - // rect가 비어도 측정이 끝났으면 활성 - apply_hit_region이 클라이언트 전체를 잡는다 - context.active.store( - desired.visible && !desired.locked && desired.last_revision.is_some(), - Ordering::Release, - ); - context - .always_on_top - .store(desired.always_on_top, Ordering::Release); - unsafe { sync_hit_window(context) } - } - - fn ensure_native(app: &AppHandle, parent: HWND, native: &mut NativeState) -> Result<()> { - let needs_recreate = native.context.as_ref().is_none_or(|context| unsafe { - context.parent.load(Ordering::Acquire) != parent.0 as usize - || !IsWindow(Some(hwnd(context.hit.load(Ordering::Acquire)))).as_bool() - }); - if !needs_recreate { - return Ok(()); - } - destroy_native(native); - register_hit_window_class()?; - - let mut context = Box::new(HitContext { - app: app.clone(), - parent: AtomicUsize::new(parent.0 as usize), - hit: AtomicUsize::new(0), - rects: RwLock::new(Vec::new()), - device_pixel_ratio: AtomicU64::new(1.0f64.to_bits()), - active: AtomicBool::new(false), - always_on_top: AtomicBool::new(true), - }); - let context_pointer = (&mut *context as *mut HitContext).cast::(); - let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; - let hit = unsafe { - CreateWindowExW( - WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP, - w!("DmNoteOverlayHitWindow"), - w!(""), - WS_POPUP, - 0, - 0, - 0, - 0, - Some(parent), - None, - Some(HINSTANCE(module.0)), - Some(context_pointer.cast_const()), - ) - } - .context("failed to create overlay hit HWND")?; - context.hit.store(hit.0 as usize, Ordering::Release); - let installed = unsafe { - SetWindowSubclass( - parent, - Some(parent_subclass_proc), - PARENT_SUBCLASS_ID, - context_pointer as usize, - ) - }; - if !installed.as_bool() { - unsafe { - SetWindowLongPtrW(hit, GWLP_USERDATA, 0); - let _ = DestroyWindow(hit); - } - return Err(anyhow!("failed to subclass overlay HWND")); - } - native.context = Some(context); - Ok(()) - } - - fn register_hit_window_class() -> Result<()> { - let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; - let class = WNDCLASSEXW { - cbSize: size_of::() as u32, - lpfnWndProc: Some(hit_window_proc), - hInstance: HINSTANCE(module.0), - lpszClassName: w!("DmNoteOverlayHitWindow"), - ..Default::default() - }; - let atom = unsafe { RegisterClassExW(&class) }; - if atom == 0 && unsafe { GetLastError() } != ERROR_CLASS_ALREADY_EXISTS { - return Err(WindowsError::from_win32().into()); - } - Ok(()) - } - - unsafe extern "system" fn hit_window_proc( - window: HWND, - message: u32, - wparam: WPARAM, - lparam: LPARAM, - ) -> LRESULT { - catch_unwind(AssertUnwindSafe(|| unsafe { - if message == WM_NCCREATE { - let create = &*(lparam.0 as *const CREATESTRUCTW); - let context = create.lpCreateParams as *mut HitContext; - SetWindowLongPtrW(window, GWLP_USERDATA, context as isize); - if let Some(context) = context.as_ref() { - context.hit.store(window.0 as usize, Ordering::Release); - } - } - - let context = GetWindowLongPtrW(window, GWLP_USERDATA) as *mut HitContext; - match message { - WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), - WM_LBUTTONDOWN => { - if let Some(context) = context - .as_ref() - .filter(|context| context.active.load(Ordering::Acquire)) - { - begin_parent_drag(context); - } - return LRESULT(0); - } - WM_RBUTTONUP => { - if let Some(context) = context - .as_ref() - .filter(|context| context.active.load(Ordering::Acquire)) - { - emit_context_menu(context, lparam); - } - return LRESULT(0); - } - WM_SETCURSOR => { - if let Ok(cursor) = LoadCursorW(None, IDC_SIZEALL) { - SetCursor(Some(cursor)); - return LRESULT(1); - } - } - // 작업 표시줄 그룹 종료 등 외부 WM_CLOSE로 히트 창이 사라지면 - // 재생성 트리거(reconcile)가 올 때까지 상호작용이 죽는다 - WM_CLOSE => return LRESULT(0), - WM_ERASEBKGND => return LRESULT(1), - WM_PAINT => { - let _ = ValidateRect(Some(window), None); - return LRESULT(0); - } - WM_NCDESTROY => { - if let Some(context) = context.as_ref() { - context.hit.store(0, Ordering::Release); - } - SetWindowLongPtrW(window, GWLP_USERDATA, 0); - } - _ => {} - } - DefWindowProcW(window, message, wparam, lparam) - })) - .unwrap_or_else(|_| DefWindowProcW(window, message, wparam, lparam)) - } - - unsafe extern "system" fn parent_subclass_proc( - window: HWND, - message: u32, - wparam: WPARAM, - lparam: LPARAM, - _subclass_id: usize, - reference_data: usize, - ) -> LRESULT { - catch_unwind(AssertUnwindSafe(|| unsafe { - let context = (reference_data as *mut HitContext).as_ref(); - if let Some(context) = context { - match message { - WM_DPICHANGED | WM_WINDOWPOSCHANGED => { - if let Err(error) = sync_hit_window(context) { - log::warn!("failed to follow overlay HWND: {error:#}"); - } - } - WM_NCDESTROY => { - context.parent.store(0, Ordering::Release); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - if IsWindow(Some(hit)).as_bool() { - let _ = ShowWindow(hit, SW_HIDE); - } - } - _ => {} - } - } - DefSubclassProc(window, message, wparam, lparam) - })) - .unwrap_or_else(|_| DefSubclassProc(window, message, wparam, lparam)) - } - - unsafe fn sync_hit_window(context: &HitContext) -> Result { - let parent = hwnd(context.parent.load(Ordering::Acquire)); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - if !IsWindow(Some(parent)).as_bool() || !IsWindow(Some(hit)).as_bool() { - return Ok(HitRegionStatus::Applied); - } - if !context.active.load(Ordering::Acquire) || !IsWindowVisible(parent).as_bool() { - let _ = ShowWindow(hit, SW_HIDE); - return Ok(HitRegionStatus::Applied); - } - - let mut client = RECT::default(); - GetClientRect(parent, &mut client).context("failed to read overlay client rect")?; - let width = client.right - client.left; - let height = client.bottom - client.top; - if width <= 0 || height <= 0 { - let _ = ShowWindow(hit, SW_HIDE); - return Ok(HitRegionStatus::Applied); - } - let mut origin = POINT::default(); - if !ClientToScreen(parent, &mut origin).as_bool() { - return Err(WindowsError::from_win32().into()); - } - - if !apply_hit_region(context, hit, width, height)? { - let _ = ShowWindow(hit, SW_HIDE); - return Ok(HitRegionStatus::FullyClipped); - } - let insert_after = if context.always_on_top.load(Ordering::Acquire) { - HWND_TOPMOST - } else { - HWND_NOTOPMOST - }; - SetWindowPos( - hit, - Some(insert_after), - origin.x, - origin.y, - width, - height, - SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_SHOWWINDOW, - ) - .context("failed to position overlay hit HWND")?; - Ok(HitRegionStatus::Applied) - } - - unsafe fn apply_hit_region( - context: &HitContext, - hit: HWND, - client_width: i32, - client_height: i32, - ) -> Result { - let union = CreateRectRgn(0, 0, 0, 0); - if union.0.is_null() { - return Err(WindowsError::from_win32().into()); - } - // 웹뷰 실측 배율(devicePixelRatio)을 쓴다 - WebView2 보정 줌이 곱해져 있어 - // GetDpiForWindow/96으로는 접근성 텍스트 배율 사용자에서 좌표가 어긋난다. - // (emit_context_menu의 dpi 나눗셈은 LogicalPosition 계약이라 별개 - 함께 바꾸지 말 것) - let scale = f64::from_bits(context.device_pixel_ratio.load(Ordering::Acquire)); - let scale = if scale.is_finite() && scale > 0.0 { - scale - } else { - f64::from(GetDpiForWindow(hwnd( - context.parent.load(Ordering::Acquire), - ))) / 96.0 - }; - let scale = if scale.is_finite() && scale > 0.0 { - scale - } else { - 1.0 - }; - let mut region_count = 0usize; - let measured_rects = context.rects.read(); - // 측정 결과가 통째로 비었는지와, 클리핑으로 전부 날아갔는지는 다르다. - // 후자는 리사이즈·DPI 변경 직후의 과도기라 창 전체를 잡으면 안 된다 - let measured_empty = measured_rects.is_empty(); - for rect in measured_rects.iter() { - let left = (rect.x * scale).floor().clamp(0.0, f64::from(client_width)) as i32; - let top = (rect.y * scale) - .floor() - .clamp(0.0, f64::from(client_height)) as i32; - let right = ((rect.x + rect.width) * scale) - .ceil() - .clamp(0.0, f64::from(client_width)) as i32; - let bottom = ((rect.y + rect.height) * scale) - .ceil() - .clamp(0.0, f64::from(client_height)) as i32; - if right <= left || bottom <= top { - continue; - } - let part = CreateRectRgn(left, top, right, bottom); - if part.0.is_null() { - let _ = DeleteObject(HGDIOBJ(union.0)); - return Err(WindowsError::from_win32().into()); - } - let _ = CombineRgn(Some(union), Some(union), Some(part), RGN_OR); - let _ = DeleteObject(HGDIOBJ(part.0)); - region_count += 1; - } - if region_count == 0 { - if !measured_empty { - // 옛 rect가 새 클라이언트 밖으로 밀린 과도기 - 다음 측정까지 숨긴다 - let _ = DeleteObject(HGDIOBJ(union.0)); - return Ok(false); - } - // 측정 결과가 "키 0개" - 창 전체를 잡아 이식 전 동작으로 폴백한다 - let full = CreateRectRgn(0, 0, client_width, client_height); - if full.0.is_null() { - let _ = DeleteObject(HGDIOBJ(union.0)); - return Err(WindowsError::from_win32().into()); - } - let _ = CombineRgn(Some(union), Some(union), Some(full), RGN_OR); - let _ = DeleteObject(HGDIOBJ(full.0)); - } - if SetWindowRgn(hit, Some(union), false) == 0 { - let _ = DeleteObject(HGDIOBJ(union.0)); - return Err(WindowsError::from_win32().into()); - } - Ok(hit_region_status(measured_empty, region_count) == HitRegionStatus::Applied) - } - - unsafe fn begin_parent_drag(context: &HitContext) { - let parent = hwnd(context.parent.load(Ordering::Acquire)); - if !IsWindow(Some(parent)).as_bool() { - return; - } - let mut cursor = POINT::default(); - if GetCursorPos(&mut cursor).is_err() { - return; - } - let _ = ReleaseCapture(); - let packed = ((cursor.y as u32 & 0xffff) << 16) | (cursor.x as u32 & 0xffff); - let _ = PostMessageW( - Some(parent), - WM_NCLBUTTONDOWN, - WPARAM(HTCAPTION as usize), - LPARAM(packed as isize), - ); - } - - unsafe fn emit_context_menu(context: &HitContext, lparam: LPARAM) { - let parent = hwnd(context.parent.load(Ordering::Acquire)); - let dpi = if IsWindow(Some(parent)).as_bool() { - GetDpiForWindow(parent).max(96) - } else { - 96 - }; - let scale = f64::from(dpi) / 96.0; - let x = f64::from(lparam.0 as i16) / scale; - let y = f64::from((lparam.0 >> 16) as i16) / scale; - let payload = OverlayHitContextMenuPayload { x, y }; - if let Err(error) = context - .app - .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) - { - log::warn!("failed to emit Windows overlay hit context menu: {error}"); - } - } - - fn hide_native(native: &mut NativeState) { - if let Some(context) = native.context.as_ref() { - context.active.store(false, Ordering::Release); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - unsafe { - if IsWindow(Some(hit)).as_bool() { - let _ = ShowWindow(hit, SW_HIDE); - } - } - } - } - - fn destroy_native(native: &mut NativeState) { - let Some(context) = native.context.take() else { - return; - }; - let parent = hwnd(context.parent.load(Ordering::Acquire)); - let hit = hwnd(context.hit.load(Ordering::Acquire)); - unsafe { - if IsWindow(Some(parent)).as_bool() { - let _ = - RemoveWindowSubclass(parent, Some(parent_subclass_proc), PARENT_SUBCLASS_ID); - } - if IsWindow(Some(hit)).as_bool() { - SetWindowLongPtrW(hit, GWLP_USERDATA, 0); - let _ = DestroyWindow(hit); - } - } - } - - fn hwnd(value: usize) -> HWND { - HWND(value as *mut c_void) - } -} +#[path = "overlay_hit/platform/windows.rs"] +mod platform; #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -mod platform { - use anyhow::Result; - use tauri::{AppHandle, WebviewWindow}; - - use super::{HitRegionStatus, OverlayHitDesiredState}; - - #[derive(Default)] - pub(super) struct NativeState; - - pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { - Ok(overlay.map(|_| 1)) - } - - pub(super) fn reconcile( - _app: &AppHandle, - _overlay: Option<&WebviewWindow>, - _desired: &OverlayHitDesiredState, - _native: &mut NativeState, - ) -> Result { - Ok(HitRegionStatus::Applied) - } -} +#[path = "overlay_hit/platform/fallback.rs"] +mod platform; #[cfg(test)] -mod tests { - use super::{ - clip_hit_rect_to_bounds, hit_region_status, probe_delay_ms, validate_hit_rects, - HitRegionStatus, OverlayHitDesiredState, OverlayHitRect, RegionSyncDecision, MAX_HIT_RECTS, - }; - use std::sync::Arc; - - fn desired_state() -> OverlayHitDesiredState { - OverlayHitDesiredState { - rects: Vec::new(), - device_pixel_ratio: 1.0, - last_revision: None, - parent: None, - visible: true, - locked: false, - always_on_top: true, - resync_epoch: 0, - renderer_session: None, - pending_resync: true, - probe_lease: Arc::new(()), - } - } - - // 보정 줌이 곱해진 실측 배율만 채택하고, 비정상값은 1.0으로 떨어뜨린다 - #[test] - fn device_pixel_ratio_is_adopted_and_sanitized() { - let mut desired = desired_state(); - - assert!(desired.apply_regions(vec![rect(1.0)], 1, 1.25).unwrap()); - assert_eq!(desired.device_pixel_ratio, 1.25); - - assert!(desired.apply_regions(vec![rect(2.0)], 2, f64::NAN).unwrap()); - assert_eq!(desired.device_pixel_ratio, 1.0); - - assert!(desired.apply_regions(vec![rect(3.0)], 3, 0.0).unwrap()); - assert_eq!(desired.device_pixel_ratio, 1.0); - } - - fn rect(x: f64) -> OverlayHitRect { - OverlayHitRect { - x, - y: 0.0, - width: 10.0, - height: 10.0, - } - } - - #[test] - fn hit_region_revision_only_accepts_newer_values() { - let mut desired = desired_state(); - assert!(desired.apply_regions(vec![rect(1.0)], 10, 1.0).unwrap()); - assert!(!desired.apply_regions(vec![rect(2.0)], 10, 1.0).unwrap()); - assert!(!desired - .apply_regions( - vec![OverlayHitRect { - x: f64::NAN, - y: 0.0, - width: 10.0, - height: 10.0, - }], - 9, - 1.0, - ) - .unwrap()); - assert_eq!(desired.rects, vec![rect(1.0)]); - assert!(desired - .apply_regions( - vec![OverlayHitRect { - x: f64::NAN, - y: 0.0, - width: 10.0, - height: 10.0, - }], - 11, - 1.0, - ) - .is_err()); - assert_eq!(desired.rects, vec![rect(1.0)]); - assert!(desired.apply_regions(vec![rect(4.0)], 11, 1.0).unwrap()); - assert_eq!(desired.rects, vec![rect(4.0)]); - } - - #[test] - fn parent_replacement_and_loss_are_invalidation_edges() { - let mut desired = desired_state(); - assert!(desired.apply_regions(vec![rect(0.0)], 1, 1.0).unwrap()); - assert!(desired.observe_parent(None)); - desired.invalidate(false).unwrap(); - - assert!(!desired.observe_parent(Some(10))); - assert!(desired.apply_regions(vec![rect(1.0)], 90, 1.0).unwrap()); - assert!(!desired.observe_parent(Some(10))); - - assert!(desired.observe_parent(Some(11))); - desired.invalidate(false).unwrap(); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - assert!(desired.apply_regions(vec![rect(2.0)], 1, 1.0).unwrap()); - - assert!(desired.observe_parent(None)); - desired.mark_parent_absent(); - desired.invalidate(true).unwrap(); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - assert!(!desired.observe_parent(None)); - assert!(!desired.observe_parent(Some(12))); - assert!(desired.apply_regions(vec![rect(3.0)], 1, 1.0).unwrap()); - assert!(desired.observe_parent(None)); - desired.invalidate(false).unwrap(); - assert!(!desired.observe_parent(None)); - } - - #[test] - fn matching_epoch_and_renderer_session_are_accepted() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.25, epoch, "renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::Applied); - assert!(decision.accepted()); - assert!(!desired.pending_resync); - assert_eq!(desired.rects, vec![rect(1.0)]); - } - - #[test] - fn renderer_session_mismatch_is_not_accepted_or_mutated() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "stale-renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::LeaseMismatch); - assert!(!decision.accepted()); - assert!(desired.pending_resync); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - } - - #[test] - fn epoch_mismatch_is_not_accepted_or_mutated() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch - 1, "renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::LeaseMismatch); - assert!(!decision.accepted()); - assert!(desired.pending_resync); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, None); - } - - #[test] - fn stale_revision_keeps_valid_lease_accepted() { - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - assert_eq!( - desired - .apply_renderer_regions(vec![rect(2.0)], 2, 1.0, epoch, "renderer") - .unwrap(), - RegionSyncDecision::Applied - ); - let decision = desired - .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "renderer") - .unwrap(); - - assert_eq!(decision, RegionSyncDecision::StaleRevision); - assert!(decision.accepted()); - assert!(!desired.pending_resync); - assert_eq!(desired.rects, vec![rect(2.0)]); - assert_eq!(desired.last_revision, Some(2)); - } - - #[test] - fn hidden_locked_and_unready_states_pause_without_clearing_pending() { - let mut desired = desired_state(); - assert!(!desired.can_probe()); - assert!(desired.pending_resync); - - desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - assert!(desired.can_probe()); - - desired.visible = false; - assert!(!desired.can_probe()); - assert!(desired.pending_resync); - - desired.visible = true; - desired.locked = true; - assert!(!desired.can_probe()); - assert!(desired.pending_resync); - - desired.locked = false; - assert!(desired.can_probe()); - } - - #[test] - fn measured_empty_and_fully_clipped_regions_remain_distinct() { - assert_eq!(hit_region_status(true, 0), HitRegionStatus::Applied); - assert_eq!(hit_region_status(false, 0), HitRegionStatus::FullyClipped); - assert_eq!(hit_region_status(false, 1), HitRegionStatus::Applied); - - let mut desired = desired_state(); - let epoch = desired - .renew_renderer_session("renderer".to_string()) - .unwrap(); - assert_eq!( - desired - .apply_renderer_regions(Vec::new(), 1, 1.0, epoch, "renderer") - .unwrap(), - RegionSyncDecision::Applied - ); - assert!(desired.rects.is_empty()); - assert_eq!(desired.last_revision, Some(1)); - assert!(!desired.pending_resync); - } - - #[test] - fn epoch_overflow_revokes_renderer_and_keeps_resync_pending() { - let mut desired = desired_state(); - desired.resync_epoch = u64::MAX; - desired.renderer_session = Some("renderer".to_string()); - desired.pending_resync = false; - - assert!(desired.invalidate(false).is_err()); - assert_eq!(desired.resync_epoch, u64::MAX); - assert_eq!(desired.renderer_session, None); - assert!(desired.pending_resync); - assert!(!desired.can_probe()); - } - - #[test] - fn probe_schedule_reaches_indefinite_five_second_interval() { - assert_eq!( - (0..8).map(probe_delay_ms).collect::>(), - vec![100, 250, 500, 1_000, 5_000, 5_000, 5_000, 5_000] - ); - } - - #[test] - fn hit_rect_clipping_uses_client_bounds_and_drops_empty_intersections() { - assert_eq!( - clip_hit_rect_to_bounds( - OverlayHitRect { - x: -5.0, - y: -10.0, - width: 20.0, - height: 30.0, - }, - 100.0, - 100.0, - ), - Some(OverlayHitRect { - x: 0.0, - y: 0.0, - width: 15.0, - height: 20.0, - }) - ); - assert_eq!( - clip_hit_rect_to_bounds( - OverlayHitRect { - x: 90.0, - y: 95.0, - width: 30.0, - height: 10.0, - }, - 100.0, - 100.0, - ), - Some(OverlayHitRect { - x: 90.0, - y: 95.0, - width: 10.0, - height: 5.0, - }) - ); - assert_eq!(clip_hit_rect_to_bounds(rect(110.0), 100.0, 100.0), None); - assert_eq!(clip_hit_rect_to_bounds(rect(1.0), 0.0, 100.0), None); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_hit_classes_register() { - super::platform::register_classes_for_test(); - } - - #[test] - fn hit_rect_validation_accepts_finite_positive_rects() { - assert!(validate_hit_rects(&[OverlayHitRect { - x: -10.0, - y: 20.0, - width: 30.0, - height: 40.0, - }]) - .is_ok()); - } - - #[test] - fn hit_rect_validation_rejects_non_positive_dimensions() { - assert!(validate_hit_rects(&[OverlayHitRect { - x: 0.0, - y: 0.0, - width: 0.0, - height: 10.0, - }]) - .is_err()); - } - - #[test] - fn hit_rect_validation_rejects_non_finite_values() { - for invalid in [ - OverlayHitRect { - x: f64::NAN, - y: 0.0, - width: 10.0, - height: 10.0, - }, - OverlayHitRect { - x: 0.0, - y: f64::INFINITY, - width: 10.0, - height: 10.0, - }, - OverlayHitRect { - x: 0.0, - y: 0.0, - width: f64::NEG_INFINITY, - height: 10.0, - }, - OverlayHitRect { - x: 0.0, - y: 0.0, - width: 10.0, - height: f64::NAN, - }, - ] { - assert!(validate_hit_rects(&[invalid]).is_err()); - } - } - - #[test] - fn hit_rect_validation_rejects_excessive_count() { - let rect = OverlayHitRect { - x: 0.0, - y: 0.0, - width: 10.0, - height: 10.0, - }; - assert!(validate_hit_rects(&vec![rect; MAX_HIT_RECTS + 1]).is_err()); - } -} +mod tests; diff --git a/src-tauri/src/services/overlay_hit/platform/fallback.rs b/src-tauri/src/services/overlay_hit/platform/fallback.rs new file mode 100644 index 000000000..56d5b324f --- /dev/null +++ b/src-tauri/src/services/overlay_hit/platform/fallback.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use tauri::{AppHandle, WebviewWindow}; + +use super::{HitRegionStatus, OverlayHitDesiredState}; + +#[derive(Default)] +pub(super) struct NativeState; + +pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { + Ok(overlay.map(|_| 1)) +} + +pub(super) fn reconcile( + _app: &AppHandle, + _overlay: Option<&WebviewWindow>, + _desired: &OverlayHitDesiredState, + _native: &mut NativeState, +) -> Result { + Ok(HitRegionStatus::Applied) +} diff --git a/src-tauri/src/services/overlay_hit/platform/macos.rs b/src-tauri/src/services/overlay_hit/platform/macos.rs new file mode 100644 index 000000000..698ac8078 --- /dev/null +++ b/src-tauri/src/services/overlay_hit/platform/macos.rs @@ -0,0 +1,431 @@ +use std::{ + os::raw::c_void, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use anyhow::{anyhow, Context, Result}; +use cocoa::{ + appkit::{NSBackingStoreBuffered, NSEvent, NSEventType}, + base::{id, nil, BOOL, NO, YES}, + foundation::{NSInteger, NSPoint, NSRect, NSSize}, +}; +use objc::{ + class, + declare::ClassDecl, + msg_send, + runtime::{Class, Object, Sel}, + sel, sel_impl, +}; +use once_cell::sync::Lazy; +use tauri::{AppHandle, Emitter, WebviewWindow}; + +use super::{ + clip_hit_rect_to_bounds, hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, + OverlayHitDesiredState, OverlayHitRect, OVERLAY_LABEL, +}; + +const HIT_CONTEXT_IVAR: &str = "dmNoteOverlayHitContext"; +const NS_NONACTIVATING_PANEL_MASK: u64 = 1 << 7; +const NS_WINDOW_ABOVE: i64 = 1; + +#[derive(Default)] +pub(super) struct NativeState { + panels: Vec, +} + +struct HitPanel { + panel: usize, + view: usize, + context: Box, +} + +struct HitContext { + app: AppHandle, + parent: usize, + rect: OverlayHitRect, + active: bool, +} + +struct ObjectiveCClass(&'static Class); + +unsafe impl Send for ObjectiveCClass {} +unsafe impl Sync for ObjectiveCClass {} + +static HIT_PANEL_CLASS: Lazy = Lazy::new(|| unsafe { + if let Some(class) = Class::get("DmNoteOverlayHitPanel") { + return ObjectiveCClass(class); + } + let superclass = class!(NSPanel); + let mut declaration = + ClassDecl::new("DmNoteOverlayHitPanel", superclass).expect("hit panel class"); + declaration.add_method( + sel!(canBecomeKeyWindow), + never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, + ); + declaration.add_method( + sel!(canBecomeMainWindow), + never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, + ); + ObjectiveCClass(declaration.register()) +}); + +static HIT_VIEW_CLASS: Lazy = Lazy::new(|| unsafe { + if let Some(class) = Class::get("DmNoteOverlayHitView") { + return ObjectiveCClass(class); + } + let superclass = class!(NSView); + let mut declaration = + ClassDecl::new("DmNoteOverlayHitView", superclass).expect("hit view class"); + declaration.add_method( + sel!(acceptsFirstMouse:), + accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + declaration.add_method( + sel!(acceptsFirstResponder), + never_becomes_key as extern "C" fn(&Object, Sel) -> BOOL, + ); + declaration.add_method( + sel!(mouseDown:), + mouse_down as extern "C" fn(&Object, Sel, id), + ); + declaration.add_method( + sel!(rightMouseUp:), + right_mouse_up as extern "C" fn(&Object, Sel, id), + ); + declaration.add_method( + sel!(resetCursorRects), + reset_cursor_rects as extern "C" fn(&Object, Sel), + ); + declaration.add_ivar::<*mut c_void>(HIT_CONTEXT_IVAR); + ObjectiveCClass(declaration.register()) +}); + +extern "C" fn never_becomes_key(_this: &Object, _selector: Sel) -> BOOL { + NO +} + +extern "C" fn accepts_first_mouse(_this: &Object, _selector: Sel, _event: id) -> BOOL { + YES +} + +extern "C" fn mouse_down(this: &Object, _selector: Sel, event: id) { + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + let cursor: id = msg_send![class!(NSCursor), closedHandCursor]; + let _: () = msg_send![cursor, set]; + // performWindowDragWithEvent는 모달 이벤트 루프를 돈다 - 그 사이 창 이동이 + // reconcile을 재진입시켜 이 컨텍스트를 덮어쓰거나 패널을 해제할 수 있으므로 + // 필요한 값만 복사해 참조를 놓고 호출한다 + let drag_target = hit_context(this) + .filter(|context| context.active) + .map(|context| (context.parent, context.rect)); + if let Some((parent, rect)) = drag_target { + if let Err(error) = start_parent_drag(parent, rect, event) { + log::warn!("failed to start macOS overlay hit drag: {error:#}"); + } + } + let cursor: id = msg_send![class!(NSCursor), openHandCursor]; + let _: () = msg_send![cursor, set]; + })); +} + +extern "C" fn right_mouse_up(this: &Object, _selector: Sel, event: id) { + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + let Some(context) = hit_context(this) else { + return; + }; + if !context.active { + return; + } + let location: NSPoint = msg_send![event, locationInWindow]; + let payload = OverlayHitContextMenuPayload { + x: context.rect.x + location.x, + y: context.rect.y + context.rect.height - location.y, + }; + if let Err(error) = context + .app + .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) + { + log::warn!("failed to emit macOS overlay hit context menu: {error}"); + } + })); +} + +extern "C" fn reset_cursor_rects(this: &Object, _selector: Sel) { + unsafe { + let bounds: NSRect = msg_send![this, bounds]; + let cursor: id = msg_send![class!(NSCursor), openHandCursor]; + let _: () = msg_send![this, addCursorRect: bounds cursor: cursor]; + } +} + +unsafe fn hit_context(this: &Object) -> Option<&HitContext> { + let context: *mut c_void = *this.get_ivar(HIT_CONTEXT_IVAR); + context.cast::().as_ref() +} + +unsafe fn start_parent_drag(parent: usize, rect: OverlayHitRect, source_event: id) -> Result<()> { + if source_event.is_null() || parent == 0 { + return Err(anyhow!("overlay hit drag context is unavailable")); + } + let parent = parent as id; + let content_view: id = msg_send![parent, contentView]; + if content_view.is_null() { + return Err(anyhow!("overlay content view is unavailable")); + } + + let panel_location: NSPoint = msg_send![source_event, locationInWindow]; + let client_x = rect.x + panel_location.x; + let client_y = rect.y + rect.height - panel_location.y; + let content_bounds: NSRect = msg_send![content_view, bounds]; + let content_location = NSPoint::new(client_x, content_bounds.size.height - client_y); + let window_location: NSPoint = + msg_send![content_view, convertPoint: content_location toView: nil]; + let target_window_number: NSInteger = msg_send![parent, windowNumber]; + let modifier_flags = NSEvent::modifierFlags(source_event); + let timestamp = NSEvent::timestamp(source_event); + let event_number = NSEvent::eventNumber(source_event); + let drag_event = NSEvent::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( + nil, + NSEventType::NSLeftMouseDown, + window_location, + modifier_flags, + timestamp, + target_window_number, + nil, + event_number, + 1, + 1.0, + ); + if drag_event.is_null() { + return Err(anyhow!("failed to synthesize overlay drag event")); + } + let _: () = msg_send![parent, performWindowDragWithEvent: drag_event]; + Ok(()) +} + +pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { + let Some(overlay) = overlay else { + return Ok(None); + }; + let parent = overlay + .ns_window() + .context("failed to get overlay NSWindow")? as id; + if parent.is_null() { + return Err(anyhow!("overlay NSWindow is unavailable")); + } + Ok(Some(parent as usize)) +} + +pub(super) fn reconcile( + app: &AppHandle, + overlay: Option<&WebviewWindow>, + desired: &OverlayHitDesiredState, + native: &mut NativeState, +) -> Result { + let Some(overlay) = overlay else { + hide_panels(native); + return Ok(HitRegionStatus::Applied); + }; + let parent = overlay + .ns_window() + .context("failed to get overlay NSWindow")? as id; + if parent.is_null() { + hide_panels(native); + return Ok(HitRegionStatus::Applied); + } + + let parent_visible: BOOL = unsafe { msg_send![parent, isVisible] }; + // 프론트가 "키 0개"를 확정 보고했으면 콘텐츠 전체를 잡는다 - 모든 키를 숨기거나 + // 커스텀 CSS로 지웠을 때 창을 옮길 수도 우클릭할 수도 없게 되는 것을 막는다. + // 첫 측정 전(last_revision == None)에는 그대로 클릭 통과 + let clipped_rects = if desired.rects.is_empty() { + if desired.last_revision.is_some() { + let (content_width, content_height) = content_size(parent)?; + vec![OverlayHitRect { + x: 0.0, + y: 0.0, + width: content_width, + height: content_height, + }] + } else { + Vec::new() + } + } else { + let (content_width, content_height) = content_size(parent)?; + desired + .rects + .iter() + .filter_map(|rect| clip_hit_rect_to_bounds(*rect, content_width, content_height)) + .collect::>() + }; + let active = + desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_visible != NO; + if !active { + hide_panels(native); + if clipped_rects.is_empty() { + resize_panel_pool(app, parent, &clipped_rects, native)?; + } + if desired.visible && !desired.locked && parent_visible != NO { + return Ok(hit_region_status( + desired.rects.is_empty(), + clipped_rects.len(), + )); + } + return Ok(HitRegionStatus::Applied); + } + resize_panel_pool(app, parent, &clipped_rects, native)?; + + for (panel, rect) in native.panels.iter_mut().zip(&clipped_rects) { + panel.context.parent = parent as usize; + panel.context.rect = *rect; + let frame = panel_frame(parent, rect)?; + unsafe { + let panel_id = panel.panel as id; + let view_id = panel.view as id; + let current_parent: id = msg_send![panel_id, parentWindow]; + if current_parent != parent { + if !current_parent.is_null() { + let _: () = msg_send![current_parent, removeChildWindow: panel_id]; + } + let _: () = msg_send![parent, addChildWindow: panel_id ordered: NS_WINDOW_ABOVE]; + } + let parent_level: NSInteger = msg_send![parent, level]; + let _: () = msg_send![panel_id, setLevel: parent_level]; + let _: () = msg_send![panel_id, setFrame: frame display: NO]; + let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO]; + let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id]; + panel.context.active = true; + let _: () = msg_send![panel_id, orderFront: nil]; + } + } + Ok(HitRegionStatus::Applied) +} + +fn content_size(parent: id) -> Result<(f64, f64)> { + unsafe { + let content_view: id = msg_send![parent, contentView]; + if content_view.is_null() { + return Err(anyhow!("overlay content view is unavailable")); + } + let content_bounds: NSRect = msg_send![content_view, bounds]; + Ok((content_bounds.size.width, content_bounds.size.height)) + } +} + +fn resize_panel_pool( + app: &AppHandle, + parent: id, + rects: &[OverlayHitRect], + native: &mut NativeState, +) -> Result<()> { + while native.panels.len() > rects.len() { + if let Some(panel) = native.panels.pop() { + destroy_panel(panel); + } + } + while native.panels.len() < rects.len() { + let rect = rects[native.panels.len()]; + native.panels.push(create_panel(app, parent, rect)?); + } + Ok(()) +} + +fn create_panel(app: &AppHandle, parent: id, rect: OverlayHitRect) -> Result { + let frame = panel_frame(parent, &rect)?; + let mut context = Box::new(HitContext { + app: app.clone(), + parent: parent as usize, + rect, + active: false, + }); + unsafe { + let panel: id = msg_send![HIT_PANEL_CLASS.0, alloc]; + let panel: id = msg_send![panel, + initWithContentRect: frame + styleMask: NS_NONACTIVATING_PANEL_MASK + backing: NSBackingStoreBuffered + defer: NO + ]; + if panel.is_null() { + return Err(anyhow!("failed to create overlay hit NSPanel")); + } + + let view_frame = NSRect::new(NSPoint::new(0.0, 0.0), frame.size); + let view: id = msg_send![HIT_VIEW_CLASS.0, alloc]; + let view: id = msg_send![view, initWithFrame: view_frame]; + if view.is_null() { + let _: () = msg_send![panel, release]; + return Err(anyhow!("failed to create overlay hit NSView")); + } + let context_pointer = (&mut *context as *mut HitContext).cast::(); + (*view).set_ivar(HIT_CONTEXT_IVAR, context_pointer); + + let clear_color: id = msg_send![class!(NSColor), clearColor]; + let _: () = msg_send![panel, setOpaque: NO]; + let _: () = msg_send![panel, setBackgroundColor: clear_color]; + let _: () = msg_send![panel, setHasShadow: NO]; + let _: () = msg_send![panel, setReleasedWhenClosed: NO]; + let _: () = msg_send![panel, setHidesOnDeactivate: NO]; + let _: () = msg_send![panel, setIgnoresMouseEvents: NO]; + let _: () = msg_send![view, setAlphaValue: 0.0_f64]; + let _: () = msg_send![panel, setContentView: view]; + let _: () = msg_send![view, release]; + let _: () = msg_send![parent, addChildWindow: panel ordered: NS_WINDOW_ABOVE]; + + Ok(HitPanel { + panel: panel as usize, + view: view as usize, + context, + }) + } +} + +fn panel_frame(parent: id, rect: &OverlayHitRect) -> Result { + unsafe { + let content_view: id = msg_send![parent, contentView]; + if content_view.is_null() { + return Err(anyhow!("overlay content view is unavailable")); + } + let content_bounds: NSRect = msg_send![content_view, bounds]; + let local_frame = NSRect::new( + NSPoint::new(rect.x, content_bounds.size.height - rect.y - rect.height), + NSSize::new(rect.width, rect.height), + ); + let window_frame: NSRect = msg_send![content_view, convertRect: local_frame toView: nil]; + let screen_frame: NSRect = msg_send![parent, convertRectToScreen: window_frame]; + Ok(screen_frame) + } +} + +fn hide_panels(native: &mut NativeState) { + for panel in &mut native.panels { + panel.context.parent = 0; + panel.context.active = false; + unsafe { + let panel_id = panel.panel as id; + let _: () = msg_send![panel_id, setIgnoresMouseEvents: YES]; + let _: () = msg_send![panel_id, orderOut: nil]; + } + } +} + +fn destroy_panel(panel: HitPanel) { + unsafe { + let panel_id = panel.panel as id; + let view_id = panel.view as id; + (*view_id).set_ivar(HIT_CONTEXT_IVAR, std::ptr::null_mut::()); + let parent: id = msg_send![panel_id, parentWindow]; + if !parent.is_null() { + let _: () = msg_send![parent, removeChildWindow: panel_id]; + } + let _: () = msg_send![panel_id, orderOut: nil]; + let _: () = msg_send![panel_id, close]; + let _: () = msg_send![panel_id, release]; + } +} + +#[cfg(test)] +pub(super) fn register_classes_for_test() { + Lazy::force(&HIT_PANEL_CLASS); + Lazy::force(&HIT_VIEW_CLASS); +} diff --git a/src-tauri/src/services/overlay_hit/platform/windows.rs b/src-tauri/src/services/overlay_hit/platform/windows.rs new file mode 100644 index 000000000..2f58a3a59 --- /dev/null +++ b/src-tauri/src/services/overlay_hit/platform/windows.rs @@ -0,0 +1,465 @@ +use std::{ + ffi::c_void, + mem::size_of, + panic::{catch_unwind, AssertUnwindSafe}, + sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, +}; + +use anyhow::{anyhow, Context, Result}; +use parking_lot::RwLock; +use tauri::{AppHandle, Emitter, WebviewWindow}; +use windows::{ + core::{w, Error as WindowsError}, + Win32::{ + Foundation::{ + GetLastError, ERROR_CLASS_ALREADY_EXISTS, HINSTANCE, HWND, LPARAM, LRESULT, POINT, + RECT, WPARAM, + }, + Graphics::Gdi::{ + ClientToScreen, CombineRgn, CreateRectRgn, DeleteObject, SetWindowRgn, ValidateRect, + HGDIOBJ, RGN_OR, + }, + System::LibraryLoader::GetModuleHandleW, + UI::{ + HiDpi::GetDpiForWindow, + Input::KeyboardAndMouse::ReleaseCapture, + Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}, + WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, GetClientRect, GetCursorPos, + GetWindowLongPtrW, IsWindow, IsWindowVisible, LoadCursorW, PostMessageW, + RegisterClassExW, SetCursor, SetWindowLongPtrW, SetWindowPos, ShowWindow, + CREATESTRUCTW, GWLP_USERDATA, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, IDC_SIZEALL, + MA_NOACTIVATE, SWP_NOACTIVATE, SWP_NOOWNERZORDER, SWP_SHOWWINDOW, SW_HIDE, + WM_CLOSE, WM_DPICHANGED, WM_ERASEBKGND, WM_LBUTTONDOWN, WM_MOUSEACTIVATE, + WM_NCCREATE, WM_NCDESTROY, WM_NCLBUTTONDOWN, WM_PAINT, WM_RBUTTONUP, WM_SETCURSOR, + WM_WINDOWPOSCHANGED, WNDCLASSEXW, WS_EX_NOACTIVATE, WS_EX_NOREDIRECTIONBITMAP, + WS_EX_TOOLWINDOW, WS_POPUP, + }, + }, + }, +}; + +use super::{ + hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, OverlayHitDesiredState, + OverlayHitRect, OVERLAY_LABEL, +}; + +const PARENT_SUBCLASS_ID: usize = 0x444d_4849; +#[derive(Default)] +pub(super) struct NativeState { + context: Option>, +} + +struct HitContext { + app: AppHandle, + parent: AtomicUsize, + hit: AtomicUsize, + rects: RwLock>, + // CSS px -> 물리 px 배율. f64 원자값이 없어 비트 패턴으로 보관 + device_pixel_ratio: AtomicU64, + active: AtomicBool, + always_on_top: AtomicBool, +} + +pub(super) fn parent_identity(overlay: Option<&WebviewWindow>) -> Result> { + let Some(overlay) = overlay else { + return Ok(None); + }; + let parent = overlay.hwnd().context("failed to get overlay HWND")?; + Ok(Some(parent.0 as usize)) +} + +pub(super) fn reconcile( + app: &AppHandle, + overlay: Option<&WebviewWindow>, + desired: &OverlayHitDesiredState, + native: &mut NativeState, +) -> Result { + let Some(overlay) = overlay else { + hide_native(native); + return Ok(HitRegionStatus::Applied); + }; + let parent = overlay.hwnd().context("failed to get overlay HWND")?; + ensure_native(app, parent, native)?; + let context = native + .context + .as_ref() + .ok_or_else(|| anyhow!("overlay hit HWND context is unavailable"))?; + *context.rects.write() = desired.rects.clone(); + context + .device_pixel_ratio + .store(desired.device_pixel_ratio.to_bits(), Ordering::Release); + // rect가 비어도 측정이 끝났으면 활성 - apply_hit_region이 클라이언트 전체를 잡는다 + context.active.store( + desired.visible && !desired.locked && desired.last_revision.is_some(), + Ordering::Release, + ); + context + .always_on_top + .store(desired.always_on_top, Ordering::Release); + unsafe { sync_hit_window(context) } +} + +fn ensure_native(app: &AppHandle, parent: HWND, native: &mut NativeState) -> Result<()> { + let needs_recreate = native.context.as_ref().is_none_or(|context| unsafe { + context.parent.load(Ordering::Acquire) != parent.0 as usize + || !IsWindow(Some(hwnd(context.hit.load(Ordering::Acquire)))).as_bool() + }); + if !needs_recreate { + return Ok(()); + } + destroy_native(native); + register_hit_window_class()?; + + let mut context = Box::new(HitContext { + app: app.clone(), + parent: AtomicUsize::new(parent.0 as usize), + hit: AtomicUsize::new(0), + rects: RwLock::new(Vec::new()), + device_pixel_ratio: AtomicU64::new(1.0f64.to_bits()), + active: AtomicBool::new(false), + always_on_top: AtomicBool::new(true), + }); + let context_pointer = (&mut *context as *mut HitContext).cast::(); + let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; + let hit = unsafe { + CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_NOREDIRECTIONBITMAP, + w!("DmNoteOverlayHitWindow"), + w!(""), + WS_POPUP, + 0, + 0, + 0, + 0, + Some(parent), + None, + Some(HINSTANCE(module.0)), + Some(context_pointer.cast_const()), + ) + } + .context("failed to create overlay hit HWND")?; + context.hit.store(hit.0 as usize, Ordering::Release); + let installed = unsafe { + SetWindowSubclass( + parent, + Some(parent_subclass_proc), + PARENT_SUBCLASS_ID, + context_pointer as usize, + ) + }; + if !installed.as_bool() { + unsafe { + SetWindowLongPtrW(hit, GWLP_USERDATA, 0); + let _ = DestroyWindow(hit); + } + return Err(anyhow!("failed to subclass overlay HWND")); + } + native.context = Some(context); + Ok(()) +} + +fn register_hit_window_class() -> Result<()> { + let module = unsafe { GetModuleHandleW(None) }.context("failed to get module handle")?; + let class = WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(hit_window_proc), + hInstance: HINSTANCE(module.0), + lpszClassName: w!("DmNoteOverlayHitWindow"), + ..Default::default() + }; + let atom = unsafe { RegisterClassExW(&class) }; + if atom == 0 && unsafe { GetLastError() } != ERROR_CLASS_ALREADY_EXISTS { + return Err(WindowsError::from_win32().into()); + } + Ok(()) +} + +unsafe extern "system" fn hit_window_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + catch_unwind(AssertUnwindSafe(|| unsafe { + if message == WM_NCCREATE { + let create = &*(lparam.0 as *const CREATESTRUCTW); + let context = create.lpCreateParams as *mut HitContext; + SetWindowLongPtrW(window, GWLP_USERDATA, context as isize); + if let Some(context) = context.as_ref() { + context.hit.store(window.0 as usize, Ordering::Release); + } + } + + let context = GetWindowLongPtrW(window, GWLP_USERDATA) as *mut HitContext; + match message { + WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize), + WM_LBUTTONDOWN => { + if let Some(context) = context + .as_ref() + .filter(|context| context.active.load(Ordering::Acquire)) + { + begin_parent_drag(context); + } + return LRESULT(0); + } + WM_RBUTTONUP => { + if let Some(context) = context + .as_ref() + .filter(|context| context.active.load(Ordering::Acquire)) + { + emit_context_menu(context, lparam); + } + return LRESULT(0); + } + WM_SETCURSOR => { + if let Ok(cursor) = LoadCursorW(None, IDC_SIZEALL) { + SetCursor(Some(cursor)); + return LRESULT(1); + } + } + // 작업 표시줄 그룹 종료 등 외부 WM_CLOSE로 히트 창이 사라지면 + // 재생성 트리거(reconcile)가 올 때까지 상호작용이 죽는다 + WM_CLOSE => return LRESULT(0), + WM_ERASEBKGND => return LRESULT(1), + WM_PAINT => { + let _ = ValidateRect(Some(window), None); + return LRESULT(0); + } + WM_NCDESTROY => { + if let Some(context) = context.as_ref() { + context.hit.store(0, Ordering::Release); + } + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + } + _ => {} + } + DefWindowProcW(window, message, wparam, lparam) + })) + .unwrap_or_else(|_| DefWindowProcW(window, message, wparam, lparam)) +} + +unsafe extern "system" fn parent_subclass_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + reference_data: usize, +) -> LRESULT { + catch_unwind(AssertUnwindSafe(|| unsafe { + let context = (reference_data as *mut HitContext).as_ref(); + if let Some(context) = context { + match message { + WM_DPICHANGED | WM_WINDOWPOSCHANGED => { + if let Err(error) = sync_hit_window(context) { + log::warn!("failed to follow overlay HWND: {error:#}"); + } + } + WM_NCDESTROY => { + context.parent.store(0, Ordering::Release); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + if IsWindow(Some(hit)).as_bool() { + let _ = ShowWindow(hit, SW_HIDE); + } + } + _ => {} + } + } + DefSubclassProc(window, message, wparam, lparam) + })) + .unwrap_or_else(|_| DefSubclassProc(window, message, wparam, lparam)) +} + +unsafe fn sync_hit_window(context: &HitContext) -> Result { + let parent = hwnd(context.parent.load(Ordering::Acquire)); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + if !IsWindow(Some(parent)).as_bool() || !IsWindow(Some(hit)).as_bool() { + return Ok(HitRegionStatus::Applied); + } + if !context.active.load(Ordering::Acquire) || !IsWindowVisible(parent).as_bool() { + let _ = ShowWindow(hit, SW_HIDE); + return Ok(HitRegionStatus::Applied); + } + + let mut client = RECT::default(); + GetClientRect(parent, &mut client).context("failed to read overlay client rect")?; + let width = client.right - client.left; + let height = client.bottom - client.top; + if width <= 0 || height <= 0 { + let _ = ShowWindow(hit, SW_HIDE); + return Ok(HitRegionStatus::Applied); + } + let mut origin = POINT::default(); + if !ClientToScreen(parent, &mut origin).as_bool() { + return Err(WindowsError::from_win32().into()); + } + + if !apply_hit_region(context, hit, width, height)? { + let _ = ShowWindow(hit, SW_HIDE); + return Ok(HitRegionStatus::FullyClipped); + } + let insert_after = if context.always_on_top.load(Ordering::Acquire) { + HWND_TOPMOST + } else { + HWND_NOTOPMOST + }; + SetWindowPos( + hit, + Some(insert_after), + origin.x, + origin.y, + width, + height, + SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_SHOWWINDOW, + ) + .context("failed to position overlay hit HWND")?; + Ok(HitRegionStatus::Applied) +} + +unsafe fn apply_hit_region( + context: &HitContext, + hit: HWND, + client_width: i32, + client_height: i32, +) -> Result { + let union = CreateRectRgn(0, 0, 0, 0); + if union.0.is_null() { + return Err(WindowsError::from_win32().into()); + } + // 웹뷰 실측 배율(devicePixelRatio)을 쓴다 - WebView2 보정 줌이 곱해져 있어 + // GetDpiForWindow/96으로는 접근성 텍스트 배율 사용자에서 좌표가 어긋난다. + // (emit_context_menu의 dpi 나눗셈은 LogicalPosition 계약이라 별개 - 함께 바꾸지 말 것) + let scale = f64::from_bits(context.device_pixel_ratio.load(Ordering::Acquire)); + let scale = if scale.is_finite() && scale > 0.0 { + scale + } else { + f64::from(GetDpiForWindow(hwnd( + context.parent.load(Ordering::Acquire), + ))) / 96.0 + }; + let scale = if scale.is_finite() && scale > 0.0 { + scale + } else { + 1.0 + }; + let mut region_count = 0usize; + let measured_rects = context.rects.read(); + // 측정 결과가 통째로 비었는지와, 클리핑으로 전부 날아갔는지는 다르다. + // 후자는 리사이즈·DPI 변경 직후의 과도기라 창 전체를 잡으면 안 된다 + let measured_empty = measured_rects.is_empty(); + for rect in measured_rects.iter() { + let left = (rect.x * scale).floor().clamp(0.0, f64::from(client_width)) as i32; + let top = (rect.y * scale) + .floor() + .clamp(0.0, f64::from(client_height)) as i32; + let right = ((rect.x + rect.width) * scale) + .ceil() + .clamp(0.0, f64::from(client_width)) as i32; + let bottom = ((rect.y + rect.height) * scale) + .ceil() + .clamp(0.0, f64::from(client_height)) as i32; + if right <= left || bottom <= top { + continue; + } + let part = CreateRectRgn(left, top, right, bottom); + if part.0.is_null() { + let _ = DeleteObject(HGDIOBJ(union.0)); + return Err(WindowsError::from_win32().into()); + } + let _ = CombineRgn(Some(union), Some(union), Some(part), RGN_OR); + let _ = DeleteObject(HGDIOBJ(part.0)); + region_count += 1; + } + if region_count == 0 { + if !measured_empty { + // 옛 rect가 새 클라이언트 밖으로 밀린 과도기 - 다음 측정까지 숨긴다 + let _ = DeleteObject(HGDIOBJ(union.0)); + return Ok(false); + } + // 측정 결과가 "키 0개" - 창 전체를 잡아 이식 전 동작으로 폴백한다 + let full = CreateRectRgn(0, 0, client_width, client_height); + if full.0.is_null() { + let _ = DeleteObject(HGDIOBJ(union.0)); + return Err(WindowsError::from_win32().into()); + } + let _ = CombineRgn(Some(union), Some(union), Some(full), RGN_OR); + let _ = DeleteObject(HGDIOBJ(full.0)); + } + if SetWindowRgn(hit, Some(union), false) == 0 { + let _ = DeleteObject(HGDIOBJ(union.0)); + return Err(WindowsError::from_win32().into()); + } + Ok(hit_region_status(measured_empty, region_count) == HitRegionStatus::Applied) +} + +unsafe fn begin_parent_drag(context: &HitContext) { + let parent = hwnd(context.parent.load(Ordering::Acquire)); + if !IsWindow(Some(parent)).as_bool() { + return; + } + let mut cursor = POINT::default(); + if GetCursorPos(&mut cursor).is_err() { + return; + } + let _ = ReleaseCapture(); + let packed = ((cursor.y as u32 & 0xffff) << 16) | (cursor.x as u32 & 0xffff); + let _ = PostMessageW( + Some(parent), + WM_NCLBUTTONDOWN, + WPARAM(HTCAPTION as usize), + LPARAM(packed as isize), + ); +} + +unsafe fn emit_context_menu(context: &HitContext, lparam: LPARAM) { + let parent = hwnd(context.parent.load(Ordering::Acquire)); + let dpi = if IsWindow(Some(parent)).as_bool() { + GetDpiForWindow(parent).max(96) + } else { + 96 + }; + let scale = f64::from(dpi) / 96.0; + let x = f64::from(lparam.0 as i16) / scale; + let y = f64::from((lparam.0 >> 16) as i16) / scale; + let payload = OverlayHitContextMenuPayload { x, y }; + if let Err(error) = context + .app + .emit_to(OVERLAY_LABEL, "overlay-hit-context-menu", payload) + { + log::warn!("failed to emit Windows overlay hit context menu: {error}"); + } +} + +fn hide_native(native: &mut NativeState) { + if let Some(context) = native.context.as_ref() { + context.active.store(false, Ordering::Release); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + unsafe { + if IsWindow(Some(hit)).as_bool() { + let _ = ShowWindow(hit, SW_HIDE); + } + } + } +} + +fn destroy_native(native: &mut NativeState) { + let Some(context) = native.context.take() else { + return; + }; + let parent = hwnd(context.parent.load(Ordering::Acquire)); + let hit = hwnd(context.hit.load(Ordering::Acquire)); + unsafe { + if IsWindow(Some(parent)).as_bool() { + let _ = RemoveWindowSubclass(parent, Some(parent_subclass_proc), PARENT_SUBCLASS_ID); + } + if IsWindow(Some(hit)).as_bool() { + SetWindowLongPtrW(hit, GWLP_USERDATA, 0); + let _ = DestroyWindow(hit); + } + } +} + +fn hwnd(value: usize) -> HWND { + HWND(value as *mut c_void) +} diff --git a/src-tauri/src/services/overlay_hit/tests.rs b/src-tauri/src/services/overlay_hit/tests.rs new file mode 100644 index 000000000..c4cbd7a6e --- /dev/null +++ b/src-tauri/src/services/overlay_hit/tests.rs @@ -0,0 +1,363 @@ +use super::{ + clip_hit_rect_to_bounds, hit_region_status, probe_delay_ms, validate_hit_rects, + HitRegionStatus, OverlayHitDesiredState, OverlayHitRect, RegionSyncDecision, MAX_HIT_RECTS, +}; +use std::sync::Arc; + +fn desired_state() -> OverlayHitDesiredState { + OverlayHitDesiredState { + rects: Vec::new(), + device_pixel_ratio: 1.0, + last_revision: None, + parent: None, + visible: true, + locked: false, + always_on_top: true, + resync_epoch: 0, + renderer_session: None, + pending_resync: true, + probe_lease: Arc::new(()), + } +} + +// 보정 줌이 곱해진 실측 배율만 채택하고, 비정상값은 1.0으로 떨어뜨린다 +#[test] +fn device_pixel_ratio_is_adopted_and_sanitized() { + let mut desired = desired_state(); + + assert!(desired.apply_regions(vec![rect(1.0)], 1, 1.25).unwrap()); + assert_eq!(desired.device_pixel_ratio, 1.25); + + assert!(desired.apply_regions(vec![rect(2.0)], 2, f64::NAN).unwrap()); + assert_eq!(desired.device_pixel_ratio, 1.0); + + assert!(desired.apply_regions(vec![rect(3.0)], 3, 0.0).unwrap()); + assert_eq!(desired.device_pixel_ratio, 1.0); +} + +fn rect(x: f64) -> OverlayHitRect { + OverlayHitRect { + x, + y: 0.0, + width: 10.0, + height: 10.0, + } +} + +#[test] +fn hit_region_revision_only_accepts_newer_values() { + let mut desired = desired_state(); + assert!(desired.apply_regions(vec![rect(1.0)], 10, 1.0).unwrap()); + assert!(!desired.apply_regions(vec![rect(2.0)], 10, 1.0).unwrap()); + assert!(!desired + .apply_regions( + vec![OverlayHitRect { + x: f64::NAN, + y: 0.0, + width: 10.0, + height: 10.0, + }], + 9, + 1.0, + ) + .unwrap()); + assert_eq!(desired.rects, vec![rect(1.0)]); + assert!(desired + .apply_regions( + vec![OverlayHitRect { + x: f64::NAN, + y: 0.0, + width: 10.0, + height: 10.0, + }], + 11, + 1.0, + ) + .is_err()); + assert_eq!(desired.rects, vec![rect(1.0)]); + assert!(desired.apply_regions(vec![rect(4.0)], 11, 1.0).unwrap()); + assert_eq!(desired.rects, vec![rect(4.0)]); +} + +#[test] +fn parent_replacement_and_loss_are_invalidation_edges() { + let mut desired = desired_state(); + assert!(desired.apply_regions(vec![rect(0.0)], 1, 1.0).unwrap()); + assert!(desired.observe_parent(None)); + desired.invalidate(false).unwrap(); + + assert!(!desired.observe_parent(Some(10))); + assert!(desired.apply_regions(vec![rect(1.0)], 90, 1.0).unwrap()); + assert!(!desired.observe_parent(Some(10))); + + assert!(desired.observe_parent(Some(11))); + desired.invalidate(false).unwrap(); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); + assert!(desired.apply_regions(vec![rect(2.0)], 1, 1.0).unwrap()); + + assert!(desired.observe_parent(None)); + desired.mark_parent_absent(); + desired.invalidate(true).unwrap(); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); + assert!(!desired.observe_parent(None)); + assert!(!desired.observe_parent(Some(12))); + assert!(desired.apply_regions(vec![rect(3.0)], 1, 1.0).unwrap()); + assert!(desired.observe_parent(None)); + desired.invalidate(false).unwrap(); + assert!(!desired.observe_parent(None)); +} + +#[test] +fn matching_epoch_and_renderer_session_are_accepted() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.25, epoch, "renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::Applied); + assert!(decision.accepted()); + assert!(!desired.pending_resync); + assert_eq!(desired.rects, vec![rect(1.0)]); +} + +#[test] +fn renderer_session_mismatch_is_not_accepted_or_mutated() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "stale-renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::LeaseMismatch); + assert!(!decision.accepted()); + assert!(desired.pending_resync); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); +} + +#[test] +fn epoch_mismatch_is_not_accepted_or_mutated() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch - 1, "renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::LeaseMismatch); + assert!(!decision.accepted()); + assert!(desired.pending_resync); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, None); +} + +#[test] +fn stale_revision_keeps_valid_lease_accepted() { + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + assert_eq!( + desired + .apply_renderer_regions(vec![rect(2.0)], 2, 1.0, epoch, "renderer") + .unwrap(), + RegionSyncDecision::Applied + ); + let decision = desired + .apply_renderer_regions(vec![rect(1.0)], 1, 1.0, epoch, "renderer") + .unwrap(); + + assert_eq!(decision, RegionSyncDecision::StaleRevision); + assert!(decision.accepted()); + assert!(!desired.pending_resync); + assert_eq!(desired.rects, vec![rect(2.0)]); + assert_eq!(desired.last_revision, Some(2)); +} + +#[test] +fn hidden_locked_and_unready_states_pause_without_clearing_pending() { + let mut desired = desired_state(); + assert!(!desired.can_probe()); + assert!(desired.pending_resync); + + desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + assert!(desired.can_probe()); + + desired.visible = false; + assert!(!desired.can_probe()); + assert!(desired.pending_resync); + + desired.visible = true; + desired.locked = true; + assert!(!desired.can_probe()); + assert!(desired.pending_resync); + + desired.locked = false; + assert!(desired.can_probe()); +} + +#[test] +fn measured_empty_and_fully_clipped_regions_remain_distinct() { + assert_eq!(hit_region_status(true, 0), HitRegionStatus::Applied); + assert_eq!(hit_region_status(false, 0), HitRegionStatus::FullyClipped); + assert_eq!(hit_region_status(false, 1), HitRegionStatus::Applied); + + let mut desired = desired_state(); + let epoch = desired + .renew_renderer_session("renderer".to_string()) + .unwrap(); + assert_eq!( + desired + .apply_renderer_regions(Vec::new(), 1, 1.0, epoch, "renderer") + .unwrap(), + RegionSyncDecision::Applied + ); + assert!(desired.rects.is_empty()); + assert_eq!(desired.last_revision, Some(1)); + assert!(!desired.pending_resync); +} + +#[test] +fn epoch_overflow_revokes_renderer_and_keeps_resync_pending() { + let mut desired = desired_state(); + desired.resync_epoch = u64::MAX; + desired.renderer_session = Some("renderer".to_string()); + desired.pending_resync = false; + + assert!(desired.invalidate(false).is_err()); + assert_eq!(desired.resync_epoch, u64::MAX); + assert_eq!(desired.renderer_session, None); + assert!(desired.pending_resync); + assert!(!desired.can_probe()); +} + +#[test] +fn probe_schedule_reaches_indefinite_five_second_interval() { + assert_eq!( + (0..8).map(probe_delay_ms).collect::>(), + vec![100, 250, 500, 1_000, 5_000, 5_000, 5_000, 5_000] + ); +} + +#[test] +fn hit_rect_clipping_uses_client_bounds_and_drops_empty_intersections() { + assert_eq!( + clip_hit_rect_to_bounds( + OverlayHitRect { + x: -5.0, + y: -10.0, + width: 20.0, + height: 30.0, + }, + 100.0, + 100.0, + ), + Some(OverlayHitRect { + x: 0.0, + y: 0.0, + width: 15.0, + height: 20.0, + }) + ); + assert_eq!( + clip_hit_rect_to_bounds( + OverlayHitRect { + x: 90.0, + y: 95.0, + width: 30.0, + height: 10.0, + }, + 100.0, + 100.0, + ), + Some(OverlayHitRect { + x: 90.0, + y: 95.0, + width: 10.0, + height: 5.0, + }) + ); + assert_eq!(clip_hit_rect_to_bounds(rect(110.0), 100.0, 100.0), None); + assert_eq!(clip_hit_rect_to_bounds(rect(1.0), 0.0, 100.0), None); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_hit_classes_register() { + super::platform::register_classes_for_test(); +} + +#[test] +fn hit_rect_validation_accepts_finite_positive_rects() { + assert!(validate_hit_rects(&[OverlayHitRect { + x: -10.0, + y: 20.0, + width: 30.0, + height: 40.0, + }]) + .is_ok()); +} + +#[test] +fn hit_rect_validation_rejects_non_positive_dimensions() { + assert!(validate_hit_rects(&[OverlayHitRect { + x: 0.0, + y: 0.0, + width: 0.0, + height: 10.0, + }]) + .is_err()); +} + +#[test] +fn hit_rect_validation_rejects_non_finite_values() { + for invalid in [ + OverlayHitRect { + x: f64::NAN, + y: 0.0, + width: 10.0, + height: 10.0, + }, + OverlayHitRect { + x: 0.0, + y: f64::INFINITY, + width: 10.0, + height: 10.0, + }, + OverlayHitRect { + x: 0.0, + y: 0.0, + width: f64::NEG_INFINITY, + height: 10.0, + }, + OverlayHitRect { + x: 0.0, + y: 0.0, + width: 10.0, + height: f64::NAN, + }, + ] { + assert!(validate_hit_rects(&[invalid]).is_err()); + } +} + +#[test] +fn hit_rect_validation_rejects_excessive_count() { + let rect = OverlayHitRect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + }; + assert!(validate_hit_rects(&vec![rect; MAX_HIT_RECTS + 1]).is_err()); +} diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs index 356bdc08f..057e64197 100644 --- a/src-tauri/src/services/preview_broker.rs +++ b/src-tauri/src/services/preview_broker.rs @@ -576,524 +576,4 @@ fn send_envelopes(recipients: &[Channel], envelopes: &[PreviewE } #[cfg(test)] -mod tests { - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; - - use tauri::ipc::InvokeResponseBody; - - use super::*; - - fn session_id() -> String { - Uuid::new_v4().to_string() - } - - fn request(session_id: &str, seq: u64) -> PreviewPublishRequest { - request_for_domain(session_id, seq, PreviewDomain::KeyPosition) - } - - fn request_for_domain( - session_id: &str, - seq: u64, - domain: PreviewDomain, - ) -> PreviewPublishRequest { - PreviewPublishRequest { - schema_version: PREVIEW_SCHEMA_VERSION, - session_id: session_id.to_string(), - seq, - kind: PreviewKind::Patch, - domain, - mode: "default".to_string(), - targets: vec![0], - patch: Map::from_iter([("width".to_string(), Value::from(80))]), - } - } - - fn envelope(label: &str, request: PreviewPublishRequest) -> PreviewEnvelope { - PreviewEnvelope { - schema_version: request.schema_version, - session_id: request.session_id, - seq: request.seq, - kind: request.kind, - source_label: label.to_string(), - domain: request.domain, - mode: request.mode, - targets: request.targets, - patch: request.patch, - } - } - - fn channel(counter: Arc) -> Channel { - Channel::new(move |_| { - counter.fetch_add(1, Ordering::SeqCst); - Ok(()) - }) - } - - fn recording_channel(messages: Arc>>) -> Channel { - Channel::new(move |body| { - let InvokeResponseBody::Json(json) = body else { - panic!("preview envelope must use a JSON channel body"); - }; - messages - .lock() - .push(serde_json::from_str(&json).expect("preview envelope is valid JSON")); - Ok(()) - }) - } - - fn subscribe(broker: &PreviewBroker, label: &str) -> Arc { - let counter = Arc::new(AtomicUsize::new(0)); - broker - .subscribe(label, channel(counter.clone())) - .expect("subscribe succeeds"); - counter - } - - #[test] - fn rejects_publish_from_non_owner() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - subscribe(&broker, "intruder"); - let session_id = session_id(); - - broker - .publish("owner", request(&session_id, 1)) - .expect("owner claims session"); - - assert!(broker - .publish("intruder", request(&session_id, 2)) - .unwrap_err() - .contains("another window")); - } - - #[test] - fn publish_injects_source_label_and_defaults_to_patch() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let messages = Arc::new(Mutex::new(Vec::new())); - broker - .subscribe("observer", recording_channel(messages.clone())) - .expect("observer subscribes"); - let session_id = session_id(); - let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ - "schemaVersion": PREVIEW_SCHEMA_VERSION, - "sessionId": session_id, - "seq": 1, - "domain": "keyPosition", - "mode": "default", - "targets": [0], - "patch": { "width": 80 } - })) - .expect("frontend request shape is accepted"); - - broker.publish("owner", request).expect("publish succeeds"); - - let messages = messages.lock(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].source_label, "owner"); - assert_eq!(messages[0].kind, PreviewKind::Patch); - } - - #[test] - fn image_transform_preview_fields_round_trip_through_channel() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let messages = Arc::new(Mutex::new(Vec::new())); - broker - .subscribe("observer", recording_channel(messages.clone())) - .expect("observer subscribes"); - let session_id = session_id(); - let expected_patch = serde_json::json!({ - "idleImageTransform": { - "offsetX": 12.5, - "offsetY": -4.0, - "rotation": 25.0, - "scale": 1.35 - }, - "activeImageTransform": { - "offsetX": -8.0, - "offsetY": 6.25, - "rotation": -15.0, - "scale": 0.75 - } - }); - let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ - "schemaVersion": PREVIEW_SCHEMA_VERSION, - "sessionId": session_id, - "seq": 1, - "domain": "keyPosition", - "mode": "default", - "targets": [0], - "patch": expected_patch - })) - .expect("nested image transform request is valid JSON"); - - broker - .publish("owner", request) - .expect("image transform preview publishes"); - - let messages = messages.lock(); - assert_eq!(messages.len(), 1); - assert_eq!( - messages[0].patch, - expected_patch.as_object().unwrap().clone() - ); - } - - #[test] - fn font_gradient_preview_fields_are_allowed_without_widening_the_allowlist() { - let session_id = session_id(); - let mut allowed = request(&session_id, 1); - allowed.patch = Map::from_iter([ - ( - "fontGradient".to_string(), - serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - ("activeFontGradient".to_string(), Value::Null), - ]); - validate_publish_request(&allowed).expect("font gradient preview fields are allowed"); - - let mut rejected = request(&session_id, 2); - rejected.patch = Map::from_iter([("fontPaint".to_string(), Value::Null)]); - assert_eq!( - validate_publish_request(&rejected).unwrap_err(), - "preview patch field 'fontPaint' is not allowed" - ); - } - - #[test] - fn note_glow_sync_paint_preview_field_is_allowed() { - let session_id = session_id(); - let mut allowed = request(&session_id, 1); - allowed.patch = Map::from_iter([("noteGlowSyncPaint".to_string(), Value::Bool(true))]); - - validate_publish_request(&allowed).expect("note glow paint sync preview field is allowed"); - } - - #[test] - fn malformed_gradient_preview_values_are_rejected_before_broadcast() { - let session_id = session_id(); - let cases: [(&str, Value); 6] = [ - // 객체 아님 - ("fontGradient", serde_json::json!("broken")), - // stops 누락 - ("activeFontGradient", serde_json::json!({ "angle": 45 })), - // angle이 숫자가 아님 - ( - "backgroundGradient", - serde_json::json!({ - "angle": "45", - "stops": [ - { "color": "#112233", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - // stop 1개 - ( - "borderGradient", - serde_json::json!({ - "angle": 45, - "stops": [{ "color": "#112233", "pos": 0 }] - }), - ), - // 빈 stop 색 - ( - "fontGradient", - serde_json::json!({ - "angle": 45, - "stops": [ - { "color": " ", "pos": 0 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - // pos 범위 밖 - ( - "activeBorderGradient", - serde_json::json!({ - "angle": 45, - "stops": [ - { "color": "#112233", "pos": 2 }, - { "color": "#445566", "pos": 1 } - ] - }), - ), - ]; - for (seq, (field, value)) in cases.into_iter().enumerate() { - let mut rejected = request(&session_id, seq as u64 + 1); - rejected.patch = Map::from_iter([(field.to_string(), value)]); - let error = validate_publish_request(&rejected) - .expect_err("malformed gradient preview must be rejected"); - assert!( - error.contains(&format!("preview field '{field}'")), - "unexpected error for {field}: {error}" - ); - } - } - - #[test] - fn forwards_every_position_preview_domain() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let messages = Arc::new(Mutex::new(Vec::new())); - broker - .subscribe("observer", recording_channel(messages.clone())) - .expect("observer subscribes"); - let session_id = session_id(); - let domains = [ - PreviewDomain::KeyPosition, - PreviewDomain::StatPosition, - PreviewDomain::GraphPosition, - PreviewDomain::KnobPosition, - ]; - - for (index, domain) in domains.iter().copied().enumerate() { - broker - .publish( - "owner", - request_for_domain(&session_id, index as u64 + 1, domain), - ) - .expect("supported domain publishes"); - } - - let forwarded = messages - .lock() - .iter() - .map(|message| message.domain) - .collect::>(); - assert_eq!(forwarded, domains); - } - - #[test] - fn rejects_non_monotonic_sequence() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 2)) - .expect("first publish succeeds"); - - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("monotonically")); - assert!(broker - .publish("owner", request(&session_id, 1)) - .unwrap_err() - .contains("monotonically")); - } - - #[test] - fn rejects_oversized_payload() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - let mut oversized = request(&session_id, 1); - oversized.patch.insert( - "displayText".to_string(), - Value::String("x".repeat(MAX_PREVIEW_BYTES)), - ); - - assert!(broker - .publish("owner", oversized) - .unwrap_err() - .contains("byte limit")); - } - - #[test] - fn rejects_patch_field_outside_allowlist() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - let mut invalid = request(&session_id, 1); - invalid - .patch - .insert("groupId".to_string(), Value::from("g1")); - - assert!(broker - .publish("owner", invalid) - .unwrap_err() - .contains("not allowed")); - } - - #[test] - fn resubscribe_cancels_previous_generation_sessions() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 1)) - .expect("publish succeeds"); - assert_eq!(observer_messages.load(Ordering::SeqCst), 1); - - subscribe(&broker, "owner"); - - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn committed_sessions_are_tombstoned_without_auxiliary_broadcast() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_ids = vec![session_id(), session_id()]; - for session_id in &session_ids { - broker - .publish("owner", request(session_id, 1)) - .expect("publish succeeds"); - } - - for session_id in &session_ids { - assert!(broker - .finish_committed_session("owner", session_id, false) - .expect("commit cleanup succeeds")); - } - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - for session_id in &session_ids { - assert!(broker - .publish("owner", request(session_id, 2)) - .unwrap_err() - .contains("already ended")); - } - } - - #[test] - fn no_op_commit_broadcasts_cancel_and_rejects_late_patch() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - - assert!(broker - .finish_committed_session("owner", &session_id, true) - .expect("commit cleanup succeeds")); - assert_eq!(observer_messages.load(Ordering::SeqCst), 1); - - assert!(broker - .publish("owner", request(&session_id, 1)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn cancel_before_first_publish_rejects_late_patch() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let session_id = session_id(); - - broker - .cancel("owner", &session_id) - .expect("early cancel succeeds"); - - assert!(broker - .publish("owner", request(&session_id, 1)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn destroyed_window_cancels_owned_sessions() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 1)) - .expect("publish succeeds"); - - broker.remove_label("owner"); - - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("already ended")); - } - - #[test] - fn history_gate_rejects_busy_preview_admission() { - let broker = PreviewBroker::default(); - let gate = Arc::clone(&broker.history_gate); - let operation_id = Uuid::new_v4().to_string(); - let barrier = gate.close(&operation_id).unwrap(); - - let subscribe_error = broker - .subscribe("owner", channel(Arc::new(AtomicUsize::new(0)))) - .unwrap_err(); - assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); - let publish_error = broker - .publish("owner", request(&session_id(), 1)) - .unwrap_err(); - assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); - drop(barrier); - } - - #[test] - fn preview_revalidates_admission_generation_after_broker_wait() { - let broker = PreviewBroker::default(); - subscribe(&broker, "owner"); - let gate = Arc::clone(&broker.history_gate); - - let subscribe_admission = gate.try_admit().unwrap(); - let first_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); - drop(first_barrier); - let subscribe_error = broker - .subscribe_after_admission( - "observer", - channel(Arc::new(AtomicUsize::new(0))), - subscribe_admission, - ) - .unwrap_err(); - assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); - - let publish_admission = gate.try_admit().unwrap(); - let second_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); - drop(second_barrier); - let session_id = session_id(); - let publish_error = broker - .publish_after_admission( - "owner", - envelope("owner", request(&session_id, 1)), - publish_admission, - ) - .unwrap_err(); - assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); - } - - #[test] - fn cancel_all_tombstones_every_active_session() { - let broker = PreviewBroker::default(); - let owner_messages = subscribe(&broker, "owner"); - let observer_messages = subscribe(&broker, "observer"); - let session_id = session_id(); - broker - .publish("owner", request(&session_id, 1)) - .expect("publish succeeds"); - assert_eq!(observer_messages.load(Ordering::SeqCst), 1); - - assert_eq!(broker.cancel_all(), 1); - assert_eq!(owner_messages.load(Ordering::SeqCst), 1); - assert_eq!(observer_messages.load(Ordering::SeqCst), 2); - assert!(broker.state.lock().sessions.is_empty()); - assert!(broker.state.lock().tombstones.contains(&session_id)); - assert!(broker - .publish("owner", request(&session_id, 2)) - .unwrap_err() - .contains("already ended")); - } -} +mod tests; diff --git a/src-tauri/src/services/preview_broker/tests.rs b/src-tauri/src/services/preview_broker/tests.rs new file mode 100644 index 000000000..76affd3be --- /dev/null +++ b/src-tauri/src/services/preview_broker/tests.rs @@ -0,0 +1,515 @@ +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +use tauri::ipc::InvokeResponseBody; + +use super::*; + +fn session_id() -> String { + Uuid::new_v4().to_string() +} + +fn request(session_id: &str, seq: u64) -> PreviewPublishRequest { + request_for_domain(session_id, seq, PreviewDomain::KeyPosition) +} + +fn request_for_domain(session_id: &str, seq: u64, domain: PreviewDomain) -> PreviewPublishRequest { + PreviewPublishRequest { + schema_version: PREVIEW_SCHEMA_VERSION, + session_id: session_id.to_string(), + seq, + kind: PreviewKind::Patch, + domain, + mode: "default".to_string(), + targets: vec![0], + patch: Map::from_iter([("width".to_string(), Value::from(80))]), + } +} + +fn envelope(label: &str, request: PreviewPublishRequest) -> PreviewEnvelope { + PreviewEnvelope { + schema_version: request.schema_version, + session_id: request.session_id, + seq: request.seq, + kind: request.kind, + source_label: label.to_string(), + domain: request.domain, + mode: request.mode, + targets: request.targets, + patch: request.patch, + } +} + +fn channel(counter: Arc) -> Channel { + Channel::new(move |_| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) +} + +fn recording_channel(messages: Arc>>) -> Channel { + Channel::new(move |body| { + let InvokeResponseBody::Json(json) = body else { + panic!("preview envelope must use a JSON channel body"); + }; + messages + .lock() + .push(serde_json::from_str(&json).expect("preview envelope is valid JSON")); + Ok(()) + }) +} + +fn subscribe(broker: &PreviewBroker, label: &str) -> Arc { + let counter = Arc::new(AtomicUsize::new(0)); + broker + .subscribe(label, channel(counter.clone())) + .expect("subscribe succeeds"); + counter +} + +#[test] +fn rejects_publish_from_non_owner() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + subscribe(&broker, "intruder"); + let session_id = session_id(); + + broker + .publish("owner", request(&session_id, 1)) + .expect("owner claims session"); + + assert!(broker + .publish("intruder", request(&session_id, 2)) + .unwrap_err() + .contains("another window")); +} + +#[test] +fn publish_injects_source_label_and_defaults_to_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id, + "seq": 1, + "domain": "keyPosition", + "mode": "default", + "targets": [0], + "patch": { "width": 80 } + })) + .expect("frontend request shape is accepted"); + + broker.publish("owner", request).expect("publish succeeds"); + + let messages = messages.lock(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].source_label, "owner"); + assert_eq!(messages[0].kind, PreviewKind::Patch); +} + +#[test] +fn image_transform_preview_fields_round_trip_through_channel() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let expected_patch = serde_json::json!({ + "idleImageTransform": { + "offsetX": 12.5, + "offsetY": -4.0, + "rotation": 25.0, + "scale": 1.35 + }, + "activeImageTransform": { + "offsetX": -8.0, + "offsetY": 6.25, + "rotation": -15.0, + "scale": 0.75 + } + }); + let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id, + "seq": 1, + "domain": "keyPosition", + "mode": "default", + "targets": [0], + "patch": expected_patch + })) + .expect("nested image transform request is valid JSON"); + + broker + .publish("owner", request) + .expect("image transform preview publishes"); + + let messages = messages.lock(); + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].patch, + expected_patch.as_object().unwrap().clone() + ); +} + +#[test] +fn font_gradient_preview_fields_are_allowed_without_widening_the_allowlist() { + let session_id = session_id(); + let mut allowed = request(&session_id, 1); + allowed.patch = Map::from_iter([ + ( + "fontGradient".to_string(), + serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + ("activeFontGradient".to_string(), Value::Null), + ]); + validate_publish_request(&allowed).expect("font gradient preview fields are allowed"); + + let mut rejected = request(&session_id, 2); + rejected.patch = Map::from_iter([("fontPaint".to_string(), Value::Null)]); + assert_eq!( + validate_publish_request(&rejected).unwrap_err(), + "preview patch field 'fontPaint' is not allowed" + ); +} + +#[test] +fn note_glow_sync_paint_preview_field_is_allowed() { + let session_id = session_id(); + let mut allowed = request(&session_id, 1); + allowed.patch = Map::from_iter([("noteGlowSyncPaint".to_string(), Value::Bool(true))]); + + validate_publish_request(&allowed).expect("note glow paint sync preview field is allowed"); +} + +#[test] +fn malformed_gradient_preview_values_are_rejected_before_broadcast() { + let session_id = session_id(); + let cases: [(&str, Value); 6] = [ + // 객체 아님 + ("fontGradient", serde_json::json!("broken")), + // stops 누락 + ("activeFontGradient", serde_json::json!({ "angle": 45 })), + // angle이 숫자가 아님 + ( + "backgroundGradient", + serde_json::json!({ + "angle": "45", + "stops": [ + { "color": "#112233", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + // stop 1개 + ( + "borderGradient", + serde_json::json!({ + "angle": 45, + "stops": [{ "color": "#112233", "pos": 0 }] + }), + ), + // 빈 stop 색 + ( + "fontGradient", + serde_json::json!({ + "angle": 45, + "stops": [ + { "color": " ", "pos": 0 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + // pos 범위 밖 + ( + "activeBorderGradient", + serde_json::json!({ + "angle": 45, + "stops": [ + { "color": "#112233", "pos": 2 }, + { "color": "#445566", "pos": 1 } + ] + }), + ), + ]; + for (seq, (field, value)) in cases.into_iter().enumerate() { + let mut rejected = request(&session_id, seq as u64 + 1); + rejected.patch = Map::from_iter([(field.to_string(), value)]); + let error = validate_publish_request(&rejected) + .expect_err("malformed gradient preview must be rejected"); + assert!( + error.contains(&format!("preview field '{field}'")), + "unexpected error for {field}: {error}" + ); + } +} + +#[test] +fn forwards_every_position_preview_domain() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let domains = [ + PreviewDomain::KeyPosition, + PreviewDomain::StatPosition, + PreviewDomain::GraphPosition, + PreviewDomain::KnobPosition, + ]; + + for (index, domain) in domains.iter().copied().enumerate() { + broker + .publish( + "owner", + request_for_domain(&session_id, index as u64 + 1, domain), + ) + .expect("supported domain publishes"); + } + + let forwarded = messages + .lock() + .iter() + .map(|message| message.domain) + .collect::>(); + assert_eq!(forwarded, domains); +} + +#[test] +fn rejects_non_monotonic_sequence() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 2)) + .expect("first publish succeeds"); + + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("monotonically")); + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("monotonically")); +} + +#[test] +fn rejects_oversized_payload() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut oversized = request(&session_id, 1); + oversized.patch.insert( + "displayText".to_string(), + Value::String("x".repeat(MAX_PREVIEW_BYTES)), + ); + + assert!(broker + .publish("owner", oversized) + .unwrap_err() + .contains("byte limit")); +} + +#[test] +fn rejects_patch_field_outside_allowlist() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut invalid = request(&session_id, 1); + invalid + .patch + .insert("groupId".to_string(), Value::from("g1")); + + assert!(broker + .publish("owner", invalid) + .unwrap_err() + .contains("not allowed")); +} + +#[test] +fn resubscribe_cancels_previous_generation_sessions() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + subscribe(&broker, "owner"); + + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn committed_sessions_are_tombstoned_without_auxiliary_broadcast() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_ids = vec![session_id(), session_id()]; + for session_id in &session_ids { + broker + .publish("owner", request(session_id, 1)) + .expect("publish succeeds"); + } + + for session_id in &session_ids { + assert!(broker + .finish_committed_session("owner", session_id, false) + .expect("commit cleanup succeeds")); + } + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + for session_id in &session_ids { + assert!(broker + .publish("owner", request(session_id, 2)) + .unwrap_err() + .contains("already ended")); + } +} + +#[test] +fn no_op_commit_broadcasts_cancel_and_rejects_late_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + + assert!(broker + .finish_committed_session("owner", &session_id, true) + .expect("commit cleanup succeeds")); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn cancel_before_first_publish_rejects_late_patch() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + + broker + .cancel("owner", &session_id) + .expect("early cancel succeeds"); + + assert!(broker + .publish("owner", request(&session_id, 1)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn destroyed_window_cancels_owned_sessions() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + + broker.remove_label("owner"); + + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); +} + +#[test] +fn history_gate_rejects_busy_preview_admission() { + let broker = PreviewBroker::default(); + let gate = Arc::clone(&broker.history_gate); + let operation_id = Uuid::new_v4().to_string(); + let barrier = gate.close(&operation_id).unwrap(); + + let subscribe_error = broker + .subscribe("owner", channel(Arc::new(AtomicUsize::new(0)))) + .unwrap_err(); + assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); + let publish_error = broker + .publish("owner", request(&session_id(), 1)) + .unwrap_err(); + assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); + drop(barrier); +} + +#[test] +fn preview_revalidates_admission_generation_after_broker_wait() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let gate = Arc::clone(&broker.history_gate); + + let subscribe_admission = gate.try_admit().unwrap(); + let first_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); + drop(first_barrier); + let subscribe_error = broker + .subscribe_after_admission( + "observer", + channel(Arc::new(AtomicUsize::new(0))), + subscribe_admission, + ) + .unwrap_err(); + assert_eq!(subscribe_error, "HISTORY_IN_PROGRESS"); + + let publish_admission = gate.try_admit().unwrap(); + let second_barrier = gate.close(&Uuid::new_v4().to_string()).unwrap(); + drop(second_barrier); + let session_id = session_id(); + let publish_error = broker + .publish_after_admission( + "owner", + envelope("owner", request(&session_id, 1)), + publish_admission, + ) + .unwrap_err(); + assert_eq!(publish_error, "HISTORY_IN_PROGRESS"); +} + +#[test] +fn cancel_all_tombstones_every_active_session() { + let broker = PreviewBroker::default(); + let owner_messages = subscribe(&broker, "owner"); + let observer_messages = subscribe(&broker, "observer"); + let session_id = session_id(); + broker + .publish("owner", request(&session_id, 1)) + .expect("publish succeeds"); + assert_eq!(observer_messages.load(Ordering::SeqCst), 1); + + assert_eq!(broker.cancel_all(), 1); + assert_eq!(owner_messages.load(Ordering::SeqCst), 1); + assert_eq!(observer_messages.load(Ordering::SeqCst), 2); + assert!(broker.state.lock().sessions.is_empty()); + assert!(broker.state.lock().tombstones.contains(&session_id)); + assert!(broker + .publish("owner", request(&session_id, 2)) + .unwrap_err() + .contains("already ended")); +} diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 9afd2313e..5816d8d72 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -25,10 +25,87 @@ use tauri::{ use tauri_runtime_wry::wry::dpi::{LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize}; use tokio::sync::oneshot; +mod audio_runtime; +mod counter_runtime; +mod css_runtime; +mod frontend_flush; +mod frontend_lifecycle; +mod keyboard_runtime; +mod lifecycle_runtime; +mod native_window; +mod overlay_runtime; +mod panel_runtime; +mod window_geometry; + +use css_runtime::collect_authorized_css_paths; +#[cfg(test)] +use css_runtime::global_css_watch_path; +use frontend_flush::{ + acknowledge_editor_flush_handshake, begin_history_gate_close, + emit_frontend_history_flush_released, frontend_history_mutation_blocked, + install_history_handshake, install_lifecycle_handshake, take_cancelable_editor_flush_handshake, + take_editor_flush_handshake, EditorFlushAcknowledge, EditorFlushCompletion, + EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction, FrontendHistoryFlushPhase, + LifecycleHandshakeInstall, EDITOR_FLUSH_HANDSHAKE_TIMEOUT, +}; +pub(crate) use frontend_flush::{ + FrontendHistoryFlushReady, HISTORY_FRONTEND_FLUSH_BUSY, HISTORY_FRONTEND_FLUSH_CANCELED, + HISTORY_FRONTEND_FLUSH_EMIT_FAILED, HISTORY_FRONTEND_FLUSH_INTERRUPTED, + HISTORY_FRONTEND_FLUSH_TIMEOUT, +}; +use keyboard_runtime::{ + bootstrap_keyboard_state, build_key_sound_binding_table, KeySoundBindingTable, + KeyboardDaemonTask, +}; +#[cfg(test)] +use keyboard_runtime::{ + canonical_hold_duration_ms, key_state_payload, next_keyboard_recovery_plan, + resolve_event_age_ms, should_recover_keyboard_daemon, KEYBOARD_DAEMON_STABLE_RUNTIME, + KEYBOARD_RECOVERY_DELAYS_MS, +}; +use lifecycle_runtime::{ + attach_main_window_close_handler, dispatch_remove_tray_icon, execute_frontend_lifecycle, + remove_tray_icon, tray_menu_labels, +}; +#[cfg(target_os = "macos")] +use native_window::apply_macos_overlay_fullscreen_behavior; +pub(crate) use native_window::fade_overlay_window; +use native_window::{apply_overlay_frame, hide_overlay_window, show_overlay_window}; +#[cfg(target_os = "windows")] +use native_window::{ + disable_system_context_menu, install_overlay_move_observer, + raise_panel_window_without_activation, +}; + +#[cfg(any(target_os = "windows", test))] +use window_geometry::applied_overlay_frame_from_native; +#[cfg(not(target_os = "macos"))] +use window_geometry::applied_overlay_frame_from_window; +#[cfg(not(target_os = "windows"))] +use window_geometry::convert_physical_bounds_to_logical; +#[cfg(all(target_os = "windows", not(test)))] +use window_geometry::OverlayRestoreSource; +use window_geometry::{ + applied_overlay_frame_from_placement, complete_overlay_scale_resolution, + flush_deferred_overlay_bounds, logical_position_to_native, main_window_content_origin, + main_window_logical_rect, main_window_native_rect, native_placement_from_window, + overlay_restore_window_scale, panel_bounds_sample_from_window, panel_client_to_outer_position, + persist_overlay_placement, persist_overlay_placement_from_window, resolve_overlay_placement, + resolve_panel_window_layout, AppliedOverlayFrame, MonitorData, NativePlacement, NativeRect, + OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayPosition, PanelBoundsChange, + PanelBoundsPersistenceController, PanelWindowLayout, ResolvedOverlayPlacement, +}; +#[cfg(test)] +use window_geometry::{ + apply_panel_bounds_change, changed_panel_max_height, next_overlay_placement_trust, + panel_bounds_from_sample, panel_height_bounds, panel_position_beside_main, + resolve_windows_overlay_placement, stored_overlay_bounds_for_persistence, MonitorSpec, + NativeRejectReason, OverlayRestoreSource, PanelBoundsPersistenceState, PanelBoundsSample, +}; +pub use window_geometry::{LogicalPoint, LogicalRect, PanelDragContext}; + use super::{ - history::{ - HistoryAdmissionGate, HistoryAdmissionLease, HistoryBarrierLease, HistoryBarrierWaiter, - }, + history::HistoryAdmissionLease, panel_drag::PanelDragController, plugin::{PluginAuthorityLease, PluginRuntimeAuthority}, store::{ @@ -76,9 +153,6 @@ const MIN_OVERLAY_DIMENSION: f64 = 100.0; // 넓은 배치에 트랙 높이를 크게 잡으면 이전 상한 2000에서 조용히 잘렸음 const MAX_OVERLAY_DIMENSION: f64 = 4096.0; -#[cfg(target_os = "macos")] -const OVERLAY_FRAME_APPLY_TIMEOUT_MS: u64 = 250; - fn clamp_overlay_dimension(value: f64) -> f64 { value .clamp(MIN_OVERLAY_DIMENSION, MAX_OVERLAY_DIMENSION) @@ -177,17 +251,8 @@ const PANEL_CLOSE_ACK_TIMEOUT: Duration = Duration::from_millis(1_500); const OVERLAY_MARGIN: f64 = 40.0; const OVERLAY_BOUNDS_DEBOUNCE_MS: u64 = 400; const OVERLAY_CREATION_LOCK_TIMEOUT: Duration = Duration::from_secs(10); -const EDITOR_FLUSH_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(5); const SHUTDOWN_WATCHDOG_EXIT_CODE: i32 = 1; -const MAX_INPUT_EVENT_AGE_MS: f64 = 10_000.0; -const KEYBOARD_DAEMON_STABLE_RUNTIME: Duration = Duration::from_secs(30); -const KEYBOARD_RECOVERY_DELAYS_MS: [u64; 5] = [250, 500, 1_000, 2_000, 4_000]; -pub(crate) const HISTORY_FRONTEND_FLUSH_BUSY: &str = "HISTORY_FRONTEND_FLUSH_BUSY"; -pub(crate) const HISTORY_FRONTEND_FLUSH_CANCELED: &str = "HISTORY_FRONTEND_FLUSH_CANCELED"; -pub(crate) const HISTORY_FRONTEND_FLUSH_EMIT_FAILED: &str = "HISTORY_FRONTEND_FLUSH_EMIT_FAILED"; -pub(crate) const HISTORY_FRONTEND_FLUSH_INTERRUPTED: &str = "HISTORY_FRONTEND_FLUSH_INTERRUPTED"; -pub(crate) const HISTORY_FRONTEND_FLUSH_TIMEOUT: &str = "HISTORY_FRONTEND_FLUSH_TIMEOUT"; pub(crate) const MUTATION_SHUTDOWN_STARTED: &str = "MUTATION_SHUTDOWN_STARTED"; struct ShutdownWatchdogState { @@ -195,66 +260,6 @@ struct ShutdownWatchdogState { stage: &'static str, } -#[derive(Debug, Clone, Copy, PartialEq)] -struct PanelBoundsSample { - position: PhysicalPosition, - position_scale_factor: f64, - size: PhysicalSize, - size_scale_factor: f64, - current_scale_factor: f64, -} - -#[derive(Debug, Clone, Copy)] -enum PanelBoundsChange { - Snapshot(PanelBoundsSample), - Moved(PhysicalPosition), - Resized(PhysicalSize), - ScaleFactorChanged { - position: Option>, - size: PhysicalSize, - scale_factor: f64, - }, -} - -impl PanelBoundsChange { - // 복원에 쓰는 값은 높이뿐이라 이동은 디스크로 가지 않는다. - // x/y는 store 호환을 위해 계속 기록만 되고 창 배치에는 쓰이지 않음 - fn changes_persisted_bounds(self) -> bool { - !matches!(self, Self::Moved(_)) - } -} - -#[derive(Default)] -struct PanelBoundsPersistenceState { - latest: Option, - window: Option, - applied_max_height: Option, - // 초기화로 발생한 resize가 비운 저장값을 되살리지 않게 하는 기본 높이 추적 - unpersisted_default_height: Option, - default_height_pending: bool, - session: u64, - generation: u64, - worker_running: bool, - // dirty는 워커가 처리할 변경이 남았는지, persist_dirty는 그중 저장까지 필요한지 - dirty: bool, - persist_dirty: bool, - active: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct PanelBoundsPersistWork { - session: u64, - generation: u64, - sample: PanelBoundsSample, - persist: bool, -} - -struct PanelBoundsPersistenceController { - store: Arc, - state: Mutex, - persist_lock: Mutex<()>, -} - #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) enum FrontendLifecycleAction { @@ -279,265 +284,6 @@ fn overlay_close_action(force_close: bool, lifecycle_pending: bool) -> OverlayCl } } -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -enum FrontendFlushAction { - Quit, - Restart, - History, -} - -impl From for FrontendFlushAction { - fn from(action: FrontendLifecycleAction) -> Self { - match action { - FrontendLifecycleAction::Quit => Self::Quit, - FrontendLifecycleAction::Restart => Self::Restart, - } - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct EditorFlushRequest { - handshake_id: String, - action: FrontendFlushAction, -} - -enum EditorFlushCompletion { - Lifecycle(FrontendLifecycleAction), - History { - operation_id: String, - sender: Option>>, - phase: FrontendHistoryFlushPhase, - barrier: Option, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FrontendHistoryFlushPhase { - Collecting, - Closing, - Running, -} - -impl EditorFlushCompletion { - fn is_lifecycle(&self) -> bool { - matches!(self, Self::Lifecycle(_)) - } - - fn is_history(&self) -> bool { - matches!(self, Self::History { .. }) - } - - fn history_phase(&self) -> Option { - match self { - Self::History { phase, .. } => Some(*phase), - Self::Lifecycle(_) => None, - } - } -} - -pub(crate) struct FrontendHistoryFlushReady { - barrier: Option, - complete: Option>, -} - -impl FrontendHistoryFlushReady { - pub(crate) fn take_barrier(&mut self) -> HistoryBarrierLease { - self.barrier - .take() - .expect("history flush barrier can only be taken once") - } -} - -impl Drop for FrontendHistoryFlushReady { - fn drop(&mut self) { - drop(self.barrier.take()); - if let Some(complete) = self.complete.take() { - complete(); - } - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct FrontendHistoryFlushReleased { - handshake_id: String, -} - -struct EditorFlushHandshake { - id: String, - completion: EditorFlushCompletion, - target_windows: HashSet, - pending_windows: HashSet, -} - -fn take_editor_flush_handshake( - slot: &mut Option, - handshake_id: &str, -) -> Option { - if slot - .as_ref() - .is_some_and(|active| active.id == handshake_id) - { - slot.take() - } else { - None - } -} - -fn take_cancelable_editor_flush_handshake( - slot: &mut Option, - handshake_id: &str, -) -> Option { - if slot.as_ref().is_some_and(|active| { - active.id == handshake_id - && active.completion.history_phase() != Some(FrontendHistoryFlushPhase::Running) - }) { - slot.take() - } else { - None - } -} - -enum EditorFlushAcknowledge { - LifecycleReady(EditorFlushHandshake), - HistoryClosing { - handshake_id: String, - waiter: HistoryBarrierWaiter, - }, - HistoryCloseFailed { - handshake: EditorFlushHandshake, - error: String, - }, -} - -fn acknowledge_editor_flush_handshake( - slot: &mut Option, - handshake_id: &str, - window_label: &str, - history_gate: &Arc, -) -> Option { - { - let active = slot.as_mut()?; - if active.id != handshake_id || !active.pending_windows.remove(window_label) { - return None; - } - if !active.pending_windows.is_empty() { - return None; - } - if active.completion.is_lifecycle() { - return slot.take().map(EditorFlushAcknowledge::LifecycleReady); - } - } - begin_history_gate_close(slot, handshake_id, history_gate) -} - -fn begin_history_gate_close( - slot: &mut Option, - handshake_id: &str, - history_gate: &Arc, -) -> Option { - let operation_id = match slot.as_ref() { - Some(EditorFlushHandshake { - id, - completion: - EditorFlushCompletion::History { - operation_id, - phase: FrontendHistoryFlushPhase::Collecting, - .. - }, - .. - }) if id == handshake_id => operation_id.clone(), - _ => return None, - }; - match history_gate.begin_close(&operation_id) { - Ok(next_barrier) => { - let waiter = next_barrier.waiter(); - let active = slot - .as_mut() - .expect("history handshake disappeared while closing gate"); - let EditorFlushCompletion::History { phase, barrier, .. } = &mut active.completion - else { - unreachable!("history handshake changed while closing gate"); - }; - *phase = FrontendHistoryFlushPhase::Closing; - *barrier = Some(next_barrier); - Some(EditorFlushAcknowledge::HistoryClosing { - handshake_id: handshake_id.to_string(), - waiter, - }) - } - Err(error) => slot - .take() - .map(|handshake| EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }), - } -} - -enum LifecycleHandshakeInstall { - Installed, - InterruptedHistory(Box), - LifecycleAlreadyActive, - DeferredUntilHistoryComplete, -} - -fn install_lifecycle_handshake( - slot: &mut Option, - next: EditorFlushHandshake, -) -> LifecycleHandshakeInstall { - if slot - .as_ref() - .is_some_and(|active| active.completion.is_lifecycle()) - { - return LifecycleHandshakeInstall::LifecycleAlreadyActive; - } - if slot.as_ref().is_some_and(|active| { - active.completion.history_phase() == Some(FrontendHistoryFlushPhase::Running) - }) { - return LifecycleHandshakeInstall::DeferredUntilHistoryComplete; - } - - match slot.replace(next) { - Some(interrupted) => LifecycleHandshakeInstall::InterruptedHistory(Box::new(interrupted)), - None => LifecycleHandshakeInstall::Installed, - } -} - -fn install_history_handshake( - slot: &mut Option, - next: EditorFlushHandshake, -) -> bool { - if slot.is_some() { - return false; - } - *slot = Some(next); - true -} - -fn frontend_history_mutation_blocked( - slot: &Option, - window_label: &str, -) -> bool { - slot.as_ref().is_some_and(|active| { - active.completion.is_history() && !active.pending_windows.contains(window_label) - }) -} - -fn emit_frontend_history_flush_released( - app_handle: &AppHandle, - handshake_id: &str, - target_windows: &HashSet, -) { - let payload = FrontendHistoryFlushReleased { - handshake_id: handshake_id.to_string(), - }; - for label in target_windows { - if let Err(error) = app_handle.emit_to(label, "app:history-flush-released", &payload) { - log::warn!("failed to release history flush lock for {label}: {error}"); - } - } -} - fn frontend_lifecycle_restore_labels(target_windows: &HashSet) -> Vec<&'static str> { FRONTEND_LIFECYCLE_WINDOW_LABELS .into_iter() @@ -654,46 +400,6 @@ struct RuntimePublicationState { key_sound_bindings_generation: u64, } -#[derive(Debug, Clone, PartialEq)] -struct KeySoundBinding { - sound_path: String, - per_key_volume: f32, -} - -type KeySoundBindingTable = HashMap>>; - -fn build_key_sound_binding_table(key_positions: &KeyPositions) -> KeySoundBindingTable { - key_positions - .iter() - .map(|(mode, positions)| { - let bindings = positions - .iter() - .map(|position| { - if !position.sound_enabled.unwrap_or(false) { - return None; - } - let sound_path = position.sound_path.as_deref()?.trim(); - if sound_path.is_empty() { - return None; - } - let volume_percent = position.sound_volume.unwrap_or(100.0); - Some(KeySoundBinding { - sound_path: sound_path.to_string(), - per_key_volume: (volume_percent / 100.0).clamp(0.0, 2.0) as f32, - }) - }) - .collect(); - (mode.clone(), bindings) - }) - .collect() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct KeyboardRecoveryPlan { - attempt: usize, - delay: Duration, -} - #[derive(Debug)] pub(crate) struct AdmittedCounterMutation { pub(crate) counters: KeyCounters, @@ -796,101 +502,6 @@ fn is_panel_open_url(url: &str) -> bool { url.is_empty() || url == "about:blank" } -fn bootstrap_keyboard_state(keyboard: &KeyboardManager) -> (String, Vec) { - keyboard.current_mode_and_pressed_keys() -} - -fn unix_epoch_ms() -> Option { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map(|duration| duration.as_secs_f64() * 1000.0) -} - -fn resolve_event_age_ms( - input_ts_ms: Option, - now_wall_ms: Option, - fallback_age_ms: f64, -) -> f64 { - let Some(event_age_ms) = input_ts_ms - .zip(now_wall_ms) - .map(|(input_ts_ms, now_wall_ms)| now_wall_ms - input_ts_ms) - else { - return fallback_age_ms; - }; - if event_age_ms.is_finite() && (0.0..=MAX_INPUT_EVENT_AGE_MS).contains(&event_age_ms) { - event_age_ms - } else { - fallback_age_ms - } -} - -fn next_keyboard_recovery_plan( - current_attempt: usize, - daemon_uptime: Duration, -) -> Option { - let attempt = if daemon_uptime >= KEYBOARD_DAEMON_STABLE_RUNTIME { - 1 - } else { - current_attempt.saturating_add(1) - }; - let delay_ms = *KEYBOARD_RECOVERY_DELAYS_MS.get(attempt.checked_sub(1)?)?; - Some(KeyboardRecoveryPlan { - attempt, - delay: Duration::from_millis(delay_ms), - }) -} - -fn should_recover_keyboard_daemon( - shutdown_started: bool, - current_generation: u64, - task_generation: Option, - failed_generation: u64, -) -> bool { - !shutdown_started - && current_generation == failed_generation - && task_generation == Some(failed_generation) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct KeyStatePayload<'a> { - key: &'a str, - state: &'a str, - mode: &'a str, - event_age_ms: f64, - #[serde(skip_serializing_if = "Option::is_none")] - hold_duration_ms: Option, -} - -fn key_state_payload<'a>( - key: &'a str, - state: &'a str, - mode: &'a str, - event_age_ms: f64, - is_down: bool, - hold_duration_ms: Option, -) -> KeyStatePayload<'a> { - KeyStatePayload { - key, - state, - mode, - event_age_ms, - hold_duration_ms: if is_down { None } else { hold_duration_ms }, - } -} - -fn canonical_hold_duration_ms( - can_use_physical_hold_duration: bool, - physical_hold_duration_ms: Option, -) -> Option { - if can_use_physical_hold_duration { - physical_hold_duration_ms - } else { - None - } -} - fn collect_frontend_lifecycle_targets( mut resolve: impl FnMut(&str) -> Option, ) -> Vec<(String, T)> { @@ -1491,9412 +1102,162 @@ impl AppState { } } - pub fn overlay_status(&self) -> BootstrapOverlayState { - let state = self.store.snapshot(); - BootstrapOverlayState { - visible: *self.overlay_visible.read(), - locked: state.overlay_locked, - anchor: state.overlay_resize_anchor.as_str().to_string(), - } + pub(crate) fn plugin_authority(&self) -> &PluginRuntimeAuthority { + &self.plugin_authority } - pub fn emit_settings_changed(&self, diff: &SettingsDiff, app: &AppHandle) -> Result<()> { - log::debug!( - "[IPC] emit_settings_changed: {} fields changed", - diff.changed_count() - ); - self.apply_settings_effects(diff, app)?; - if let Some(value) = diff.changed.key_counter_enabled { - self.key_counter_enabled.store(value, Ordering::SeqCst); - } - // OBS 브릿지 캐시 갱신 - if self.obs_bridge.is_running() { - let bp = self.bootstrap_payload(); - if let Ok(snap) = serde_json::to_value(&bp) { - self.obs_bridge.update_snapshot(snap); - } - } - // 전체 설정 페이로드 전송 방지 (임베디드 폰트 등 대용량 데이터 제외) - let mut payload = diff.clone(); - payload.full = None; - publish_event(app, "settings:changed", payload); - Ok(()) + pub(crate) fn reset_plugin_authority(&self) -> Result { + self.plugin_authority.reset() } - /// 저장된 토큰 재사용 또는 신규 생성 후 store에 저장 - /// 기존 토큰은 commit-after-persist로 디스크 저장이 보장되므로 재저장 생략 - pub fn resolve_and_save_obs_token(&self) -> Result { - if let Some(token) = self - .store - .with_state(|s| s.obs_token.clone()) - .filter(|token| !token.is_empty()) - { - return Ok(token); - } - - let token = uuid::Uuid::new_v4().simple().to_string(); - let t = token.clone(); - self.store.update(|s| { - s.obs_token = Some(t.clone()); - })?; - Ok(token) + pub fn mark_plugin_authority_unavailable(&self) { + self.plugin_authority.mark_unavailable(); } +} - /// 부팅 시 OBS 모드 자동 시작 (obs_mode_enabled=true일 때) - fn auto_start_obs(&self, app: &AppHandle) { - let bridge = self.obs_bridge.clone(); - let store = self.store.clone(); - - // 부팅 시에는 오버레이를 생성하지 않았으므로 이전 표시 상태만 저장 - // (initialize_runtime에서 obs_mode_enabled일 때 ensure_overlay_window 건너뜀) - let was_visible = store.with_state(|s| s.overlay_visible); - *self.obs_previous_overlay_visible.write() = Some(was_visible); - let app_handle = app.clone(); - - // dev 모드: Vite dev server로 리다이렉트 - if cfg!(debug_assertions) { - let dev_url = "http://localhost:3400".to_string(); - log::info!("[ObsBridge] dev 모드: Vite dev server로 리다이렉트 ({dev_url})"); - bridge.set_dev_url(dev_url); - } else { - // 프로덕션: Tauri 임베딩 에셋으로 서빙 - let handle = app_handle.clone(); - let fetcher = std::sync::Arc::new(move |path: &str| { - let resolver = handle.asset_resolver(); - resolver.get(path.into()).map(|asset| { - let mime = asset.mime_type.clone(); - (asset.bytes.to_vec(), mime) - }) - }); - bridge.set_asset_fetcher(fetcher); - } - - // AppHandle 전달 (invoke_request 디스패치용) - bridge.set_app_handle(app.clone()); - // async start를 tokio 런타임에서 실행 - tauri::async_runtime::spawn(async move { - let state = app_handle.state::(); - let _lifecycle_guard = state.obs_lifecycle_lock.lock().await; - let port = store.with_state(|s| s.obs_port); - // 미저장 토큰 사용 방지를 위한 시작 중단 - let token = match state.resolve_and_save_obs_token() { - Ok(token) => token, - Err(e) => { - log::error!( - "[ObsBridge] auto-start 중단: 토큰 저장 실패 ({e}), obs_mode_enabled를 false로 복구" - ); - let _ = store.update(|s| { - s.obs_mode_enabled = false; - }); - state.obs_restore_overlay(&app_handle); - let _ = app_handle.emit("obs:status", &state.obs_bridge.status()); - return; - } - }; - - match bridge.start(port, token).await { - Ok(actual_port) => { - log::info!("[ObsBridge] auto-start 성공 (port={})", actual_port); - // fallback 포트가 사용된 경우 store에 저장 - if actual_port != port { - let _ = store.update(|s| { - s.obs_port = actual_port; - }); - } - // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) - state.refresh_obs_snapshot(); - let _ = app_handle.emit("obs:status", &state.obs_bridge.status()); - } - Err(e) => { - log::error!( - "[ObsBridge] auto-start 실패: {}, obs_mode_enabled를 false로 복구", - e - ); - let _ = store.update(|state| { - state.obs_mode_enabled = false; - }); - // 실패 시 오버레이 복원 (윈도우 재생성 포함) - state.obs_restore_overlay(&app_handle); - let _ = app_handle.emit("obs:status", &state.obs_bridge.status()); - } - } - }); +impl Drop for AppState { + fn drop(&mut self) { + self.shutdown(); } +} - /// OBS 시작 시 오버레이 윈도우 destroy (이전 상태 보존) - pub fn obs_hide_overlay(&self, app: &AppHandle) { - let was_visible = *self.overlay_visible.read(); - *self.obs_previous_overlay_visible.write() = Some(was_visible); - // destroy()는 CloseRequested 이벤트 없이 즉시 윈도우를 파괴 - if let Some(window) = app.get_webview_window(OVERLAY_LABEL) { - if let Err(e) = window.destroy() { - log::warn!("[ObsBridge] 오버레이 destroy 실패: {}", e); - // destroy 실패 시 hide로 fallback - if was_visible { - if let Err(e) = self.set_overlay_visibility(app, false) { - log::warn!("[ObsBridge] 오버레이 hide fallback 실패: {}", e); - } - } - return; - } - } - if let Err(error) = self.overlay_hit.reset_for_parent_loss(app) { - log::warn!("failed to reset overlay hit state for OBS mode: {error:#}"); +fn output_backend_from_persist(value: KeySoundOutputBackendPersist) -> KeySoundOutputBackend { + match value { + KeySoundOutputBackendPersist::DefaultDevice => KeySoundOutputBackend::DefaultDevice, + KeySoundOutputBackendPersist::Device { id, name } => { + KeySoundOutputBackend::Device { id, name } } - // destroy 성공(또는 윈도우 부재) 후 런타임 플래그만 갱신 - // store.overlay_visible은 변경하지 않음 — ensure_overlay_window가 재생성 시 - // 이 값을 기준으로 show/hide를 결정하므로, 원래 값을 유지해야 함 - *self.overlay_visible.write() = false; - publish_event(app, "overlay:visibility", json!({ "visible": false })); + KeySoundOutputBackendPersist::Asio { + driver_name, + buffer_size, + } => KeySoundOutputBackend::Asio { + driver_name, + buffer_size, + }, } +} - /// OBS 중지 시 오버레이 재생성 + 복원 - pub fn obs_restore_overlay(&self, app: &AppHandle) { - let prev = self.obs_previous_overlay_visible.write().take(); - match prev { - Some(true) => { - // set_overlay_visibility(true) 내부에서 ensure_overlay_window + show + store 갱신 + emit 처리 - if let Err(e) = self.set_overlay_visibility(app, true) { - log::warn!("[ObsBridge] 오버레이 복원 실패: {}", e); - } - } - Some(false) => { - // 이전 상태가 hidden이었더라도 윈도우는 재생성 필요 - // (이후 sync 커맨드에서 WebView2 빌드 시 메시지 루프 블로킹 방지) - if let Err(e) = self.ensure_overlay_window(app) { - log::warn!("[ObsBridge] 오버레이 윈도우 재생성 실패: {}", e); - } - } - None => {} +fn output_backend_to_persist(value: KeySoundOutputBackend) -> KeySoundOutputBackendPersist { + match value { + KeySoundOutputBackend::DefaultDevice => KeySoundOutputBackendPersist::DefaultDevice, + KeySoundOutputBackend::Device { id, name } => { + KeySoundOutputBackendPersist::Device { id, name } } + KeySoundOutputBackend::Asio { + driver_name, + buffer_size, + } => KeySoundOutputBackendPersist::Asio { + driver_name, + buffer_size, + }, } +} - /// OBS 모드 활성화 여부 - pub fn is_obs_mode_active(&self) -> bool { - self.obs_bridge.is_running() - } - - /// OBS 브릿지용 전체 스냅샷 빌드 + 캐시 갱신 + 연결된 클라이언트에 broadcast - pub fn refresh_obs_snapshot(&self) { - if !self.obs_bridge.is_running() { - return; - } - let payload = self.bootstrap_payload(); - if let Ok(snapshot) = serde_json::to_value(&payload) { - self.obs_bridge.update_snapshot(snapshot); - self.obs_bridge.broadcast_snapshot(); - } - } - - /// OBS 브릿지 캐시 스냅샷 갱신 - /// CSS 등 개별 설정 변경이 OBS 런타임 상태(키 시그널, KPS)를 리셋하지 않도록 사용 - pub fn notify_obs_settings_diff(&self, _diff: serde_json::Value) { - if !self.obs_bridge.is_running() { - return; - } - let bp = self.bootstrap_payload(); - if let Ok(snap) = serde_json::to_value(&bp) { - self.obs_bridge.update_snapshot(snap); - } - } - - /// OBS 브릿지 카운터 스냅샷 갱신 - pub fn obs_broadcast_counters(&self) { - if !self.obs_bridge.is_running() { - return; - } - let bp = self.bootstrap_payload(); - if let Ok(snap) = serde_json::to_value(&bp) { - self.obs_bridge.update_snapshot(snap); - } - } - - pub fn set_overlay_visibility(&self, app: &AppHandle, visible: bool) -> Result<()> { - log::debug!("[IPC] set_overlay_visibility: visible={}", visible); - let _transition_guard = self - .overlay_creation_lock - .try_lock_for(OVERLAY_CREATION_LOCK_TIMEOUT) - .ok_or_else(|| { - anyhow!( - "timed out after {} seconds waiting for overlay creation lock", - OVERLAY_CREATION_LOCK_TIMEOUT.as_secs() - ) - })?; - - if !visible { - flush_deferred_overlay_bounds(&self.store, &self.overlay_bounds_generation)?; - } - - if visible { - // 오버레이를 열 때: 창이 없으면 생성하고 표시 - let window = self.ensure_overlay_window_while_locked(app)?; - let snapshot = self.store.snapshot(); - show_overlay_window(&window, snapshot.always_on_top)?; - - // 오버레이가 숨겨진 동안 변경된 설정을 다시 적용. - // 본체는 상시 클릭 통과 - 실제 잠금은 히트 창이 강제한다 - window.set_ignore_cursor_events(true)?; - window.set_always_on_top(snapshot.always_on_top)?; - #[cfg(target_os = "macos")] - apply_macos_overlay_fullscreen_behavior(&window, snapshot.always_on_top); - if let Err(error) = self.overlay_hit.set_configuration( - app, - true, - snapshot.overlay_locked, - snapshot.always_on_top, - ) { - log::warn!("failed to configure overlay hit windows: {error:#}"); - } - } else { - // 오버레이를 숨길 때: 창이 존재하는 경우에만 숨김 - // 창 미존재 시 무시 (창 생성하지 않음) - // 히트 창 먼저 - CloseRequested의 HideAndPersist와 같은 전환 순서 - if let Err(error) = self.overlay_hit.set_visible(app, false) { - log::warn!("failed to hide overlay hit windows: {error:#}"); - } - if let Some(window) = app.get_webview_window(OVERLAY_LABEL) { - hide_overlay_window(&window)?; - } - } - - // 창 조작 성공 후에만 영속. 저장 실패면 창 조작을 보상해 전 계층을 이전 상태로 복원 - if let Err(persist_err) = self.store.update(|state| { - state.overlay_visible = visible; - }) { - let compensation = if visible { - app.get_webview_window(OVERLAY_LABEL) - .map_or(Ok(()), |window| hide_overlay_window(&window)) - } else { - match app.get_webview_window(OVERLAY_LABEL) { - Some(window) => { - let snapshot = self.store.snapshot(); - show_overlay_window(&window, snapshot.always_on_top) - } - None => Ok(()), - } - }; - if let Err(comp_err) = compensation { - // 보상 실패 — 실제 창 상태를 권위로 runtime과 이벤트를 동기화 - log::error!( - "[Overlay] 저장 실패 후 보상도 실패({comp_err}) — 창 상태({visible})를 권위로 동기화" - ); - *self.overlay_visible.write() = visible; - publish_event(app, "overlay:visibility", json!({ "visible": visible })); - } else if let Err(error) = self.overlay_hit.set_visible(app, !visible) { - log::warn!("failed to restore overlay hit visibility: {error:#}"); - } - return Err(persist_err); - } - - *self.overlay_visible.write() = visible; - publish_event(app, "overlay:visibility", json!({ "visible": visible })); - Ok(()) - } - - pub fn set_overlay_lock(&self, app: &AppHandle, locked: bool, persist: bool) -> Result<()> { - log::debug!( - "[IPC] set_overlay_lock: locked={}, persist={}", - locked, - persist - ); - if persist { - let _ = self.store.update(|state| { - state.overlay_locked = locked; - })?; - } - - // 본체는 상시 클릭 통과라 잠금은 히트 창에만 반영한다 - if let Err(error) = self.overlay_hit.set_locked(app, locked) { - log::warn!("failed to update overlay hit lock: {error:#}"); - } - publish_event(app, "overlay:lock", json!({ "locked": locked })); - Ok(()) - } - - pub fn sync_overlay_hit_regions( - &self, - app: &AppHandle, - rects: Vec, - revision: u64, - device_pixel_ratio: f64, - epoch: u64, - renderer_session_id: String, - ) -> Result { - self.overlay_hit.sync_regions( - app, - rects, - revision, - device_pixel_ratio, - epoch, - renderer_session_id, - ) - } - - pub fn overlay_hit_renderer_ready( - &self, - app: &AppHandle, - renderer_session_id: String, - ) -> Result { - self.overlay_hit.renderer_ready(app, renderer_session_id) - } - - pub fn overlay_hit_renderer_load_started(&self, app: &AppHandle) -> Result<()> { - self.overlay_hit.renderer_load_started(app) - } - - pub fn shutdown(&self) { - if self.shutdown_started.swap(true, Ordering::SeqCst) { - return; - } - self.panel_drag.clear_for_lifecycle(None, "shutdown"); - self.overlay_bounds_generation - .fetch_add(1, Ordering::SeqCst); - self.keyboard_task_generation.fetch_add(1, Ordering::SeqCst); - let keyboard_task = { - let mut task_guard = self.keyboard_task.write(); - task_guard.take() - }; - if let Some(task) = keyboard_task { - drop(task); - } - if let Some(watcher) = self.css_watcher.write().take() { - watcher.shutdown(); - } - if let Err(err) = self.persist_key_counters() { - log::warn!("failed to persist key counters during shutdown: {err}"); - } - if let Err(err) = self.store.flush_cleanup_and_shutdown() { - log::warn!("failed to finalize store during shutdown: {err:#}"); - } - } - - pub(crate) fn arm_shutdown_watchdog(&self, stage: &'static str) { - { - let mut watchdog = self.shutdown_watchdog.lock(); - if watchdog.armed { - return; - } - watchdog.armed = true; - watchdog.stage = stage; - } - let watchdog = self.shutdown_watchdog.clone(); - thread::spawn(move || { - thread::sleep(SHUTDOWN_WATCHDOG_TIMEOUT); - log::error!( - "[Shutdown] watchdog exceeded {} seconds during '{}'; forcing process exit with code {}", - SHUTDOWN_WATCHDOG_TIMEOUT.as_secs(), - watchdog.lock().stage, - SHUTDOWN_WATCHDOG_EXIT_CODE - ); - std::process::exit(SHUTDOWN_WATCHDOG_EXIT_CODE); - }); - } - - pub(crate) fn set_shutdown_watchdog_stage(&self, stage: &'static str) { - self.shutdown_watchdog.lock().stage = stage; - } - - pub fn is_process_exit_authorized(&self) -> bool { - self.process_exit_authorized.load(Ordering::SeqCst) - } - - fn authorize_process_exit(&self) { - self.process_exit_authorized.store(true, Ordering::SeqCst); - } - - pub fn request_frontend_shutdown(&self, app_handle: AppHandle) { - self.request_frontend_lifecycle(app_handle, FrontendLifecycleAction::Quit); - } - - pub fn request_frontend_restart(&self, app_handle: AppHandle) { - self.request_frontend_lifecycle(app_handle, FrontendLifecycleAction::Restart); - } - - #[cfg(target_os = "windows")] - fn frontend_lifecycle_pending(&self) -> bool { - if self - .editor_flush_handshake - .lock() - .as_ref() - .is_some_and(|handshake| handshake.completion.is_lifecycle()) - { - return true; - } - - self.deferred_frontend_lifecycle.lock().is_some() - } - - pub fn acknowledge_frontend_lifecycle( - &self, - app_handle: AppHandle, - handshake_id: &str, - window_label: &str, - ) { - let prepared = { - let mut handshake = self.editor_flush_handshake.lock(); - acknowledge_editor_flush_handshake( - &mut handshake, - handshake_id, - window_label, - &self.store.history_gate(), - ) - }; - - match prepared { - Some(EditorFlushAcknowledge::LifecycleReady(completed)) => { - self.complete_editor_flush_handshake(app_handle, completed); - } - Some(EditorFlushAcknowledge::HistoryClosing { - handshake_id, - waiter, - }) => { - let drain_result = waiter.wait_for_drain(); - self.finish_frontend_history_gate_close(app_handle, &handshake_id, drain_result); - } - Some(EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }) => { - log::warn!("failed to close history admission gate: {error}"); - self.fail_editor_flush_handshake( - &app_handle, - handshake, - HISTORY_FRONTEND_FLUSH_BUSY, - ); - } - None => {} - } - } - - pub(crate) fn admit_frontend_history_mutation( - &self, - window_label: &str, - ) -> std::result::Result { - let handshake = self.editor_flush_handshake.lock(); - if frontend_history_mutation_blocked(&handshake, window_label) { - return Err(EditorCommitError::history_in_progress()); - } - self.store - .history_gate() - .admit_mutation() - .map_err(|_| EditorCommitError::history_in_progress()) - } - - pub(crate) fn ensure_mutation_allowed(&self) -> std::result::Result<(), &'static str> { - if self.shutdown_started.load(Ordering::SeqCst) { - return Err(MUTATION_SHUTDOWN_STARTED); - } - Ok(()) - } - - pub(crate) fn issue_mutation_publication( - &self, - ) -> std::result::Result { - self.ensure_mutation_allowed()?; - self.mutation_publication.issue() - } - - pub fn cancel_frontend_lifecycle(&self, app_handle: AppHandle, handshake_id: &str) { - let canceled = { - let mut handshake = self.editor_flush_handshake.lock(); - take_cancelable_editor_flush_handshake(&mut handshake, handshake_id) - }; - if let Some(canceled) = canceled { - self.fail_editor_flush_handshake( - &app_handle, - canceled, - HISTORY_FRONTEND_FLUSH_CANCELED, - ); - } - } - - fn complete_editor_flush_handshake( - &self, - app_handle: AppHandle, - completed: EditorFlushHandshake, - ) { - let EditorFlushHandshake { completion, .. } = completed; - match completion { - EditorFlushCompletion::Lifecycle(action) => { - execute_frontend_lifecycle(app_handle, action, self.overlay_force_close.clone()); - } - EditorFlushCompletion::History { .. } => { - log::error!("history handshake completed through lifecycle path"); - } - } - } - - fn finish_frontend_history_gate_close( - &self, - app_handle: AppHandle, - handshake_id: &str, - drain_result: std::result::Result<(), String>, - ) { - if let Err(error) = drain_result { - let failed = { - let mut active = self.editor_flush_handshake.lock(); - take_cancelable_editor_flush_handshake(&mut active, handshake_id) - }; - if let Some(failed) = failed { - log::warn!("history admission drain was interrupted: {error}"); - self.fail_editor_flush_handshake( - &app_handle, - failed, - HISTORY_FRONTEND_FLUSH_INTERRUPTED, - ); - } - return; - } - - let prepared = { - let mut active = self.editor_flush_handshake.lock(); - let Some(handshake) = active.as_mut().filter(|item| item.id == handshake_id) else { - return; - }; - let EditorFlushCompletion::History { - sender, - phase, - barrier, - .. - } = &mut handshake.completion - else { - return; - }; - if *phase != FrontendHistoryFlushPhase::Closing { - return; - } - let Some(sender) = sender.take() else { - return; - }; - let Some(barrier) = barrier.take() else { - return; - }; - *phase = FrontendHistoryFlushPhase::Running; - Some((sender, barrier)) - }; - - let Some((sender, barrier)) = prepared else { - return; - }; - let completion_app = app_handle.clone(); - let completion_id = handshake_id.to_string(); - let ready = FrontendHistoryFlushReady { - barrier: Some(barrier), - complete: Some(Box::new(move || { - let state = completion_app.state::(); - state.complete_frontend_history_operation(&completion_app, &completion_id); - })), - }; - let _ = sender.send(Ok(ready)); - } - - fn complete_frontend_history_operation(&self, app_handle: &AppHandle, handshake_id: &str) { - let (completed, deferred_action) = { - let mut active = self.editor_flush_handshake.lock(); - let is_running = active.as_ref().is_some_and(|handshake| { - handshake.id == handshake_id - && handshake.completion.history_phase() - == Some(FrontendHistoryFlushPhase::Running) - }); - if !is_running { - return; - } - let completed = active - .take() - .expect("running history handshake disappeared"); - let deferred_action = self.deferred_frontend_lifecycle.lock().take(); - (completed, deferred_action) - }; - emit_frontend_history_flush_released(app_handle, &completed.id, &completed.target_windows); - if let Some(action) = deferred_action { - self.request_frontend_lifecycle(app_handle.clone(), action); - } - } - - fn fail_editor_flush_handshake( - &self, - app_handle: &AppHandle, - failed: EditorFlushHandshake, - history_error: &'static str, - ) { - let EditorFlushHandshake { - id, - completion, - target_windows, - .. - } = failed; - match completion { - EditorFlushCompletion::Lifecycle(_) => { - #[cfg(target_os = "macos")] - if let Err(error) = super::macos_termination::cancel_pending_termination(app_handle) - { - log::warn!("failed to cancel pending macOS termination: {error}"); - } - self.restore_frontend_lifecycle_windows(app_handle, &target_windows); - } - EditorFlushCompletion::History { - sender, barrier, .. - } => { - drop(barrier); - emit_frontend_history_flush_released(app_handle, &id, &target_windows); - if let Some(sender) = sender { - let _ = sender.send(Err(history_error.to_string())); - } - } - } - } - - fn restore_frontend_lifecycle_windows( - &self, - app_handle: &AppHandle, - target_windows: &HashSet, - ) { - for label in frontend_lifecycle_restore_labels(target_windows) { - match label { - "main" => { - if let Err(error) = self.show_main_window(app_handle) { - log::warn!( - "failed to restore main window after canceled lifecycle: {error}" - ); - } - } - OVERLAY_LABEL if *self.overlay_visible.read() => { - if let Err(error) = self.set_overlay_visibility(app_handle, true) { - log::warn!("failed to restore overlay after canceled lifecycle: {error}"); - } - } - PANEL_LABEL => { - if let Some(panel) = app_handle.get_webview_window(PANEL_LABEL) { - if let Err(error) = panel.show() { - log::warn!("failed to restore panel after canceled lifecycle: {error}"); - } - } - } - _ => {} - } - } - } - - fn request_frontend_lifecycle(&self, app_handle: AppHandle, action: FrontendLifecycleAction) { - if self.overlay_force_close.load(Ordering::SeqCst) - || self.shutdown_started.load(Ordering::SeqCst) - { - return; - } - - let targets = - collect_frontend_lifecycle_targets(|label| app_handle.get_webview_window(label)); - let handshake_id = uuid::Uuid::new_v4().to_string(); - let target_windows = targets - .iter() - .map(|(label, _)| label.clone()) - .collect::>(); - let next_handshake = EditorFlushHandshake { - id: handshake_id.clone(), - completion: EditorFlushCompletion::Lifecycle(action), - pending_windows: target_windows.clone(), - target_windows, - }; - let interrupted_history = { - let mut active = self.editor_flush_handshake.lock(); - match install_lifecycle_handshake(&mut active, next_handshake) { - LifecycleHandshakeInstall::Installed => None, - LifecycleHandshakeInstall::InterruptedHistory(interrupted) => Some(*interrupted), - LifecycleHandshakeInstall::LifecycleAlreadyActive => return, - LifecycleHandshakeInstall::DeferredUntilHistoryComplete => { - let mut deferred = self.deferred_frontend_lifecycle.lock(); - if deferred.is_none() { - *deferred = Some(action); - } - return; - } - } - }; - if let Some(interrupted) = interrupted_history { - self.fail_editor_flush_handshake( - &app_handle, - interrupted, - HISTORY_FRONTEND_FLUSH_INTERRUPTED, - ); - } - - if targets.is_empty() { - let completed = { - let mut active = self.editor_flush_handshake.lock(); - take_editor_flush_handshake(&mut active, &handshake_id) - }; - if let Some(completed) = completed { - self.complete_editor_flush_handshake(app_handle, completed); - } - return; - } - - let request = EditorFlushRequest { - handshake_id: handshake_id.clone(), - action: action.into(), - }; - let mut failed_windows = Vec::new(); - for (label, window) in &targets { - if let Err(error) = window.emit("app:close-requested", &request) { - log::warn!("failed to request editor flush from {label}: {error}"); - failed_windows.push(label.clone()); - } - } - - if !failed_windows.is_empty() { - let canceled = { - let mut handshake = self.editor_flush_handshake.lock(); - take_editor_flush_handshake(&mut handshake, &handshake_id) - }; - let Some(canceled) = canceled else { - return; - }; - log::warn!( - "editor flush request failed for {:?}; lifecycle action canceled", - failed_windows - ); - self.fail_editor_flush_handshake( - &app_handle, - canceled, - HISTORY_FRONTEND_FLUSH_EMIT_FAILED, - ); - return; - } - - self.schedule_editor_flush_timeout(app_handle, handshake_id); - } - - pub(crate) fn request_frontend_history_flush( - &self, - app_handle: AppHandle, - operation_id: &str, - ) -> Result>, String> { - if self.overlay_force_close.load(Ordering::SeqCst) - || self.shutdown_started.load(Ordering::SeqCst) - { - return Err(HISTORY_FRONTEND_FLUSH_BUSY.to_string()); - } - - let targets = - collect_frontend_lifecycle_targets(|label| app_handle.get_webview_window(label)); - let target_windows = targets - .iter() - .map(|(label, _)| label.clone()) - .collect::>(); - let handshake_id = uuid::Uuid::new_v4().to_string(); - let (sender, receiver) = oneshot::channel(); - let handshake = EditorFlushHandshake { - id: handshake_id.clone(), - completion: EditorFlushCompletion::History { - operation_id: operation_id.to_string(), - sender: Some(sender), - phase: FrontendHistoryFlushPhase::Collecting, - barrier: None, - }, - pending_windows: target_windows.clone(), - target_windows, - }; - { - let mut active = self.editor_flush_handshake.lock(); - if self.store.history_gate().is_closed() - || !install_history_handshake(&mut active, handshake) - { - return Err(HISTORY_FRONTEND_FLUSH_BUSY.to_string()); - } - } - - if targets.is_empty() { - let prepared = { - let mut active = self.editor_flush_handshake.lock(); - begin_history_gate_close(&mut active, &handshake_id, &self.store.history_gate()) - }; - match prepared { - Some(EditorFlushAcknowledge::HistoryClosing { - handshake_id, - waiter, - }) => { - let drain_result = waiter.wait_for_drain(); - self.finish_frontend_history_gate_close( - app_handle, - &handshake_id, - drain_result, - ); - } - Some(EditorFlushAcknowledge::HistoryCloseFailed { handshake, error }) => { - log::warn!("failed to close history admission gate: {error}"); - self.fail_editor_flush_handshake( - &app_handle, - handshake, - HISTORY_FRONTEND_FLUSH_BUSY, - ); - } - _ => {} - } - return Ok(receiver); - } - - let request = EditorFlushRequest { - handshake_id: handshake_id.clone(), - action: FrontendFlushAction::History, - }; - let mut failed_windows = Vec::new(); - for (label, window) in &targets { - if let Err(error) = window.emit("app:close-requested", &request) { - log::warn!("failed to request history flush from {label}: {error}"); - failed_windows.push(label.clone()); - } - } - - if !failed_windows.is_empty() { - let failed = { - let mut active = self.editor_flush_handshake.lock(); - take_cancelable_editor_flush_handshake(&mut active, &handshake_id) - }; - if let Some(failed) = failed { - log::warn!( - "history frontend flush request failed for {:?}", - failed_windows - ); - self.fail_editor_flush_handshake( - &app_handle, - failed, - HISTORY_FRONTEND_FLUSH_EMIT_FAILED, - ); - } - return Ok(receiver); - } - - self.schedule_editor_flush_timeout(app_handle, handshake_id); - Ok(receiver) - } - - fn schedule_editor_flush_timeout(&self, app_handle: AppHandle, handshake_id: String) { - let handshake = self.editor_flush_handshake.clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(EDITOR_FLUSH_HANDSHAKE_TIMEOUT).await; - let timed_out = { - let mut active = handshake.lock(); - take_cancelable_editor_flush_handshake(&mut active, &handshake_id) - }; - - if let Some(timed_out) = timed_out { - let state = app_handle.state::(); - if timed_out.completion.is_lifecycle() { - log::warn!("editor flush handshake timed out; lifecycle action canceled"); - } else { - log::warn!("editor flush handshake timed out; history action canceled"); - } - state.fail_editor_flush_handshake( - &app_handle, - timed_out, - HISTORY_FRONTEND_FLUSH_TIMEOUT, - ); - } - }); - } - - pub fn set_overlay_anchor(&self, app: &AppHandle, anchor: &str) -> Result { - let parsed = overlay_resize_anchor_from_str(anchor); - let value: OverlayResizeAnchor = - parsed.unwrap_or_else(|| self.store.snapshot().overlay_resize_anchor.clone()); - let updated = self.store.update(|state| { - state.overlay_resize_anchor = value.clone(); - })?; - publish_event(app, "overlay:anchor", json!({ "anchor": value.as_str() })); - Ok(updated.overlay_resize_anchor.as_str().to_string()) - } - - #[allow(clippy::too_many_arguments)] - pub fn resize_overlay( - &self, - app: &AppHandle, - width: f64, - height: f64, - anchor: Option, - content_top_offset: Option, - fixed_position_delta_x: Option, - fixed_position_delta_y: Option, - ) -> Result { - // 오버레이가 이미 열려있을 때만 리사이즈 수행 - // 창 미존재 시 에러 반환 (자동 생성하지 않음) - let window = app - .get_webview_window(OVERLAY_LABEL) - .ok_or_else(|| anyhow!("Overlay window is not open"))?; - let anchor = anchor - .and_then(|value| overlay_resize_anchor_from_str(&value)) - .unwrap_or_else(|| self.store.snapshot().overlay_resize_anchor.clone()); - - let requested_width = width; - let requested_height = height; - let width = clamp_overlay_dimension(width); - let height = clamp_overlay_dimension(height); - // 잘린 경우 콘텐츠 일부가 창 밖에 남으므로 진단용 기록 - if (requested_width - width).abs() >= 0.5 || (requested_height - height).abs() >= 0.5 { - log::warn!( - "[overlay] resize clamped: requested {requested_width}x{requested_height} -> {width}x{height}" - ); - } - - let initializing = self.overlay_initializing.load(Ordering::SeqCst); - let resolved = initializing - .then(|| self.overlay_resolved_placement.lock().clone()) - .flatten(); - let current = match resolved.as_ref() { - Some(resolved) => resolved.placement, - None => native_placement_from_window(&window)?, - }; - let mut placement = if let Some(resolved) = resolved.as_ref() { - resolved.for_size(width, height) - } else { - NativePlacement { - width, - height, - ..current - } - }; - let mut next_content_top_offset = None; - - // 초기화 중(첫 resize)에는 anchor 기반 position 재계산을 건너뛰고 - // 기동 시 한 번 해석한 배치를 사용 - if initializing { - // 초기화 중이라도 content_top_offset은 저장해야 다음 resize에서 delta 계산이 정확함 - if let Some(offset) = content_top_offset { - if offset.is_finite() { - next_content_top_offset = Some(offset); - } - } - } else { - let scale = placement.target_scale; - match anchor { - OverlayResizeAnchor::BottomLeft => { - placement.position.y += (current.height - height) * scale - } - OverlayResizeAnchor::TopRight => { - placement.position.x += (current.width - width) * scale - } - OverlayResizeAnchor::BottomRight => { - placement.position.x += (current.width - width) * scale; - placement.position.y += (current.height - height) * scale; - } - OverlayResizeAnchor::Center => { - placement.position.x += (current.width - width) * scale / 2.0; - placement.position.y += (current.height - height) * scale / 2.0; - } - OverlayResizeAnchor::FixedPosition => {} - OverlayResizeAnchor::TopLeft => {} - } - - if anchor == OverlayResizeAnchor::FixedPosition { - if let Some(delta_x) = fixed_position_delta_x.filter(|value| value.is_finite()) { - placement.position.x += delta_x * scale; - } - if let Some(delta_y) = fixed_position_delta_y.filter(|value| value.is_finite()) { - placement.position.y += delta_y * scale; - } - } - - if let Some(offset) = content_top_offset { - if offset.is_finite() { - let previous = self - .store - .snapshot() - .overlay_last_content_top_offset - .unwrap_or(offset); - let delta = offset - previous; - if delta != 0.0 { - match anchor { - OverlayResizeAnchor::Center => { - placement.position.y -= delta * scale / 2.0 - } - OverlayResizeAnchor::BottomLeft | OverlayResizeAnchor::BottomRight => {} - OverlayResizeAnchor::FixedPosition => { - placement.position.y -= delta * scale - } - _ => placement.position.y -= delta * scale, - } - } - next_content_top_offset = Some(offset); - } - } - } - - // 크기·위치를 단일 네이티브 트랜잭션으로 적용 - 분리 호출은 창이 두 단계로 움직여 덜컥거림 유발 - let applied = apply_overlay_frame(&window, placement)?; - if initializing { - *self.overlay_resolved_placement.lock() = None; - self.overlay_initializing.store(false, Ordering::SeqCst); - } - persist_overlay_placement( - &self.store, - &self.overlay_bounds_generation, - &self.overlay_placement_trust, - applied.clone(), - next_content_top_offset, - OverlayPersistenceAuthority::General, - )?; - let bounds = applied.public_bounds; - - log::debug!( - "[IPC] resize_overlay: emit overlay:resized ({}x{} at {}, {})", - bounds.width, - bounds.height, - bounds.x, - bounds.y - ); - publish_event( - app, - "overlay:resized", - json!({ - "x": bounds.x, - "y": bounds.y, - "width": bounds.width, - "height": bounds.height, - }), - ); - - Ok(bounds) - } - - /// 오버레이를 겹침이 가장 큰 모니터(없으면 주 모니터) 작업 영역 가운데로 되돌린다. - /// 창이 화면 밖으로 나가 잡을 수 없을 때의 탈출구이므로 표시 여부도 창 존재 여부도 따지지 않는다. - /// 창이 없으면 저장된 위치만 갱신해, 다음에 오버레이를 켰을 때 제자리에 뜬다 - pub fn reset_overlay_position(&self, app: &AppHandle) -> Result { - let window = app.get_webview_window(OVERLAY_LABEL); - let snapshot = self.store.snapshot(); - let stored = snapshot.overlay_bounds; - // 저장된 사각형을 해석하려면 모니터 정보가 먼저 필요하다 - let monitors = MonitorData::gather(app); - - // 창도 모니터 정보도 없으면 착지점을 고를 근거가 전무하다. 임의 좌표로 - // 덮어써 성공을 보고하느니 실패시켜 저장된 값과 마커를 보존한다 - if window.is_none() && monitors.is_empty() { - return Err(anyhow!("monitor information unavailable")); - } - - let current = match window.as_ref() { - Some(window) => native_placement_from_window(window)?, - None => overlay_reset_fallback_rect( - stored.as_ref(), - snapshot.overlay_bounds_are_logical, - &monitors, - ), - }; - let target = monitors - .find_best_overlap_native(current.native_rect()) - .or_else(|| monitors.primary_spec()); - let planned = match target { - Some(spec) => { - let width_native = spec.logical_length_to_native(current.width); - let height_native = spec.logical_length_to_native(current.height); - let rect = NativeRect { - x: spec.work_rect_native.x + (spec.work_rect_native.width - width_native) / 2.0, - y: spec.work_rect_native.y - + (spec.work_rect_native.height - height_native) / 2.0, - width: width_native, - height: height_native, - }; - NativePlacement { - position: spec.clamp_native(rect), - width: current.width, - height: current.height, - target_scale: spec.logical_to_native_scale, - } - } - None => NativePlacement { - position: OverlayPosition { - x: OVERLAY_MARGIN * current.target_scale, - y: OVERLAY_MARGIN * current.target_scale, - }, - ..current - }, - }; - - let applied = match window.as_ref() { - Some(window) => apply_overlay_frame(window, planned)?, - None => applied_overlay_frame_from_placement(planned), - }; - - // 크기가 그대로라 창 안에서의 콘텐츠 위치도 그대로 - 기준선을 건드리면 - // 다음 resize가 이동량을 두 번 반영한다 - persist_overlay_placement( - &self.store, - &self.overlay_bounds_generation, - &self.overlay_placement_trust, - applied.clone(), - None, - OverlayPersistenceAuthority::Reset, - )?; - let bounds = applied.public_bounds; - - publish_event( - app, - "overlay:resized", - json!({ - "x": bounds.x, - "y": bounds.y, - "width": bounds.width, - "height": bounds.height, - }), - ); - - Ok(bounds) - } - - pub fn start_keyboard_hook(&self, app: AppHandle) -> Result<()> { - let mut task_guard = self.keyboard_task.write(); - if task_guard.is_some() { - return Ok(()); - } - self.start_keyboard_hook_locked(app, &mut task_guard, 0, None) - } - - fn start_keyboard_hook_locked( - &self, - app: AppHandle, - task_slot: &mut Option, - recovery_attempt: usize, - expected_generation: Option, - ) -> Result<()> { - if self.shutdown_started.load(Ordering::SeqCst) { - return Ok(()); - } - - let generation = if let Some(expected_generation) = expected_generation { - let next_generation = expected_generation.wrapping_add(1); - if self - .keyboard_task_generation - .compare_exchange( - expected_generation, - next_generation, - Ordering::SeqCst, - Ordering::SeqCst, - ) - .is_err() - { - return Ok(()); - } - next_generation - } else { - self.keyboard_task_generation - .fetch_add(1, Ordering::SeqCst) - .wrapping_add(1) - }; - - self.reset_keyboard_hook_state(&app); - - let daemon_started_at = Instant::now(); - - let current_exe = std::env::current_exe().context("failed to locate dm-note executable")?; - let shortcuts_json = serde_json::to_string(&self.store.settings_snapshot().shortcuts) - .unwrap_or_else(|_| "{}".to_string()); - - // Named Pipe 서버를 비동기로 준비 (daemon 스폰 전 블로킹 방지) - #[cfg(target_os = "windows")] - let pipe_receiver: Option>> = { - use std::sync::mpsc; - let (tx, rx) = mpsc::channel(); - std::thread::spawn( - move || match crate::ipc::pipe_server_create("dmnote_keys_v1") { - Ok(f) => { - let _ = tx.send(Some(f)); - } - Err(err) => { - warn!("failed to create named pipe: {err}"); - let _ = tx.send(None); - } - }, - ); - Some(rx) - }; - #[cfg(not(target_os = "windows"))] - let _pipe_receiver: Option>> = None; - let mut child = Command::new(current_exe) - .arg("--keyboard-daemon") - .env("DMNOTE_HOTKEYS_V1", shortcuts_json) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn keyboard daemon process")?; - - let parent_stdin = child - .stdin - .take() - .context("keyboard daemon stdin unavailable")?; - let stdout = child - .stdout - .take() - .context("keyboard daemon stdout unavailable")?; - let stderr = child.stderr.take(); - - let running = Arc::new(AtomicBool::new(true)); - let running_reader = running.clone(); - let keyboard = self.keyboard.clone(); - let app_handle = app.clone(); - - let reader_handle = thread::Builder::new() - .name("keyboard-daemon-reader".into()) - .spawn(move || { - let mut keys_state_emit_count: u64 = 0; - // Named Pipe 우선 사용; 불가 시 stdout fallback - #[allow(unused_mut)] - let mut reader: BufReader> = { - #[cfg(target_os = "windows")] - { - if let Some(rx) = pipe_receiver { - // Pipe 준비 대기; 타임아웃 시 stdout fallback - match rx.recv_timeout(Duration::from_millis(1500)) { - Ok(Some(f)) => BufReader::new(Box::new(f)), - _ => BufReader::new(Box::new(stdout)), - } - } else { - BufReader::new(Box::new(stdout)) - } - } - #[cfg(not(target_os = "windows"))] - { - BufReader::new(Box::new(stdout)) - } - }; - // Windows에서 reader 스레드 우선순위 약간 상향 - #[cfg(target_os = "windows")] - unsafe { - use windows::Win32::System::Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_ABOVE_NORMAL}; - let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL); - } - - let mut exit_reason = None; - while running_reader.load(Ordering::SeqCst) { - let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) => { - exit_reason = Some(String::from("output EOF")); - break; - } - Ok(_) => { - let s = line.trim(); - if s.is_empty() { - continue; - } - - // DaemonCommand(글로벌 단축키) 파싱 우선 시도 - if let Ok(command) = serde_json::from_str::(s) { - match command { - crate::ipc::DaemonCommand::ToggleOverlay => { - log::info!("[AppState] received ToggleOverlay command from daemon"); - let app_state = app_handle.state::(); - if app_state.is_obs_mode_active() { - log::info!("[AppState] OBS 모드 활성화 중 — 오버레이 토글 무시"); - } else { - let is_visible = *app_state.overlay_visible.read(); - if let Err(err) = app_state.set_overlay_visibility(&app_handle, !is_visible) { - log::error!("failed to toggle overlay visibility: {err}"); - } - } - } - crate::ipc::DaemonCommand::ToggleOverlayLock => { - log::info!("[AppState] received ToggleOverlayLock command from daemon"); - let app_state = app_handle.state::(); - let current = app_state.store.snapshot().overlay_locked; - match app_state.settings.apply_patch(crate::models::SettingsPatchInput { - overlay_locked: Some(!current), - ..Default::default() - }) { - Ok(diff) => { - if let Err(err) = app_state.emit_settings_changed(&diff, &app_handle) { - log::error!("failed to apply overlay lock toggle: {err}"); - } - } - Err(err) => log::error!("failed to toggle overlay lock: {err}"), - } - } - crate::ipc::DaemonCommand::ToggleAlwaysOnTop => { - log::info!("[AppState] received ToggleAlwaysOnTop command from daemon"); - let app_state = app_handle.state::(); - let current = app_state.store.snapshot().always_on_top; - match app_state.settings.apply_patch(crate::models::SettingsPatchInput { - always_on_top: Some(!current), - ..Default::default() - }) { - Ok(diff) => { - if let Err(err) = app_state.emit_settings_changed(&diff, &app_handle) { - log::error!("failed to apply always-on-top toggle: {err}"); - } - } - Err(err) => log::error!("failed to toggle always-on-top: {err}"), - } - } - } - continue; - } - - // HID 축(노브) 메시지 → input:axis 이벤트 브로드캐스트 - // (버튼은 아래 HookMessage 경로로 기존 키 시각화 재사용) - if let Ok(axis) = - serde_json::from_str::(s) - { - publish_event( - &app_handle, - "input:axis", - InputAxisPayload { - axis_id: &axis.axis_id, - value: axis.value, - full: axis.full, - }, - ); - continue; - } - - // 입력 수신 시각 — 노트 위치의 프레임 양자화 보정용 age 측정 기준 - let recv_at = Instant::now(); - - // 우선 형식: JSON 인코딩된 HookMessage (device 포함) - let parsed: Option = - serde_json::from_str(s).ok(); - - let message = if let Some(msg) = parsed { - if msg.labels.is_empty() { - continue; - } - msg - } else { - // 레거시 간소 형식: "D: