From f7fcf9ddf8c2caef641cd0e3222c89b45c1a4b40 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 15:23:52 +0900 Subject: [PATCH 01/56] =?UTF-8?q?feat:=20OBS=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20WS=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppStoreData/SettingsState에 obs_mode_enabled, obs_port 필드 추가 - models/obs.rs: WS 프로토콜 메시지 타입 (ObsEnvelope, ObsInMessage, ObsBroadcast, KeyState enum 등) - types/obs.ts: 프론트엔드 OBS 프로토콜 타입 정의 - Cargo.toml: tokio, tokio-tungstenite, futures-util 의존성 추가 Co-Authored-By: Claude Opus 4.6 --- src-tauri/Cargo.lock | 51 +++++++++++++++ src-tauri/Cargo.toml | 3 + src-tauri/src/models/mod.rs | 20 ++++++ src-tauri/src/models/obs.rs | 108 +++++++++++++++++++++++++++++++ src-tauri/src/state/app_state.rs | 2 + src-tauri/src/state/store.rs | 2 + src/renderer/defaults.ts | 3 + src/types/obs.ts | 54 ++++++++++++++++ src/types/settings/settings.ts | 2 + 9 files changed, 245 insertions(+) create mode 100644 src-tauri/src/models/obs.rs create mode 100644 src/types/obs.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4663dc3c..b35d3914 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -935,6 +935,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.5" @@ -1069,6 +1075,7 @@ dependencies = [ "base64 0.22.1", "dirs-next", "fern", + "futures-util", "gif", "gif-dispose", "log", @@ -1096,6 +1103,8 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "thread-priority", + "tokio", + "tokio-tungstenite", "uuid", "walkdir", "webp-animation", @@ -5067,9 +5076,21 @@ dependencies = [ "mio 1.1.1", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5080,6 +5101,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -5293,6 +5326,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1cad0790..b9629739 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,9 @@ sha2 = "0.10" webp-animation = "0.9.0" rodio = { version = "0.19", default-features = false } symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis", "wav", "aiff", "mp3", "isomp4", "aac", "pcm", "adpcm", "flac", "alac"] } +tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros"] } +tokio-tungstenite = "0.24" +futures-util = "0.3" [target."cfg(windows)".dependencies] windows = { version = "0.61.3", features = [ diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 8bdaac2e..1e1fadc2 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,3 +1,5 @@ +pub mod obs; + use serde::de::Error as DeError; use serde::ser::SerializeMap; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -1147,6 +1149,10 @@ fn default_auto_update_enabled() -> bool { true } +fn default_obs_port() -> u16 { + obs::DEFAULT_OBS_PORT +} + fn default_grid_snap_size() -> u32 { 5 } @@ -1282,6 +1288,12 @@ pub struct AppStoreData { /// 사운드 라이브러리 메타데이터 (키: 절대 경로, 값: 메타데이터) #[serde(default)] pub sound_library: HashMap, + /// OBS 모드 활성화 여부 + #[serde(default)] + pub obs_mode_enabled: bool, + /// OBS WebSocket 서버 포트 + #[serde(default = "default_obs_port")] + pub obs_port: u16, /// 플러그인 데이터 저장소 (plugin_data_* 키로 저장) #[serde(default, flatten)] pub plugin_data: HashMap, @@ -1332,6 +1344,8 @@ impl Default for AppStoreData { grid_settings: GridSettings::default(), shortcuts: ShortcutsState::default(), sound_library: HashMap::new(), + obs_mode_enabled: false, + obs_port: default_obs_port(), plugin_data: HashMap::new(), } } @@ -1598,6 +1612,10 @@ pub struct SettingsState { pub grid_settings: GridSettings, #[serde(default)] pub shortcuts: ShortcutsState, + #[serde(default)] + pub obs_mode_enabled: bool, + #[serde(default = "default_obs_port")] + pub obs_port: u16, } impl Default for SettingsState { @@ -1628,6 +1646,8 @@ impl Default for SettingsState { key_counter_enabled: false, grid_settings: GridSettings::default(), shortcuts: ShortcutsState::default(), + obs_mode_enabled: false, + obs_port: default_obs_port(), } } } diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs new file mode 100644 index 00000000..6c5cd6d9 --- /dev/null +++ b/src-tauri/src/models/obs.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// WS 프로토콜 버전 +pub const OBS_PROTOCOL_VERSION: u32 = 1; + +/// 기본 OBS 포트 +pub const DEFAULT_OBS_PORT: u16 = 34891; + +// ── 공통 Envelope (수신 파싱용) ── + +#[derive(Debug, Clone, Deserialize)] +pub struct ObsEnvelope { + #[serde(default)] + pub v: u32, + #[serde(rename = "type")] + pub msg_type: String, + #[serde(default)] + pub payload: Value, +} + +// ── 클라이언트 → 서버 메시지 ── + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ObsInMessage { + Hello { payload: HelloPayload }, + Ping, + ResyncRequest, +} + +// ── Payload 타입 ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HelloPayload { + pub client: String, + pub protocol: u32, + #[serde(default)] + pub app_version: String, + #[serde(default)] + pub resume_from_seq: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelloAckPayload { + pub server_version: String, + pub obs_mode: bool, +} + +/// 키 상태 (DOWN/UP) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum KeyState { + Down, + Up, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyEventPayload { + pub key: String, + pub state: KeyState, + pub mode: String, +} + +// ── 브로드캐스트 내부 메시지 (tokio::sync::broadcast용) ── + +#[derive(Debug, Clone)] +pub enum ObsBroadcast { + KeyEvent { + key: String, + state: KeyState, + mode: String, + }, + SettingsDiff(Value), + LayoutDiff(Value), + CounterUpdate(Value), + Snapshot(Value), +} + +/// OBS 연결 상태 (프론트엔드 표시용) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObsStatus { + pub running: bool, + pub port: u16, + pub client_count: u32, +} + +/// JSON envelope 생성 헬퍼 +pub fn make_envelope(msg_type: &str, seq: u64, payload: Value) -> Value { + serde_json::json!({ + "v": OBS_PROTOCOL_VERSION, + "type": msg_type, + "seq": seq, + "ts": timestamp_ms(), + "payload": payload, + }) +} + +fn timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index e02e95b1..f3179a71 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -223,6 +223,8 @@ impl AppState { key_counter_enabled: state.key_counter_enabled, grid_settings: state.grid_settings.clone(), shortcuts: state.shortcuts.clone(), + obs_mode_enabled: state.obs_mode_enabled, + obs_port: state.obs_port, }, keys: state.keys.clone(), positions: state.key_positions.clone(), diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 8af3d5cc..0efb9e43 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -295,6 +295,8 @@ fn settings_from_store(store: &AppStoreData) -> SettingsState { key_counter_enabled: store.key_counter_enabled, grid_settings: store.grid_settings.clone(), shortcuts: store.shortcuts.clone(), + obs_mode_enabled: store.obs_mode_enabled, + obs_port: store.obs_port, } } diff --git a/src/renderer/defaults.ts b/src/renderer/defaults.ts index 76d011a9..260dd883 100644 --- a/src/renderer/defaults.ts +++ b/src/renderer/defaults.ts @@ -6,6 +6,7 @@ import type { NoteSettings } from '@src/types/settings/noteSettings'; import type { GridSettings, SettingsState } from '@src/types/settings/settings'; import type { ShortcutsState } from '@src/types/settings/shortcuts'; import type { FontSettings } from '@src/types/settings/fonts'; +import { DEFAULT_OBS_PORT } from '@src/types/obs'; export interface DefaultsPayload { settings: SettingsState; @@ -210,5 +211,7 @@ function FALLBACK_SETTINGS_STATE(): SettingsState { keyCounterEnabled: false, gridSettings: FALLBACK_GRID_SETTINGS(), shortcuts: FALLBACK_SHORTCUTS(), + obsModeEnabled: false, + obsPort: DEFAULT_OBS_PORT, }; } diff --git a/src/types/obs.ts b/src/types/obs.ts new file mode 100644 index 00000000..6bd312d9 --- /dev/null +++ b/src/types/obs.ts @@ -0,0 +1,54 @@ +// OBS WebSocket 프로토콜 타입 + +export const OBS_PROTOCOL_VERSION = 1; +export const DEFAULT_OBS_PORT = 34891; + +export interface ObsEnvelope { + v: number; + type: string; + seq: number; + ts: number; + payload: T; +} + +// ── 클라이언트 → 서버 ── + +export interface HelloPayload { + client: string; + protocol: number; + appVersion: string; + resumeFromSeq: number; +} + +// ── 서버 → 클라이언트 ── + +export interface HelloAckPayload { + serverVersion: string; + obsMode: boolean; +} + +export interface KeyEventPayload { + key: string; + state: 'DOWN' | 'UP'; + mode: string; +} + +export interface ObsStatus { + running: boolean; + port: number; + clientCount: number; +} + +// ── WS 메시지 타입 문자열 ── + +export type ObsMessageType = + | 'hello' + | 'hello_ack' + | 'snapshot' + | 'key_event' + | 'settings_diff' + | 'layout_diff' + | 'counter_update' + | 'ping' + | 'pong' + | 'resync_request'; diff --git a/src/types/settings/settings.ts b/src/types/settings/settings.ts index 9ee3abe6..803bf1f1 100644 --- a/src/types/settings/settings.ts +++ b/src/types/settings/settings.ts @@ -55,6 +55,8 @@ export interface SettingsState { keyCounterEnabled: boolean; gridSettings: GridSettings; shortcuts: ShortcutsState; + obsModeEnabled: boolean; + obsPort: number; } /** @deprecated Use getDefaultSettingsState() from @src/renderer/defaults */ From 3976c45a5d5f677185987dc1b82d35679154c2fe Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 15:33:26 +0900 Subject: [PATCH 02/56] =?UTF-8?q?feat:=20ObsBridgeService=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(WebSocket=20=EC=84=9C=EB=B2=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tokio-tungstenite 기반 WS 서버 (localhost 전용) - broadcast 채널로 키 이벤트/설정/레이아웃 변경 릴레이 - 클라이언트별 seq 카운터 (gap 감지 지원) - hello 핸드셰이크 (5초 타임아웃) → snapshot 전송 → 메인 루프 - compare_exchange로 start() 원자적 보장 - stop() 시 broadcast 채널 종료로 클라이언트 자연 종료 - Lagged 시 자동 snapshot 재전송 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/obs_bridge.rs | 359 +++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 src-tauri/src/services/obs_bridge.rs diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 9a7db04f..5a53d589 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,2 +1,3 @@ pub mod css_watcher; +pub mod obs_bridge; pub mod settings; diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs new file mode 100644 index 00000000..7a61fb96 --- /dev/null +++ b/src-tauri/src/services/obs_bridge.rs @@ -0,0 +1,359 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use parking_lot::RwLock; +use serde_json::Value; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{broadcast, oneshot}; +use tokio_tungstenite::tungstenite::Message; + +use crate::models::obs::{ + make_envelope, HelloAckPayload, KeyEventPayload, KeyState, ObsBroadcast, ObsEnvelope, + ObsStatus, +}; + +/// OBS WebSocket 서버 +pub struct ObsBridgeService { + running: AtomicBool, + port: RwLock, + client_count: AtomicU32, + cached_snapshot: RwLock, + broadcast_tx: broadcast::Sender, + shutdown_tx: RwLock>>, + /// 빌드된 OBS 정적 파일 경로 (dist/renderer/obs) + static_dir: RwLock>, + server_version: String, +} + +impl ObsBridgeService { + pub fn new(version: &str) -> Self { + let (broadcast_tx, _) = broadcast::channel(256); + Self { + running: AtomicBool::new(false), + port: RwLock::new(0), + client_count: AtomicU32::new(0), + cached_snapshot: RwLock::new(Value::Null), + broadcast_tx, + shutdown_tx: RwLock::new(None), + static_dir: RwLock::new(None), + server_version: version.to_string(), + } + } + + pub fn set_static_dir(&self, dir: PathBuf) { + *self.static_dir.write() = Some(dir); + } + + pub fn is_running(&self) -> bool { + self.running.load(Ordering::Relaxed) + } + + pub fn client_count(&self) -> u32 { + self.client_count.load(Ordering::Relaxed) + } + + pub fn status(&self) -> ObsStatus { + ObsStatus { + running: self.is_running(), + port: *self.port.read(), + client_count: self.client_count(), + } + } + + pub fn update_snapshot(&self, snapshot: Value) { + *self.cached_snapshot.write() = snapshot; + } + + pub fn broadcast_key_event(&self, key: String, state: KeyState, mode: String) { + let _ = self.broadcast_tx.send(ObsBroadcast::KeyEvent { key, state, mode }); + } + + pub fn broadcast_settings_diff(&self, diff: Value) { + let _ = self.broadcast_tx.send(ObsBroadcast::SettingsDiff(diff)); + } + + pub fn broadcast_layout_diff(&self, diff: Value) { + let _ = self.broadcast_tx.send(ObsBroadcast::LayoutDiff(diff)); + } + + pub fn broadcast_counter_update(&self, data: Value) { + let _ = self.broadcast_tx.send(ObsBroadcast::CounterUpdate(data)); + } + + /// 전체 스냅샷 재전송 (프리셋 로드 등 대규모 변경 시) + pub fn broadcast_snapshot(&self) { + let snapshot = self.cached_snapshot.read().clone(); + let _ = self.broadcast_tx.send(ObsBroadcast::Snapshot(snapshot)); + } + + /// WS 서버 시작 + pub async fn start(self: &Arc, port: u16) -> Result<(), String> { + // 원자적 check-and-set + if self + .running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return Err("OBS bridge already running".to_string()); + } + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + self.running.store(false, Ordering::Relaxed); + return Err(format!("포트 {port} 바인드 실패: {e}")); + } + }; + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + *self.shutdown_tx.write() = Some(shutdown_tx); + *self.port.write() = port; + + let bridge = Arc::clone(self); + tokio::spawn(async move { + bridge.server_loop(listener, shutdown_rx).await; + }); + + log::info!("[ObsBridge] 서버 시작: ws://127.0.0.1:{port}/ws"); + Ok(()) + } + + /// 서버 종료 + pub fn stop(&self) { + if !self.running.load(Ordering::Relaxed) { + return; + } + if let Some(tx) = self.shutdown_tx.write().take() { + let _ = tx.send(()); + } + self.running.store(false, Ordering::Relaxed); + // client_count는 리셋하지 않음 — 각 클라이언트 세션이 + // broadcast 채널 종료를 감지하고 자연 종료되면서 스스로 감소 + log::info!("[ObsBridge] 서버 종료"); + } + + async fn server_loop( + self: &Arc, + listener: TcpListener, + mut shutdown_rx: oneshot::Receiver<()>, + ) { + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, addr)) => { + let bridge = Arc::clone(self); + tokio::spawn(async move { + bridge.handle_connection(stream, addr).await; + }); + } + Err(e) => { + log::warn!("[ObsBridge] accept 실패: {e}"); + } + } + } + _ = &mut shutdown_rx => { + log::info!("[ObsBridge] shutdown 신호 수신"); + break; + } + } + } + self.running.store(false, Ordering::Relaxed); + } + + async fn handle_connection(self: &Arc, stream: TcpStream, addr: SocketAddr) { + let ws_stream = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(_) => { + // WS 핸드셰이크 실패 (일반 HTTP 등) — 현재는 무시 + // HTTP 정적 파일 서빙은 후속 구현 (hyper/axum 통합) + log::debug!("[ObsBridge] non-WS connection from {addr}"); + return; + } + }; + self.handle_ws_client(ws_stream, addr).await; + } + + async fn handle_ws_client( + self: &Arc, + ws: tokio_tungstenite::WebSocketStream, + addr: SocketAddr, + ) { + self.client_count.fetch_add(1, Ordering::Relaxed); + log::info!( + "[ObsBridge] 클라이언트 연결: {addr} (총 {})", + self.client_count() + ); + + let (mut ws_tx, mut ws_rx) = ws.split(); + 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 + }; + + // hello 핸드셰이크 대기 (5초 타임아웃) + let hello_result = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(msg) = ws_rx.next().await { + match msg { + Ok(Message::Text(text)) => { + if let Ok(envelope) = serde_json::from_str::(&text) { + if envelope.msg_type == "hello" { + return Some(envelope); + } + } + } + Ok(Message::Close(_)) => return None, + _ => {} + } + } + None + }) + .await; + + let _hello = match hello_result { + Ok(Some(envelope)) => envelope, + _ => { + log::warn!("[ObsBridge] {addr}: hello 타임아웃 또는 연결 종료"); + self.client_count.fetch_sub(1, Ordering::Relaxed); + return; + } + }; + + // hello_ack 전송 + let ack_payload = serde_json::to_value(HelloAckPayload { + server_version: self.server_version.clone(), + obs_mode: true, + }) + .unwrap_or_default(); + let ack_msg = make_envelope("hello_ack", next_seq(), ack_payload); + if ws_tx + .send(Message::Text(ack_msg.to_string().into())) + .await + .is_err() + { + self.client_count.fetch_sub(1, Ordering::Relaxed); + return; + } + + // snapshot 전송 + let snapshot = self.cached_snapshot.read().clone(); + let snapshot_msg = make_envelope("snapshot", next_seq(), snapshot); + if ws_tx + .send(Message::Text(snapshot_msg.to_string().into())) + .await + .is_err() + { + self.client_count.fetch_sub(1, Ordering::Relaxed); + return; + } + + // 메인 루프: broadcast 수신 + 클라이언트 메시지 수신 + let mut ping_interval = tokio::time::interval(Duration::from_secs(30)); + + loop { + tokio::select! { + // broadcast 채널에서 메시지 수신 → 클라이언트에 전송 + result = broadcast_rx.recv() => { + match result { + Ok(broadcast) => { + let msg = broadcast_to_envelope(&broadcast, next_seq()); + if ws_tx.send(Message::Text(msg.to_string().into())).await.is_err() { + break; + } + } + 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); + if ws_tx.send(Message::Text(msg.to_string().into())).await.is_err() { + break; + } + } + // broadcast 채널 닫힘 = 서버 종료 + Err(broadcast::error::RecvError::Closed) => break, + } + } + // 클라이언트에서 메시지 수신 + msg = ws_rx.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + 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); + if ws_tx.send(Message::Text(pong.to_string().into())).await.is_err() { + break; + } + } + "resync_request" => { + let snapshot = self.cached_snapshot.read().clone(); + let msg = make_envelope("snapshot", next_seq(), snapshot); + if ws_tx.send(Message::Text(msg.to_string().into())).await.is_err() { + break; + } + } + _ => {} + } + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(data))) => { + let _ = ws_tx.send(Message::Pong(data)).await; + } + _ => {} + } + } + // 서버 주도 ping (연결 유지) + _ = ping_interval.tick() => { + let ping_msg = make_envelope("ping", next_seq(), Value::Null); + if ws_tx.send(Message::Text(ping_msg.to_string().into())).await.is_err() { + break; + } + } + } + } + + self.client_count.fetch_sub(1, Ordering::Relaxed); + log::info!( + "[ObsBridge] 클라이언트 연결 종료: {addr} (남은 {})", + self.client_count() + ); + } +} + +/// ObsBroadcast → JSON envelope 변환 +fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { + match broadcast { + ObsBroadcast::KeyEvent { key, state, mode } => { + let payload = serde_json::to_value(KeyEventPayload { + key: key.clone(), + state: state.clone(), + mode: mode.clone(), + }) + .unwrap_or_default(); + make_envelope("key_event", seq, payload) + } + ObsBroadcast::SettingsDiff(diff) => { + make_envelope("settings_diff", seq, diff.clone()) + } + ObsBroadcast::LayoutDiff(diff) => { + make_envelope("layout_diff", seq, diff.clone()) + } + ObsBroadcast::CounterUpdate(data) => { + make_envelope("counter_update", seq, data.clone()) + } + ObsBroadcast::Snapshot(snapshot) => { + make_envelope("snapshot", seq, snapshot.clone()) + } + } +} From fd584782bb932dbe2b214737d4440590fdecf49e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 15:47:55 +0900 Subject: [PATCH 03/56] =?UTF-8?q?feat:=20AppState=EC=97=90=20ObsBridge=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20+=20OBS=20Tauri=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=20+=20=ED=82=A4/=EC=84=A4=EC=A0=95=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obs_bridge 필드를 AppState에 추가, initialize에서 생성 - obs_start/obs_stop/obs_status Tauri 커맨드 추가 - 키 이벤트 루프에서 broadcast_key_event 호출 - emit_settings_changed에서 broadcast_settings_diff 호출 - ObsBroadcast::Shutdown으로 stop 시 기존 클라이언트 세션 종료 Co-Authored-By: Claude Opus 4.6 --- docs/note-effect-optimization-plan.md | 533 -------------------- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/permissions/dmnote-allow-all.json | 3 + src-tauri/src/commands/app/mod.rs | 1 + src-tauri/src/commands/app/obs.rs | 27 + src-tauri/src/main.rs | 4 + src-tauri/src/models/obs.rs | 2 + src-tauri/src/services/obs_bridge.rs | 6 +- src-tauri/src/state/app_state.rs | 27 +- 9 files changed, 68 insertions(+), 537 deletions(-) delete mode 100644 docs/note-effect-optimization-plan.md create mode 100644 src-tauri/src/commands/app/obs.rs diff --git a/docs/note-effect-optimization-plan.md b/docs/note-effect-optimization-plan.md deleted file mode 100644 index c402a0d5..00000000 --- a/docs/note-effect-optimization-plan.md +++ /dev/null @@ -1,533 +0,0 @@ -# 노트 효과 프레임 드랍 최적화 계획 - -> 작성일: 2026-03-06 -> 목표: 노트 효과 활성화 시 게임 및 오버레이 프레임 안정화 -> 원칙: 시스템 자원 사용 증가 허용, 프레임 안정성 최우선 - ---- - -## 1. 현재 아키텍처 요약 - -``` -[Tauri 백엔드] → onKeyState → [KeyEventBus] → [App.tsx 리스너] - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ - 키 UI 업데이트 useNoteSystem WebGL 렌더러 - (Preact Signals) (노트 생성/종료) (OGL instanced) - │ - ▼ - NoteBuffer - (Float32Array × 9) - │ - ▼ - animationScheduler - (rAF 루프) -``` - -### 핵심 파일 - -| 파일 | 역할 | 크기 | -|------|------|------| -| `src/renderer/hooks/overlay/useNoteSystem.ts` | 노트 생명주기 관리 | 631줄 | -| `src/renderer/stores/signals/noteBuffer.ts` | GPU 버퍼 데이터 관리 | 561줄 | -| `src/renderer/components/overlay/WebGLTracksOGL.tsx` | WebGL 렌더링 | 711줄 | -| `src/renderer/windows/overlay/App.tsx` | 오버레이 루트 컴포넌트 | 948줄 | -| `src/renderer/utils/animation/animationScheduler.ts` | rAF 스케줄러 | 31줄 | -| `src/renderer/utils/core/keyEventBus.ts` | 키 이벤트 버스 | 59줄 | - ---- - -## 2. 병목 분석 및 심각도 평가 - -### 2.1 [Critical] NoteBuffer의 O(n) 삽입/삭제 - -**위치**: `noteBuffer.ts` - `allocate()`, `release()`, `releaseBatch()` - -**문제**: -- `allocate()` 시 trackIndex 기준 정렬 삽입 → 삽입 위치 이후 모든 슬롯을 `copyWithin`으로 시프트 -- 9개의 Float32Array(noteInfo, noteSize, noteColorTop, noteColorBottom, noteRadius, trackIndex, noteGlow, noteGlowColorTop, noteGlowColorBottom)에 대해 각각 `copyWithin` 실행 -- `release()`/`releaseBatch()`에서도 동일한 O(n) 시프트 발생 -- 빠른 키 입력(예: 200+ KPS) 시 매 키 입력마다 CPU 스파이크 발생 - -**영향**: CPU 메인스레드 블로킹 → rAF 콜백 지연 → 프레임 드랍 - -**수치 추정**: MAX_NOTES=2048, Float32Array 9개, 각 1~4 컴포넌트 → 최악의 경우 한 번의 allocate에서 ~150KB 메모리 이동 - -### 2.2 [High] requestAnimationFrame 래핑으로 인한 입력 지연 - -**위치**: `overlay/App.tsx:526-529` - -```typescript -requestAnimationFrame(() => { - if (isDown) handleKeyDown(key); - else handleKeyUp(key); -}); -``` - -**문제**: -- 키 이벤트 도착 시 노트 생성/종료를 다음 프레임으로 지연 -- burst 입력 시 여러 이벤트가 같은 rAF 콜백으로 배치되어 타이밍 정확도 저하 -- 노트의 `startTime`이 실제 키 입력 시점과 최대 16.67ms 어긋남 - -**영향**: 노트 타이밍 부정확 + 불필요한 1프레임 지연 - -### 2.3 [High] 오버레이 App 리렌더링 → 불필요한 재계산/재구독 - -**위치**: `overlay/App.tsx` - -**문제 1 - webglTracks 매 렌더 재생성 (721~763줄)**: -```typescript -const webglTracks = currentKeys.map((key, index) => { ... }).filter(Boolean); - -useEffect(() => { - updateTrackLayouts(webglTracks); -}, [webglTracks, updateTrackLayouts]); // 매 렌더마다 실행 -``` -- `webglTracks`가 매 렌더링마다 새 배열로 생성됨 -- `useEffect` 참조 비교 실패 → 매번 `updateTrackLayouts` 호출 -- `updateTrackLayouts` 내부에서 `resolveTrackLayout` (색상 파싱 등) 반복 실행 - -**문제 2 - 키 이벤트 리스너 재등록 (555~562줄)**: -```typescript -useEffect(() => { - // 키 이벤트 구독 로직... -}, [handleKeyDown, handleKeyUp, noteEffect, keyMappings, positions, selectedKeyType]); -``` -- 6개 의존성 중 하나라도 변경 시 구독 해제 → 재구독 -- `handleKeyDown`/`handleKeyUp`은 `noteEffect` 변경 시 새 함수 참조 생성 - -### 2.4 [High] WebGL 컨텍스트의 GPU 자원 경쟁 - -**위치**: `WebGLTracksOGL.tsx` - -**문제**: -- 오버레이의 WebGL 컨텍스트가 게임과 동일한 GPU를 공유 -- 투명 배경 + 블렌딩 + glow 효과로 인한 fill-rate 부담 -- fragment shader에서 SDF rounded rect + glow `pow` 연산 수행 -- glow가 큰 노트는 실제 렌더링 면적이 노트 본체의 수배 - -**영향**: GPU 경쟁으로 게임 측 프레임 드랍 - -### 2.5 [High] Tauri 오버레이 창의 컴포지팅 오버헤드 - -**문제**: -- 투명 창 + always-on-top + DWM 합성 -- Windows에서 borderless fullscreen 게임과 겹칠 때 DWM 합성 비용 증가 -- 오버레이 창 크기가 실제 콘텐츠보다 클 수 있음 - -### 2.6 [Medium] setTimeout 기반 클린업/종료 스케줄링 - -**위치**: `useNoteSystem.ts` - `scheduleCleanup()`, `scheduleNoteFinalization()` - -**문제**: -- 각 노트 종료마다 `setTimeout` 생성 -- 짧은 노트가 빠르게 생성될 때 타이머 과다 생성 가능 -- `setTimeout` 정확도 한계 (최소 4ms, 실제로는 더 큰 지터) -- 메인스레드 wake-up 빈도 증가 - -### 2.7 [Medium] 미사용 trackIndex attribute - -**위치**: `noteBuffer.ts`, `WebGLTracksOGL.tsx` - -**문제**: -- `trackIndex` attribute가 vertex shader에서 선언되지만 실제 렌더링 로직에 사용되지 않음 -- 그럼에도 allocate/release 시 copyWithin 대상에 포함 -- 불필요한 메모리 이동 및 GPU 업로드 - -### 2.8 [Low] animationScheduler의 전역 태스크 순회 - -**위치**: `animationScheduler.ts` - -**문제**: 오버레이 외 다른 태스크가 추가되면 매 프레임 간섭 가능성 - ---- - -## 3. 최적화 실행 계획 - -### Phase 1: CPU 메모리 이동 제거 (Critical - 최우선) - -#### 1-1. NoteBuffer를 Free-list/Swap-remove 구조로 교체 - -**현재**: 정렬 유지를 위해 삽입/삭제 시 O(n) copyWithin × 9개 배열 - -**변경 방안**: - -``` -방안 A: Swap-remove + GPU 정렬 -- 삽입: 항상 activeCount 위치에 추가 (O(1)) -- 삭제: 마지막 슬롯과 swap 후 activeCount-- (O(1)) -- 그리기 순서: trackIndex를 셰이더의 z값으로 사용하여 GPU에서 처리 -- 장점: 구현 단순, CPU 부담 최소 -- 단점: 투명 블렌딩 시 z-test만으로 정확한 순서 보장 어려울 수 있음 - -방안 B: Free-list 슬롯 할당자 -- 삭제된 슬롯을 free-list로 관리 -- 새 노트는 free-list에서 빈 슬롯 획득 (O(1)) -- 삭제 시 슬롯만 free-list에 반환 (O(1), 메모리 이동 없음) -- instancedCount 대신 shader에서 startTime == 0인 슬롯 스킵 (이미 구현됨) -- 장점: 메모리 이동 완전 제거, 기존 셰이더 호환 -- 단점: 빈 슬롯이 GPU에 업로드되어 약간의 낭비 (2048 고정이므로 무시 가능) - -권장: 방안 B (Free-list) -``` - -**구현 세부사항**: -- `freeSlots: number[]` (스택) 추가 -- `allocate()`: freeSlots에서 pop, 없으면 nextSlot++ (O(1)) -- `release()`: 슬롯의 noteInfo를 0으로 클리어하고 freeSlots에 push (O(1)) -- `releaseBatch()`: 각 슬롯을 개별 클리어 후 freeSlots에 추가 (O(k), k=삭제 수) -- `instancedCount`를 `maxAllocatedSlot`으로 변경하여 shader가 빈 슬롯 스킵 -- copyWithin 호출 완전 제거 - -**예상 효과**: allocate/release당 CPU 시간 O(n) → O(1), 9개 배열 시프트 완전 제거 - -**리스크**: -- 노트 겹침 시 렌더 순서가 변경될 수 있음 → 시각적 회귀 테스트 필요 -- 기존 셰이더에서 `startTime == 0`이면 화면 밖으로 보내는 로직이 이미 있어 호환성 양호 - ---- - -### Phase 2: 이벤트 처리 지연 제거 (High) - -#### 2-1. 키 이벤트 rAF 래핑 제거 - -**현재** (`overlay/App.tsx:526-529`): -```typescript -requestAnimationFrame(() => { - if (isDown) handleKeyDown(key); - else handleKeyUp(key); -}); -``` - -**변경**: -```typescript -// 즉시 실행 - 노트 데이터 생성은 동기, GPU 업로드만 다음 프레임 -if (isDown) handleKeyDown(key); -else handleKeyUp(key); -``` - -**근거**: `handleKeyDown`/`handleKeyUp`은 NoteBuffer에 데이터를 쓰고 subscriber에게 이벤트를 알리는 것뿐. WebGL 렌더러의 `handleNoteEvent`가 이미 attribute 업데이트를 큐잉하고 다음 프레임에 배치 처리하므로 rAF 래핑은 불필요한 1프레임 지연. - -**리스크**: 짧은 노트 판정 타이밍이 변경될 수 있음 → 단노트/롱노트 분기 테스트 필요 - -#### 2-2. 키 이벤트 리스너 1회 구독 + Ref 기반 최신값 접근 - -**현재**: 6개 의존성 변경마다 구독 해제/재구독 - -**변경**: -```typescript -// Ref로 최신값 유지 -const noteEffectRef = useRef(noteEffect); -const keyMappingsRef = useRef(keyMappings); -const positionsRef = useRef(positions); -const selectedKeyTypeRef = useRef(selectedKeyType); -const handleKeyDownRef = useRef(handleKeyDown); -const handleKeyUpRef = useRef(handleKeyUp); - -useEffect(() => { - noteEffectRef.current = noteEffect; - keyMappingsRef.current = keyMappings; - positionsRef.current = positions; - selectedKeyTypeRef.current = selectedKeyType; - handleKeyDownRef.current = handleKeyDown; - handleKeyUpRef.current = handleKeyUp; -}); - -// 구독은 1회만 -useEffect(() => { - keyEventBus.initialize(); - const unsub = keyEventBus.subscribe(({ key, state }) => { - const isDown = state === 'DOWN'; - updateKeySignalWithDelay(key, isDown); - if (noteEffectRef.current) { - const keys = keyMappingsRef.current[selectedKeyTypeRef.current] ?? []; - const pos = positionsRef.current[selectedKeyTypeRef.current] ?? []; - const idx = keys.indexOf(key); - if (pos[idx]?.noteEffectEnabled !== false) { - if (isDown) handleKeyDownRef.current(key); - else handleKeyUpRef.current(key); - } - } - }); - return () => unsub(); -}, []); // 의존성 없음 - 1회만 구독 -``` - -**예상 효과**: 불필요한 구독 해제/재구독 제거, 클로저 갱신 문제 해결 - ---- - -### Phase 3: React 리렌더링 최소화 (High) - -#### 3-1. webglTracks 메모이제이션 - -**현재**: 매 렌더링마다 `webglTracks` 배열 재생성 → `updateTrackLayouts` 매번 호출 - -**변경**: `useMemo`로 안정화 -```typescript -const webglTracks = useMemo(() => { - return currentKeys.map((key, index) => { - // ... 기존 로직 - }).filter(Boolean); -}, [currentKeys, currentPositions, displayPositions, topMostY, noteSettings?.speed, trackHeight]); -``` - -**추가**: `updateTrackLayouts`도 내부적으로 이전 값과 비교하여 변경 시에만 실제 업데이트 - -#### 3-2. 오버레이 레이어 분리 - -**현재**: App 루트에서 모든 상태를 관리하여 하나의 변경이 전체 리렌더 유발 - -**변경**: 독립적인 레이어로 분리 -``` -App (최소 상태만) -├── NoteEffectLayer (WebGL 전용, 노트 관련 상태만) -├── KeyLayer (키 UI 전용) -├── StatLayer (통계 전용) -├── GraphLayer (그래프 전용) -└── PluginLayer (플러그인 전용) -``` - -각 레이어가 자신의 store/signal만 구독하여 교차 리렌더링 방지 - ---- - -### Phase 4: GPU 업로드 최적화 (High) - -#### 4-1. Attribute 분리: 동적 vs 정적 - -**자주 변경되는 attribute** (매 프레임 또는 노트 이벤트마다): -- `noteInfo` (startTime, endTime, trackX) → 노트 생성/종료 시 - -**거의 변경되지 않는 attribute** (노트 생성 시 1회): -- `noteSize`, `noteColorTop`, `noteColorBottom`, `noteRadius`, `noteGlow`, `noteGlowColorTop`, `noteGlowColorBottom` - -**변경**: -- 동적 attribute: `DYNAMIC_DRAW` 유지, 부분 업로드 (`bufferSubData`) -- 정적 attribute: `STATIC_DRAW`로 변경, 노트 생성 시에만 해당 슬롯 업로드 - -#### 4.2. 부분 업로드 (Dirty Range Tracking) - -**현재**: 이벤트마다 전체 attribute에 `needsUpdate = true` - -**변경**: -```typescript -interface DirtyRange { - start: number; // 시작 슬롯 - end: number; // 끝 슬롯 (exclusive) -} - -// 노트 추가 시: 해당 슬롯만 dirty -// 노트 종료 시: noteInfo의 해당 슬롯만 dirty -// 프레임 시작 시: dirty range를 병합하여 bufferSubData 1회 호출 -``` - -**주의**: OGL 라이브러리가 `bufferSubData`를 직접 지원하지 않을 수 있음 → raw WebGL 호출 필요할 수 있음 - -#### 4-3. 미사용 trackIndex attribute 제거 - -- vertex shader에서 `trackIndex` 선언 제거 -- NoteBuffer에서 `trackIndex` Float32Array 제거 -- allocate/release/releaseBatch에서 관련 copyWithin/fill 제거 - -**효과**: 메모리 이동량 ~11% 감소, GPU 업로드 1개 attribute 감소 - ---- - -### Phase 5: 타이머 기반 스케줄링 개선 (Medium) - -#### 5-1. Deadline Queue로 통합 - -**현재**: 각 노트 종료마다 개별 `setTimeout` - -**변경**: -```typescript -// Min-heap 기반 deadline queue -class DeadlineQueue { - private heap: { time: number; noteId: string; keyName: string }[] = []; - private timer: ReturnType | null = null; - - add(deadline: number, noteId: string, keyName: string): void { ... } - - // 다음 deadline에 맞춰 단일 타이머만 유지 - private scheduleNext(): void { - if (this.timer) clearTimeout(this.timer); - if (this.heap.length === 0) return; - const delay = Math.max(0, this.heap[0].time - performance.now()); - this.timer = setTimeout(() => this.processExpired(), delay); - } - - private processExpired(): void { - const now = performance.now(); - while (this.heap.length > 0 && this.heap[0].time <= now) { - const { noteId, keyName } = this.extractMin(); - finalizeNote(keyName, noteId); - } - this.scheduleNext(); - } -} -``` - -**효과**: 타이머 수 N개 → 1개로 감소, 메인스레드 wake-up 최소화 - -#### 5-2. 클린업을 Animation Tick으로 이동 - -**현재**: `setTimeout` 기반 클린업 스케줄링 - -**변경**: -- animation loop 내에서 매 프레임(또는 N프레임마다) 만료된 노트 확인 -- 이미 rAF 루프가 돌고 있으므로 추가 타이머 불필요 -- 노트가 없으면 루프 자체가 멈추므로 idle 시 비용 없음 - ---- - -### Phase 6: GPU/렌더링 부담 감소 (High) - -#### 6-1. 성능 모드 도입 - -사용자 설정으로 제공: - -| 옵션 | 기본값 | 성능 모드 | -|------|--------|-----------| -| Glow 효과 | ON | OFF | -| DPR | 시스템값 | 1 (강제) | -| Frame Limit | 무제한 | 게임 FPS 약수 (예: 60) | -| 최대 동시 노트 수 | 2048 | 512 또는 256 | -| Fragment precision | highp | mediump | -| Fade 효과 | ON | OFF | -| 라운드 코너 반경 | 사용자 설정값 | 0 (사각형) | - -#### 6-2. Fragment Shader 최적화 - -**현재 비용이 높은 연산**: -- SDF rounded rectangle 계산 -- Glow falloff: `pow(glowFalloff, 2.0)` -- Fade mask 계산 (top/bottom) -- 색상 그라디언트 보간 - -**최적화**: -```glsl -// 성능 모드: glow/fade/round 분기 제거 -#ifdef PERFORMANCE_MODE - // 단순 사각형, glow 없음, fade 없음 - float bodyAlpha = baseColor.a; - gl_FragColor = vec4(baseColor.rgb * bodyAlpha, bodyAlpha); -#else - // 기존 풀 퀄리티 로직 -#endif -``` - -또는 런타임에 두 개의 Program을 미리 컴파일하고 모드에 따라 교체 - -#### 6-3. 화면 밖 노트 조기 컬링 강화 - -**현재**: vertex shader에서 `trackTopY`/`trackBottomY` 기준 클리핑 - -**추가**: -- CPU 측에서 화면 밖 노트를 더 공격적으로 감지하여 instancedCount에서 제외 -- 또는 vertex shader에서 조기 discard 조건 추가 - ---- - -### Phase 7: OS/창 레벨 최적화 (High) - -#### 7-1. 오버레이 창 크기 최소화 - -**현재**: 콘텐츠 bounding box + padding으로 리사이즈 - -**추가**: -- 노트 트랙 영역만 별도 계산하여 WebGL canvas 크기를 최소화 -- 비어있는 영역은 투명으로 두되 창 자체를 더 작게 - -#### 7-2. 유휴 시 프레임 최소화 - -**현재**: 노트가 없으면 animation loop 중지 (이미 구현) - -**추가**: -- 노트가 없고 키 입력도 없는 상태가 일정 시간 지속되면 오버레이 창 숨김 -- 키 입력 재개 시 즉시 표시 -- 설정 UI에서 토글 가능 - -#### 7-3. Windows DWM 합성 최적화 - -- `set_ignore_cursor_events(true)` 유지하여 히트 테스트 비용 제거 -- 투명 영역 최소화: 실제 렌더링 영역만 포함하도록 창 크기 축소 -- 가능하면 `WS_EX_NOREDIRECTIONBITMAP` 스타일 검토 (Tauri 지원 확인 필요) - ---- - -## 4. 구현 우선순위 및 일정 - -| 순서 | 작업 | 심각도 | 예상 효과 | 리스크 | -|------|------|--------|-----------|--------| -| 1 | NoteBuffer Free-list 구조 전환 | Critical | CPU 스파이크 제거 | 렌더 순서 변경 가능 → 시각 테스트 | -| 2 | rAF 래핑 제거 + 리스너 1회 구독 | High | 입력 지연 제거, 재구독 비용 제거 | 짧은 노트 타이밍 회귀 가능 | -| 3 | webglTracks 메모이제이션 + 리렌더 분리 | High | 불필요한 재계산/재구독 제거 | 리팩터 범위가 넓을 수 있음 | -| 4 | GPU 부분 업로드 + attribute 분리 | High | GPU 업로드 대역폭 감소 | OGL 추상화 한계 시 raw WebGL 필요 | -| 5 | 타이머 → Deadline Queue 전환 | Medium | 메인스레드 wake-up 감소 | 판정 타이밍 변경 → 테스트 필요 | -| 6 | 성능 모드 도입 (glow/DPR/frame cap 등) | High | GPU 부담 대폭 감소 | 화질 저하 (사용자 선택) | -| 7 | OS/창 레벨 최적화 | High | 컴포지팅 비용 감소 | UX 변경 가능 | - ---- - -## 5. 추가 발견 사항 - -### 5-1. Fragment Shader fill-rate 문제 - -노트 효과의 프레임 드랍은 노트 **개수**보다 **그려지는 픽셀 수**에 더 민감할 수 있음. Glow 효과가 활성화되면 노트 본체 대비 렌더링 면적이 `(noteWidth + glowSize*2) × (noteLength + glowSize*2)`로 확장되어 fill-rate 부담이 급증. - -### 5-2. updateTrackLayouts 데이터 일관성 - -`updateTrackLayouts()`가 활성 노트가 존재하는 상태에서 호출되면 `trackIndex`만 갱신하고 위치/폭/색상은 기존 값 유지. 레이아웃이 동적으로 변경되는 경우 노트 데이터 불일치 가능성 있음. - -### 5-3. 플러그인/통계/그래프 레이어의 compositor 경쟁 - -노트 효과와 동일한 compositor 경로를 공유. 성능 모드에서 이들 레이어의 애니메이션 비활성화 옵션도 고려 필요. - -### 5-4. dynamic import의 반복 호출 - -`overlay/App.tsx`의 키 이벤트 구독 `useEffect` 내부에서 `import('@utils/core/keyEventBus')`를 사용하지만, 의존성 변경 시마다 재실행되어 dynamic import가 반복 호출됨. 모듈 캐시로 실제 네트워크 비용은 없으나, Promise 체인 오버헤드는 있음. - ---- - -## 6. 리스크 및 주의사항 - -1. **시각적 회귀**: NoteBuffer 구조 변경 시 노트 겹침 순서가 달라질 수 있음. 변경 전후 스크린샷 비교 필수. - -2. **타이밍 정확도**: rAF 래핑 제거 및 타이머 통합 시 단노트/롱노트 판정 로직에 영향. 다양한 KPS 시나리오에서 테스트 필요. - -3. **OGL 라이브러리 한계**: `bufferSubData` 등 저수준 WebGL 접근이 필요할 경우 OGL 추상화를 우회해야 할 수 있음. `renderer.gl`로 직접 접근 가능하지만 OGL의 내부 상태와 충돌 가능성. - -4. **성능 모드 UX**: 사용자가 성능 모드의 존재를 인지하고 쉽게 토글할 수 있어야 함. 기본값은 자동 감지(프레임 드랍 감지 시 제안) 또는 수동 선택. - -5. **크로스 플랫폼**: macOS는 이미 DPR 1 제한이 있으나, Windows에서의 DWM 최적화는 별도 검증 필요. - ---- - -## 7. 검증 방법 - -### 프레임 측정 -- 오버레이: `animationScheduler` 내부에 frame time 로깅 추가 -- 게임: 외부 FPS 카운터 (MSI Afterburner, RTSS 등) 사용 -- 시나리오: 200+ KPS 자동 입력 시뮬레이션 - -### 메모리 프로파일링 -- Chrome DevTools Memory 탭으로 NoteBuffer 할당/해제 패턴 확인 -- Float32Array copyWithin 호출 빈도 측정 (Performance 탭) - -### GPU 프로파일링 -- Chrome DevTools Performance 탭의 GPU 레인 확인 -- WebGL Inspector로 draw call 수 및 업로드 크기 모니터링 - ---- - -## 8. 요약 - -``` -최적화 흐름: -CPU 메모리 이동 제거 → 이벤트 지연 제거 → React 재구독/재렌더 제거 -→ GPU 업로드 범위 축소 → 합성 비용 절감 -``` - -핵심은 **NoteBuffer의 O(n) → O(1) 전환**과 **불필요한 rAF 래핑/리렌더링 제거**. 이 두 가지만으로도 상당한 프레임 안정화가 예상되며, 이후 GPU/OS 레벨 최적화로 게임 측 프레임 영향을 추가 감소시킬 수 있음. diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 1d38aec3..91d89bfe 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_open_external","app_quit","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_load","css_reset","css_set_content","css_tab_clear","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_status","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","keys_update","layer_groups_get","layer_groups_update","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","plugin_bridge_send","plugin_bridge_send_to","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","positions_update","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_save_processed_wav","sound_set_enabled","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_open_external","app_quit","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_load","css_reset","css_set_content","css_tab_clear","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_status","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","keys_update","layer_groups_get","layer_groups_update","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_start","obs_status","obs_stop","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","plugin_bridge_send","plugin_bridge_send_to","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","positions_update","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_save_processed_wav","sound_set_enabled","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index dfbb23d2..69f0b1b9 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -67,6 +67,9 @@ "note_tab_get", "note_tab_get_all", "note_tab_set", + "obs_start", + "obs_status", + "obs_stop", "overlay_get", "overlay_resize", "overlay_set_anchor", diff --git a/src-tauri/src/commands/app/mod.rs b/src-tauri/src/commands/app/mod.rs index 979f4d9b..da370eff 100644 --- a/src-tauri/src/commands/app/mod.rs +++ b/src-tauri/src/commands/app/mod.rs @@ -1,3 +1,4 @@ pub mod bootstrap; +pub mod obs; pub mod system; pub mod update; diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs new file mode 100644 index 00000000..4da76985 --- /dev/null +++ b/src-tauri/src/commands/app/obs.rs @@ -0,0 +1,27 @@ +use tauri::State; + +use crate::{ + errors::CmdResult, + models::obs::ObsStatus, + state::AppState, +}; + +#[tauri::command] +pub async fn obs_start(state: State<'_, AppState>, port: Option) -> CmdResult { + let port = port.unwrap_or(state.store.with_state(|s| s.obs_port)); + state.obs_bridge.start(port).await.map_err(|e| { + crate::errors::CommandError::msg(e) + })?; + Ok(state.obs_bridge.status()) +} + +#[tauri::command] +pub fn obs_stop(state: State<'_, AppState>) -> CmdResult { + state.obs_bridge.stop(); + Ok(state.obs_bridge.status()) +} + +#[tauri::command] +pub fn obs_status(state: State<'_, AppState>) -> CmdResult { + Ok(state.obs_bridge.status()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 722d6b2b..fbbde589 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -152,6 +152,10 @@ fn main() { commands::app::system::app_quit, commands::app::system::window_open_devtools_all, commands::app::system::get_cursor_settings, + // OBS 모드 + commands::app::obs::obs_start, + commands::app::obs::obs_stop, + commands::app::obs::obs_status, // 에디터 콘텐츠 commands::editor::css::css_get, commands::editor::css::css_get_use, diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index 6c5cd6d9..27ff5719 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -78,6 +78,8 @@ pub enum ObsBroadcast { LayoutDiff(Value), CounterUpdate(Value), Snapshot(Value), + /// 서버 종료 신호 — 클라이언트 세션 종료용 + Shutdown, } /// OBS 연결 상태 (프론트엔드 표시용) diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 7a61fb96..a83ff85d 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -128,12 +128,12 @@ impl ObsBridgeService { if !self.running.load(Ordering::Relaxed) { return; } + // 기존 클라이언트 세션에 종료 신호 전송 + let _ = self.broadcast_tx.send(ObsBroadcast::Shutdown); if let Some(tx) = self.shutdown_tx.write().take() { let _ = tx.send(()); } self.running.store(false, Ordering::Relaxed); - // client_count는 리셋하지 않음 — 각 클라이언트 세션이 - // broadcast 채널 종료를 감지하고 자연 종료되면서 스스로 감소 log::info!("[ObsBridge] 서버 종료"); } @@ -265,6 +265,7 @@ impl ObsBridgeService { // broadcast 채널에서 메시지 수신 → 클라이언트에 전송 result = broadcast_rx.recv() => { match result { + Ok(ObsBroadcast::Shutdown) => break, Ok(broadcast) => { let msg = broadcast_to_envelope(&broadcast, next_seq()); if ws_tx.send(Message::Text(msg.to_string().into())).await.is_err() { @@ -355,5 +356,6 @@ fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { ObsBroadcast::Snapshot(snapshot) => { make_envelope("snapshot", seq, snapshot.clone()) } + ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"), } } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index f3179a71..f5981151 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -31,11 +31,12 @@ use crate::{ audio::{KeySoundEngine, KeySoundStatus}, keyboard::KeyboardManager, models::{ + obs::KeyState as ObsKeyState, overlay_resize_anchor_from_str, BootstrapOverlayState, BootstrapPayload, DefaultsPayload, KeyCounterSettings, KeyCounters, KeyMappings, OverlayBounds, OverlayResizeAnchor, SettingsDiff, SettingsState, }, - services::{css_watcher::CssWatcher, settings::SettingsService}, + services::{css_watcher::CssWatcher, obs_bridge::ObsBridgeService, settings::SettingsService}, }; const OVERLAY_LABEL: &str = "overlay"; @@ -63,6 +64,8 @@ pub struct AppState { key_sound: Arc, /// CSS 파일 핫리로딩 워처 css_watcher: RwLock>, + /// OBS WebSocket 브릿지 + pub obs_bridge: Arc, } impl AppState { @@ -78,6 +81,7 @@ impl AppState { let key_counter_enabled = Arc::new(AtomicBool::new(snapshot.key_counter_enabled)); let active_keys = Arc::new(RwLock::new(HashSet::new())); let key_sound = Arc::new(KeySoundEngine::new()); + let obs_bridge = Arc::new(ObsBridgeService::new(env!("CARGO_PKG_VERSION"))); Ok(Self { store, @@ -93,6 +97,7 @@ impl AppState { raw_input_subscribers: Arc::new(std::sync::atomic::AtomicU32::new(0)), key_sound, css_watcher: RwLock::new(None), + obs_bridge, }) } @@ -260,6 +265,12 @@ impl AppState { if let Some(value) = diff.changed.key_counter_enabled { self.key_counter_enabled.store(value, Ordering::SeqCst); } + // OBS 브릿지 설정 변경 브로드캐스트 + if self.obs_bridge.is_running() { + if let Ok(diff_json) = serde_json::to_value(&diff.changed) { + self.obs_bridge.broadcast_settings_diff(diff_json); + } + } // 전체 설정 페이로드 전송 방지 (임베디드 폰트 등 대용량 데이터 제외) let mut payload = diff.clone(); payload.full = None; @@ -810,6 +821,20 @@ impl AppState { } let payload = json!({ "key": key_label, "state": state, "mode": mode }); + // OBS 브릿지 키 이벤트 브로드캐스트 + if app_state.obs_bridge.is_running() { + let obs_key_state = if state == "DOWN" { + ObsKeyState::Down + } else { + ObsKeyState::Up + }; + app_state.obs_bridge.broadcast_key_event( + key_label.to_string(), + obs_key_state, + mode.to_string(), + ); + } + let mut emitted = false; if let Some(overlay) = overlay_window.as_ref() { match overlay.emit("keys:state", &payload) { From 49a76de1bba16eeee968d6fb874ea127b8b70b8d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 15:59:46 +0900 Subject: [PATCH 04/56] =?UTF-8?q?feat:=20OBS=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=BA=90=EC=8B=B1=20+=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=84=B0/=ED=94=84=EB=A6=AC=EC=85=8B=20broad?= =?UTF-8?q?cast=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refresh_obs_snapshot(): bootstrap_payload 직렬화 → cached_snapshot 갱신 - obs_broadcast_counters(): 카운터 상태 OBS 브로드캐스트 - obs_start 시 초기 스냅샷 캐싱 (신규 클라이언트 지원) - preset_load/preset_load_tab 후 전체 스냅샷 재전송 - keys:counters emit 9개 지점에 obs_broadcast_counters 추가 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 2 ++ src-tauri/src/commands/keys/keys.rs | 9 +++++++++ src-tauri/src/commands/preset/load.rs | 8 ++++++++ src-tauri/src/state/app_state.rs | 22 ++++++++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 4da76985..12a4c86d 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -12,6 +12,8 @@ pub async fn obs_start(state: State<'_, AppState>, port: Option) -> CmdResu state.obs_bridge.start(port).await.map_err(|e| { crate::errors::CommandError::msg(e) })?; + // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) + state.refresh_obs_snapshot(); Ok(state.obs_bridge.status()) } diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index e0743472..1de73771 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -79,6 +79,7 @@ pub fn keys_update( app.emit("keys:changed", &updated)?; state.sync_counters_with_keys(&updated); app.emit("keys:counters", &state.snapshot_key_counters())?; + state.obs_broadcast_counters(); Ok(updated) } @@ -220,6 +221,7 @@ pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdRes let snapshot = state.reset_key_counters(); state.persist_key_counters()?; app.emit("keys:counters", &snapshot)?; + state.obs_broadcast_counters(); Ok(snapshot) } @@ -551,6 +557,7 @@ pub fn keys_reset_counters_mode( state.persist_key_counters()?; let snapshot = state.snapshot_key_counters(); app.emit("keys:counters", &snapshot)?; + state.obs_broadcast_counters(); Ok(snapshot) } @@ -565,6 +572,7 @@ pub fn keys_reset_single_counter( state.persist_key_counters()?; let snapshot = state.snapshot_key_counters(); app.emit("keys:counters", &snapshot)?; + state.obs_broadcast_counters(); Ok(snapshot) } @@ -577,6 +585,7 @@ pub fn keys_set_counters( let keys_snapshot = state.store.snapshot().keys; let updated = state.replace_key_counters(counters, &keys_snapshot)?; app.emit("keys:counters", &updated)?; + state.obs_broadcast_counters(); Ok(updated) } diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 4cefb845..d11b8044 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -161,6 +161,10 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult Result<()> { log::debug!("[IPC] set_overlay_visibility: visible={}", visible); From 2f6cc10b2b4c33fbda58331c099b83cc2dc3bed5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 16:12:54 +0900 Subject: [PATCH 05/56] =?UTF-8?q?refactor:=20OverlayScene=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay/App.tsx에서 순수 렌더링 JSX를 OverlayScene으로 분리 - 키, 통계, 그래프, WebGL 트랙, 카운터 레이어 렌더링 공용화 - FALLBACK_POSITION을 OverlayScene에서 단일 소스로 export - showPluginElements prop으로 Tauri 전용 플러그인 렌더링 제어 - OBS standalone 페이지에서 동일 렌더링 재사용 가능 Co-Authored-By: Claude Opus 4.6 --- .../components/shared/OverlayScene.tsx | 247 ++++++++++++++++++ src/renderer/windows/overlay/App.tsx | 203 ++------------ 2 files changed, 268 insertions(+), 182 deletions(-) create mode 100644 src/renderer/components/shared/OverlayScene.tsx diff --git a/src/renderer/components/shared/OverlayScene.tsx b/src/renderer/components/shared/OverlayScene.tsx new file mode 100644 index 00000000..f4c52fae --- /dev/null +++ b/src/renderer/components/shared/OverlayScene.tsx @@ -0,0 +1,247 @@ +import React, { Suspense, lazy } from 'react'; +import { Key } from '@components/shared/Key'; +import { isMac } from '@utils/core/platform'; +import KeyCounterLayer from '@components/overlay/counters/KeyCounterLayer'; +import StatItem from '@components/overlay/counters/StatItem'; +import StatCounterLayer from '@components/overlay/counters/StatCounterLayer'; +import OverlayGraphItemBase from '@components/overlay/counters/OverlayGraphItem'; +import { PluginElementsRenderer } from '@components/shared/PluginElementsRenderer'; +import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; +import { + createDefaultCounterSettings, + type KeyPosition, +} from '@src/types/key/keys'; +import type { NoteSettings } from '@src/types/settings/noteSettings'; +import type { NoteBuffer } from '@stores/signals/noteBuffer'; + +const FALLBACK_POSITION: KeyPosition = { + dx: 0, + dy: 0, + width: 60, + height: 60, + hidden: false, + activeImage: '', + inactiveImage: '', + activeTransparent: false, + idleTransparent: false, + count: 0, + noteColor: '#FFFFFF', + noteOpacity: 80, + noteEffectEnabled: true, + noteGlowEnabled: false, + noteGlowSize: 20, + noteGlowOpacity: 70, + noteGlowColor: '#FFFFFF', + noteAutoYCorrection: true, + className: '', + counter: createDefaultCounterSettings(), +}; + +// 타입 별칭 (공용) +interface OverlayKeyProps { + keyName: string; + globalKey: string; + position: KeyPosition; + mode?: string; + counterEnabled?: boolean; +} + +interface OverlayStatItemProps { + statType: string; + label?: string; + position: Record; + counterEnabled?: boolean; +} + +interface OverlayStatCounterLayerProps { + positions: Record[]; +} + +interface OverlayGraphItemProps { + index?: number; + position: Record; +} + +const OverlayKey = Key as React.ComponentType; +const OverlayStatItem = + StatItem as unknown as React.ComponentType; +const OverlayStatCounterLayer = + StatCounterLayer as unknown as React.ComponentType; +const OverlayGraphItem = + OverlayGraphItemBase as React.ComponentType; + +// Tracks 레이지 로딩 +const Tracks = lazy(async () => { + const mod = await import('@components/overlay/WebGLTracksOGL.jsx'); + return { + default: mod.WebGLTracksOGL as unknown as React.ComponentType< + Record + >, + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type NoteSubscriber = (event: any) => void; + +interface OverlaySceneProps { + // 키/위치 데이터 + currentKeys: string[]; + displayPositions: KeyPosition[]; + currentPositions: KeyPosition[]; + displayStatPositions: Record[]; + displayGraphPositions: Record[]; + selectedKeyType: string; + + // 노트 이펙트 + noteEffect: boolean; + noteSettings: NoteSettings; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + webglTracks: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notesRef: React.RefObject; + subscribe: (cb: NoteSubscriber) => () => void; + noteBuffer: NoteBuffer | null; + + // 설정 + backgroundColor: string; + keyCounterEnabled: boolean; + + // 선택적 + positionOffset?: { x: number; y: number }; + onMouseDownCapture?: (e: React.MouseEvent) => void; + /** PluginElementsRenderer 표시 여부 (Tauri 컨텍스트에서만 true) */ + showPluginElements?: boolean; +} + +const OverlayScene = ({ + currentKeys, + displayPositions, + currentPositions, + displayStatPositions, + displayGraphPositions, + selectedKeyType, + noteEffect, + noteSettings, + webglTracks, + notesRef, + subscribe, + noteBuffer, + backgroundColor, + keyCounterEnabled, + positionOffset, + onMouseDownCapture, + showPluginElements = true, +}: OverlaySceneProps) => { + const macOS = isMac(); + + return ( +
+ {noteEffect && ( + + + + )} + + {currentKeys.map((key, index) => { + const { displayName } = getKeyInfoByGlobalKey(key); + const basePosition = + displayPositions[index] ?? + currentPositions[index] ?? + FALLBACK_POSITION; + + const position = { + ...basePosition, + zIndex: basePosition.zIndex ?? index, + }; + + return ( + + ); + })} + {displayStatPositions.map((pos, index) => { + if (!pos || (pos as { hidden?: boolean }).hidden) return null; + + const statType = (pos as { statType?: string }).statType ?? 'kps'; + const defaultLabel = + statType === 'kpsAvg' + ? 'AVG' + : statType === 'kpsMax' + ? 'MAX' + : statType === 'total' + ? 'Total' + : 'KPS'; + const label = + (((pos as { displayText?: string }).displayText || '') as string) + .trim() || defaultLabel; + const position = { ...pos, zIndex: (pos as { zIndex?: number }).zIndex ?? index }; + + return ( + + ); + })} + {displayGraphPositions.map((pos, index) => { + if (!pos || (pos as { hidden?: boolean }).hidden) return null; + const graphPosition = { ...pos, zIndex: (pos as { zIndex?: number }).zIndex ?? index }; + return ( + + ); + })} + {keyCounterEnabled ? ( + + ) : null} + + {showPluginElements && positionOffset && ( + + )} +
+ ); +}; + +export default OverlayScene; +export { FALLBACK_POSITION }; +export type { OverlaySceneProps }; diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index cd564c85..3e4a08d1 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy, useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { currentMonitor, getCurrentWindow, @@ -6,8 +6,6 @@ import { } from '@tauri-apps/api/window'; import { LogicalPosition, PhysicalPosition } from '@tauri-apps/api/dpi'; import { Menu } from '@tauri-apps/api/menu'; -import { isMac } from '@utils/core/platform'; -import { Key } from '@components/shared/Key'; import { useTranslation } from '@contexts/useTranslation'; import { DEFAULT_NOTE_BORDER_RADIUS, @@ -28,87 +26,14 @@ import { resetAllKeySignals, } from '@stores/signals/keySignals'; import { useSettingsStore } from '@stores/useSettingsStore'; -import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; -import { - createDefaultCounterSettings, - type KeyPosition, -} from '@src/types/key/keys'; +import type { KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; -import KeyCounterLayer from '@components/overlay/counters/KeyCounterLayer'; -import { PluginElementsRenderer } from '@components/shared/PluginElementsRenderer'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; -import StatItem from '@components/overlay/counters/StatItem'; -import StatCounterLayer from '@components/overlay/counters/StatCounterLayer'; -import OverlayGraphItemBase from '@components/overlay/counters/OverlayGraphItem'; - -const FALLBACK_POSITION: KeyPosition = { - dx: 0, - dy: 0, - width: 60, - height: 60, - hidden: false, - activeImage: '', - inactiveImage: '', - activeTransparent: false, - idleTransparent: false, - count: 0, - noteColor: '#FFFFFF', - noteOpacity: 80, - noteEffectEnabled: true, - noteGlowEnabled: false, - noteGlowSize: 20, - noteGlowOpacity: 70, - noteGlowColor: '#FFFFFF', - noteAutoYCorrection: true, - className: '', - counter: createDefaultCounterSettings(), -}; +import OverlayScene, { FALLBACK_POSITION } from '@components/shared/OverlayScene'; const PADDING = 30; -interface OverlayKeyProps { - keyName: string; - globalKey: string; - position: KeyPosition; - mode?: string; - counterEnabled?: boolean; -} - -interface OverlayStatItemProps { - statType: string; - label?: string; - position: Record; - counterEnabled?: boolean; -} - -interface OverlayStatCounterLayerProps { - positions: Record[]; -} - -interface OverlayGraphItemProps { - index?: number; - position: Record; -} - -const OverlayKey = Key as React.ComponentType; -const OverlayStatItem = - StatItem as unknown as React.ComponentType; -const OverlayStatCounterLayer = - StatCounterLayer as unknown as React.ComponentType; -const OverlayGraphItem = - OverlayGraphItemBase as React.ComponentType; - -// Tracks 레이지 로딩 -const Tracks = lazy(async () => { - const mod = await import('@components/overlay/WebGLTracksOGL.jsx'); - return { - default: mod.WebGLTracksOGL as unknown as React.ComponentType< - Record - >, - }; -}); - type KeyDelayTimerEntry = { timers: Set> }; export default function App() { @@ -118,7 +43,6 @@ export default function App() { useBuiltinStatsSubscription(); useBlockBrowserShortcuts(); const { t } = useTranslation(); - const macOS = isMac(); const developerModeEnabled = useSettingsStore( (state) => state.developerModeEnabled, ); @@ -836,109 +760,24 @@ export default function App() { }, [bounds, trackHeight, overlayAnchor]); return ( -
- {noteEffect && ( - - - - )} - - {currentKeys.map((key, index) => { - const { displayName } = getKeyInfoByGlobalKey(key); - const basePosition = - displayPositions[index] ?? - currentPositions[index] ?? - FALLBACK_POSITION; - - // zIndex가 null/undefined인 경우 index를 fallback으로 사용 (메인 그리드와 동일하게) - const position = { - ...basePosition, - zIndex: basePosition.zIndex ?? index, - }; - - return ( - - ); - })} - {displayStatPositions.map((pos, index) => { - if (!pos || pos.hidden) return null; - - const defaultLabel = - pos.statType === 'kpsAvg' - ? 'AVG' - : pos.statType === 'kpsMax' - ? 'MAX' - : pos.statType === 'total' - ? 'Total' - : 'KPS'; - const label = (pos.displayText || '').trim() || defaultLabel; - const position = { ...pos, zIndex: pos.zIndex ?? index }; - - return ( - - ); - })} - {displayGraphPositions.map((pos, index) => { - if (!pos || pos.hidden) return null; - const graphPosition = { ...pos, zIndex: pos.zIndex ?? index }; - return ( - - ); - })} - {keyCounterEnabled ? ( - - ) : null} - - -
+ showPluginElements={true} + /> ); } From 2a4db36c45d9c0555559b396ea8e9123c1dfa145 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 16:31:27 +0900 Subject: [PATCH 06/56] =?UTF-8?q?feat:=20OBS=20standalone=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20(WebSocket=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20+=20OverlayScen?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obs/index.html + index.tsx: Vite 멀티 엔트리 OBS 페이지 - obs/App.tsx: WebSocket 연결 → 상태 관리 → OverlayScene 렌더링 - useObsWebSocket 훅: 자동 재연결, hello 핸드셰이크, 메시지 디스패치 - snapshot/key_event/settings_diff/counter_update 처리 - vite.config.ts에 obs 엔트리 추가 Co-Authored-By: Claude Opus 4.6 --- src/renderer/hooks/obs/useObsWebSocket.ts | 164 ++++++++++++ src/renderer/windows/obs/App.tsx | 310 ++++++++++++++++++++++ src/renderer/windows/obs/index.html | 12 + src/renderer/windows/obs/index.tsx | 18 ++ vite.config.ts | 1 + 5 files changed, 505 insertions(+) create mode 100644 src/renderer/hooks/obs/useObsWebSocket.ts create mode 100644 src/renderer/windows/obs/App.tsx create mode 100644 src/renderer/windows/obs/index.html create mode 100644 src/renderer/windows/obs/index.tsx diff --git a/src/renderer/hooks/obs/useObsWebSocket.ts b/src/renderer/hooks/obs/useObsWebSocket.ts new file mode 100644 index 00000000..ad1e3945 --- /dev/null +++ b/src/renderer/hooks/obs/useObsWebSocket.ts @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ObsEnvelope, KeyEventPayload } from '@src/types/obs'; +import { OBS_PROTOCOL_VERSION } from '@src/types/obs'; +import type { BootstrapPayload } from '@src/types/app'; + +type ConnectionState = 'connecting' | 'connected' | 'disconnected'; + +interface UseObsWebSocketOptions { + url: string; + onSnapshot: (payload: BootstrapPayload) => void; + onKeyEvent: (payload: KeyEventPayload) => void; + onSettingsDiff: (diff: Record) => void; + onCounterUpdate: (data: Record) => void; +} + +function useObsWebSocket({ + url, + onSnapshot, + onKeyEvent, + onSettingsDiff, + onCounterUpdate, +}: UseObsWebSocketOptions) { + const [connectionState, setConnectionState] = + useState('disconnected'); + + // 모든 상태를 ref로 관리하여 React Compiler 호환성 확보 + const wsRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const seqRef = useRef(0); + const callbacksRef = useRef({ + onSnapshot, + onKeyEvent, + onSettingsDiff, + onCounterUpdate, + }); + + // 콜백 ref 동기화 + useEffect(() => { + callbacksRef.current = { + onSnapshot, + onKeyEvent, + onSettingsDiff, + onCounterUpdate, + }; + }, [onSnapshot, onKeyEvent, onSettingsDiff, onCounterUpdate]); + + useEffect(() => { + let disposed = false; + + const sendMessage = (type: string, payload: unknown = null) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const envelope: ObsEnvelope = { + v: OBS_PROTOCOL_VERSION, + type, + seq: seqRef.current++, + ts: Date.now(), + payload, + }; + ws.send(JSON.stringify(envelope)); + }; + + const connect = () => { + if (disposed) return; + + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + setConnectionState('connecting'); + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + sendMessage('hello', { + client: 'obs-browser', + protocol: OBS_PROTOCOL_VERSION, + appVersion: '', + resumeFromSeq: 0, + }); + }; + + ws.onmessage = (event) => { + try { + const envelope = JSON.parse(event.data as string) as ObsEnvelope; + switch (envelope.type) { + case 'hello_ack': + setConnectionState('connected'); + break; + case 'snapshot': + callbacksRef.current.onSnapshot( + envelope.payload as BootstrapPayload, + ); + break; + case 'key_event': + callbacksRef.current.onKeyEvent( + envelope.payload as KeyEventPayload, + ); + break; + case 'settings_diff': + callbacksRef.current.onSettingsDiff( + envelope.payload as Record, + ); + break; + case 'counter_update': + callbacksRef.current.onCounterUpdate( + envelope.payload as Record, + ); + break; + case 'ping': + sendMessage('pong'); + break; + } + } catch { + // 파싱 실패 무시 + } + }; + + ws.onclose = () => { + setConnectionState('disconnected'); + wsRef.current = null; + if (!disposed) { + reconnectTimerRef.current = setTimeout(connect, 3000); + } + }; + + ws.onerror = () => { + // onclose에서 재연결 처리 + }; + }; + + connect(); + + return () => { + disposed = true; + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [url]); + + const requestResync = () => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const envelope: ObsEnvelope = { + v: OBS_PROTOCOL_VERSION, + type: 'resync_request', + seq: seqRef.current++, + ts: Date.now(), + payload: null, + }; + ws.send(JSON.stringify(envelope)); + }; + + return { connectionState, requestResync }; +} + +export { useObsWebSocket }; +export type { ConnectionState }; diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx new file mode 100644 index 00000000..6b9e49bd --- /dev/null +++ b/src/renderer/windows/obs/App.tsx @@ -0,0 +1,310 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useObsWebSocket } from '@hooks/obs/useObsWebSocket'; +import { useNoteSystem } from '@hooks/overlay/useNoteSystem'; +import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; +import { mergeNoteSettings, NOTE_SETTINGS_DEFAULTS } from '@src/types/settings/noteSettings'; +import { + setKeyActive as setKeyActiveSignal, + resetAllKeySignals, +} from '@stores/signals/keySignals'; +import { + applyCounterSnapshot, +} from '@stores/signals/keyCounterSignals'; +import { applyStatsSnapshot } from '@stores/signals/statsSignals'; +import OverlayScene, { FALLBACK_POSITION } from '@components/shared/OverlayScene'; +import type { BootstrapPayload } from '@src/types/app'; +import type { KeyEventPayload } from '@src/types/obs'; +import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; +import type { StatItemPositions } from '@src/types/key/statItems'; +import type { GraphItemPositions } from '@src/types/key/graphItems'; +import type { NoteSettings } from '@src/types/settings/noteSettings'; +import { DEFAULT_NOTE_BORDER_RADIUS } from '@constants/overlayDefaults'; + +const PADDING = 30; + +export default function App() { + // URL에서 포트와 호스트 결정 + const params = new URLSearchParams(window.location.search); + const port = params.get('port') || '34891'; + const host = params.get('host') || '127.0.0.1'; + const wsUrl = `ws://${host}:${port}/ws`; + + // 상태 + const [keyMappings, setKeyMappings] = useState({}); + const [positions, setPositions] = useState({}); + const [statPositions, setStatPositions] = useState({}); + const [graphPositions, setGraphPositions] = useState({}); + const [selectedKeyType, setSelectedKeyType] = useState('4key'); + const [noteEffect, setNoteEffect] = useState(true); + const [noteSettings, setNoteSettings] = useState(NOTE_SETTINGS_DEFAULTS); + const [backgroundColor, setBackgroundColor] = useState('transparent'); + const [keyCounterEnabled, setKeyCounterEnabled] = useState(false); + const [initialized, setInitialized] = useState(false); + + // 노트 시스템 + const { + notesRef, + subscribe, + handleKeyDown, + handleKeyUp, + noteBuffer, + updateTrackLayouts, + } = useNoteSystem({ noteEffect, noteSettings }); + + // handleKeyDown/handleKeyUp를 ref로 유지 + const handleKeyDownRef = useRef(handleKeyDown); + const handleKeyUpRef = useRef(handleKeyUp); + useEffect(() => { + handleKeyDownRef.current = handleKeyDown; + handleKeyUpRef.current = handleKeyUp; + }, [handleKeyDown, handleKeyUp]); + + // 스냅샷 수신 + const onSnapshot = useCallback((payload: BootstrapPayload) => { + setKeyMappings(payload.keys ?? {}); + setPositions(payload.positions ?? {}); + setStatPositions(payload.statPositions ?? {}); + setGraphPositions(payload.graphPositions ?? {}); + setSelectedKeyType(payload.selectedKeyType ?? '4key'); + + const settings = payload.settings; + if (settings) { + setNoteEffect(settings.noteEffect ?? true); + setNoteSettings( + mergeNoteSettings(settings.noteSettings ?? NOTE_SETTINGS_DEFAULTS, null), + ); + setBackgroundColor(settings.backgroundColor ?? 'transparent'); + setKeyCounterEnabled(settings.keyCounterEnabled ?? false); + } + + // 카운터 초기화 + if (payload.keyCounters) { + applyCounterSnapshot(payload.keyCounters); + } + + // 통계 초기화 + applyStatsSnapshot({ kps: 0, kpsAvg: 0, kpsMax: 0, total: 0 }); + + resetAllKeySignals(); + setInitialized(true); + }, []); + + // 키 이벤트 수신 + const onKeyEvent = useCallback((payload: KeyEventPayload) => { + const { key, state } = payload; + const isDown = state === 'DOWN'; + setKeyActiveSignal(key, isDown); + + if (isDown) { + requestAnimationFrame(() => handleKeyDownRef.current(key)); + } else { + requestAnimationFrame(() => handleKeyUpRef.current(key)); + } + }, []); + + // 설정 변경 + const onSettingsDiff = useCallback( + (diff: Record) => { + if ('noteEffect' in diff) setNoteEffect(diff.noteEffect as boolean); + if ('noteSettings' in diff) + setNoteSettings((prev) => + mergeNoteSettings({ ...prev, ...(diff.noteSettings as Partial) }, null), + ); + if ('backgroundColor' in diff) + setBackgroundColor(diff.backgroundColor as string); + if ('keyCounterEnabled' in diff) + setKeyCounterEnabled(diff.keyCounterEnabled as boolean); + }, + [], + ); + + // 카운터 업데이트 + const onCounterUpdate = useCallback( + (data: Record) => { + applyCounterSnapshot(data as Record>); + }, + [], + ); + + useObsWebSocket({ + url: wsUrl, + onSnapshot, + onKeyEvent, + onSettingsDiff, + onCounterUpdate, + }); + + // 현재 모드 데이터 + const currentKeys = keyMappings[selectedKeyType] ?? []; + const currentPositions = positions[selectedKeyType] ?? []; + const currentStatPositions = statPositions[selectedKeyType] ?? []; + const currentGraphPositions = graphPositions[selectedKeyType] ?? []; + + const trackHeight = noteSettings?.trackHeight ?? DEFAULT_NOTE_SETTINGS.trackHeight; + + // bounds 계산 + const bounds = (() => { + if ( + !currentPositions.length && + !currentStatPositions.length && + !currentGraphPositions.length + ) + return null; + + const xs: number[] = []; + const ys: number[] = []; + const widths: number[] = []; + const heights: number[] = []; + + currentPositions.forEach((pos) => { + if (pos.hidden) return; + xs.push(pos.dx); + ys.push(pos.dy); + widths.push(pos.dx + pos.width); + heights.push(pos.dy + pos.height); + }); + + currentStatPositions.forEach((pos) => { + if (!pos || pos.hidden) return; + xs.push(pos.dx); + ys.push(pos.dy); + widths.push(pos.dx + (pos.width ?? 60)); + heights.push(pos.dy + (pos.height ?? 60)); + }); + + currentGraphPositions.forEach((pos) => { + if (!pos || pos.hidden) return; + xs.push(pos.dx); + ys.push(pos.dy); + widths.push(pos.dx + (pos.width ?? 200)); + heights.push(pos.dy + (pos.height ?? 100)); + }); + + if (xs.length === 0) return null; + + return { + minX: Math.min(...xs), + minY: Math.min(...ys), + maxX: Math.max(...widths), + maxY: Math.max(...heights), + }; + })(); + + // 표시 위치 계산 + const displayPositions = (() => { + if (!bounds || !currentPositions.length) return currentPositions; + const topOffset = trackHeight + PADDING; + const offsetX = PADDING - bounds.minX; + const offsetY = topOffset - bounds.minY; + return currentPositions.map((pos) => ({ + ...pos, + dx: pos.dx + offsetX, + dy: pos.dy + offsetY, + })); + })(); + + const displayStatPositions = (() => { + if (!bounds || !currentStatPositions.length) return currentStatPositions; + const topOffset = trackHeight + PADDING; + const offsetX = PADDING - bounds.minX; + const offsetY = topOffset - bounds.minY; + return currentStatPositions.map((pos) => ({ + ...pos, + dx: pos.dx + offsetX, + dy: pos.dy + offsetY, + })); + })(); + + const displayGraphPositions = (() => { + if (!bounds || !currentGraphPositions.length) return currentGraphPositions; + const topOffset = trackHeight + PADDING; + const offsetX = PADDING - bounds.minX; + const offsetY = topOffset - bounds.minY; + return currentGraphPositions.map((pos) => ({ + ...pos, + dx: pos.dx + offsetX, + dy: pos.dy + offsetY, + })); + })(); + + const topMostY = bounds ? trackHeight + PADDING : 0; + + // WebGL 트랙 계산 + const webglTracks = currentKeys + .map((key, index) => { + const originalPosition = currentPositions[index] ?? FALLBACK_POSITION; + if (originalPosition.hidden) return null; + const position = displayPositions[index] ?? originalPosition; + const useAutoCorrection = position.noteAutoYCorrection !== false; + const trackStartY = useAutoCorrection ? topMostY : position.dy; + const keyWidth = position.width; + const desiredNoteWidth = + typeof position.noteWidth === 'number' && + Number.isFinite(position.noteWidth) + ? Math.max(1, Math.round(position.noteWidth)) + : keyWidth; + const noteOffsetX = (keyWidth - desiredNoteWidth) / 2; + + return { + trackKey: key, + trackIndex: position.zIndex ?? index, + position: { + ...position, + dx: position.dx + noteOffsetX, + dy: trackStartY, + }, + width: desiredNoteWidth, + height: trackHeight, + noteColor: position.noteColor, + noteOpacity: position.noteOpacity, + noteOpacityTop: position.noteOpacityTop ?? position.noteOpacity, + noteOpacityBottom: position.noteOpacityBottom ?? position.noteOpacity, + noteGlowEnabled: position.noteGlowEnabled ?? false, + noteGlowSize: position.noteGlowSize ?? 20, + noteGlowOpacity: position.noteGlowOpacity ?? 70, + noteGlowOpacityTop: + position.noteGlowOpacityTop ?? position.noteGlowOpacity ?? 70, + noteGlowOpacityBottom: + position.noteGlowOpacityBottom ?? position.noteGlowOpacity ?? 70, + noteGlowColor: position.noteGlowColor ?? position.noteColor, + flowSpeed: noteSettings?.speed ?? DEFAULT_NOTE_SETTINGS.speed, + borderRadius: position.noteBorderRadius ?? DEFAULT_NOTE_BORDER_RADIUS, + }; + }) + .filter(Boolean); + + useEffect(() => { + updateTrackLayouts(webglTracks); + }, [webglTracks, updateTrackLayouts]); + + if (!initialized) { + return ( +
+
Connecting...
+
+ ); + } + + return ( + + ); +} diff --git a/src/renderer/windows/obs/index.html b/src/renderer/windows/obs/index.html new file mode 100644 index 00000000..4cc981b3 --- /dev/null +++ b/src/renderer/windows/obs/index.html @@ -0,0 +1,12 @@ + + + + + + DM NOTE OBS + + +
+ + + diff --git a/src/renderer/windows/obs/index.tsx b/src/renderer/windows/obs/index.tsx new file mode 100644 index 00000000..ee988286 --- /dev/null +++ b/src/renderer/windows/obs/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import '@styles/global.css'; + +async function bootstrap() { + try { + const { default: App } = await import('./App'); + const container = document.getElementById('root')!; + const root = createRoot(container); + root.render(); + } catch (error) { + const err = error as Error; + console.error('[OBS] Failed to mount React app:', err); + document.body.innerHTML = `
OBS Error: ${err.message}\n${err.stack}
`; + } +} + +bootstrap(); diff --git a/vite.config.ts b/vite.config.ts index b9637e98..57b0ba3c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -90,6 +90,7 @@ export default defineConfig(() => { input: { main: path.resolve(windowsRoot, "main/index.html"), overlay: path.resolve(windowsRoot, "overlay/index.html"), + obs: path.resolve(windowsRoot, "obs/index.html"), }, }, }, From c61f35f7ee9d255eaf47dc4767516d02fcdb41c7 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 16:49:52 +0900 Subject: [PATCH 07/56] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=EC=9C=88?= =?UTF-8?q?=EB=8F=84=EC=9A=B0=20OBS=20=EC=84=A4=EC=A0=95=20UI=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obsApi 모듈 추가 (start/stop/status Tauri 커맨드 래퍼) - Settings.tsx에 OBS 모드 섹션 추가 (서버 시작/중지, 포트 입력, 상태 표시, URL 복사) - 3초 주기 상태 폴링 (shallow 비교로 불필요한 리렌더 방지) - 5개 언어 i18n 키 추가 (ko, en, zh-cn, zh-Hant, ru) Co-Authored-By: Claude Opus 4.6 --- src/renderer/api/modules/obsApi.ts | 9 ++ src/renderer/components/main/Settings.tsx | 136 ++++++++++++++++++ .../components/shared/OverlayScene.tsx | 23 +-- src/renderer/locales/en.json | 13 +- src/renderer/locales/ko.json | 13 +- src/renderer/locales/ru.json | 13 +- src/renderer/locales/zh-Hant.json | 13 +- src/renderer/locales/zh-cn.json | 13 +- src/renderer/windows/obs/App.tsx | 62 ++++---- src/renderer/windows/overlay/App.tsx | 4 +- 10 files changed, 257 insertions(+), 42 deletions(-) create mode 100644 src/renderer/api/modules/obsApi.ts diff --git a/src/renderer/api/modules/obsApi.ts b/src/renderer/api/modules/obsApi.ts new file mode 100644 index 00000000..4c9312db --- /dev/null +++ b/src/renderer/api/modules/obsApi.ts @@ -0,0 +1,9 @@ +import { invoke } from '@tauri-apps/api/core'; + +import type { ObsStatus } from '@src/types/obs'; + +export const obsApi = { + start: (port?: number) => invoke('obs_start', { port }), + stop: () => invoke('obs_stop'), + status: () => invoke('obs_status'), +}; diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 4009b36d..c26d1538 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -26,6 +26,9 @@ import type { } from '@src/types/plugin/api'; import type { JsPlugin } from '@src/types/plugin/js'; import type { KeyCounters } from '@src/types/key/keys'; +import { obsApi } from '@api/modules/obsApi'; +import type { ObsStatus } from '@src/types/obs'; +import { DEFAULT_OBS_PORT } from '@src/types/obs'; // 설정 미리보기 영상 const PREVIEW_SOURCES: Record = { @@ -124,6 +127,15 @@ const Settings = ({ const [isAddingPlugins, setIsAddingPlugins] = useState(false); const [pendingPluginId, setPendingPluginId] = useState(null); + // OBS 모드 + const [obsStatus, setObsStatus] = useState({ + running: false, + port: DEFAULT_OBS_PORT, + clientCount: 0, + }); + const [obsPort, setObsPort] = useState(String(DEFAULT_OBS_PORT)); + const [obsLoading, setObsLoading] = useState(false); + // Lenis smooth scroll 적용 (전역 설정 사용) const { scrollContainerRef } = useLenis(); @@ -154,6 +166,30 @@ const Settings = ({ } }, [isMacOS, angleMode, setAngleMode]); + // OBS 상태 초기 로드 + 주기적 폴링 + useEffect(() => { + let mounted = true; + const fetchStatus = async () => { + try { + const status = await obsApi.status(); + if (!mounted) return; + setObsStatus((prev) => + prev.running === status.running && + prev.port === status.port && + prev.clientCount === status.clientCount + ? prev + : status, + ); + } catch {} + }; + fetchStatus(); + const interval = setInterval(fetchStatus, 3000); + return () => { + mounted = false; + clearInterval(interval); + }; + }, []); + const LANGUAGE_OPTIONS: { value: string; label: string }[] = [ { value: 'ko', label: '한국어' }, { value: 'en', label: 'English' }, @@ -523,6 +559,45 @@ const Settings = ({ } }; + const handleObsToggle = async (): Promise => { + if (obsLoading) return; + setObsLoading(true); + try { + if (obsStatus.running) { + const status = await obsApi.stop(); + setObsStatus(status); + } else { + const port = parseInt(obsPort, 10); + if (isNaN(port) || port < 1024 || port > 65535) { + showAlert?.(t('settings.obsStartFailed')); + return; + } + const status = await obsApi.start(port); + setObsStatus(status); + } + } catch (error) { + console.error('Failed to toggle OBS mode', error); + showAlert?.( + obsStatus.running + ? t('settings.obsStopFailed') + : t('settings.obsStartFailed'), + ); + } finally { + setObsLoading(false); + } + }; + + const handleObsCopyUrl = async (): Promise => { + // v1: HTTP 정적 서빙 미구현 — WS 엔드포인트만 제공 + const url = `ws://localhost:${obsStatus.port}`; + try { + await navigator.clipboard.writeText(url); + showAlert?.(t('settings.obsCopied')); + } catch { + showAlert?.(url); + } + }; + const handleDeveloperModeToggle = async (): Promise => { const next: boolean = !developerModeEnabled; setDeveloperModeEnabled(next); @@ -826,6 +901,67 @@ const Settings = ({ + {/* OBS 모드 */} +
+
+

+ {t('settings.obsMode')} +

+
+ {obsStatus.running && ( + + {t('settings.obsClients', { + count: obsStatus.clientCount, + })} + + )} + + {obsStatus.running + ? t('settings.obsRunning') + : t('settings.obsStopped')} + +
+
+
+
+

+ {t('settings.obsPort')} +

+ setObsPort(e.target.value)} + disabled={obsStatus.running} + className="w-[80px] bg-[#2A2A31] border border-[#3A3944] rounded-[5px] px-[8px] py-[3px] text-style-2 text-[#DBDEE8] disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + min={1024} + max={65535} + /> +
+
+ {obsStatus.running && ( + + )} + +
+
+
{/* 기타 설정 */}
diff --git a/src/renderer/components/shared/OverlayScene.tsx b/src/renderer/components/shared/OverlayScene.tsx index f4c52fae..fe154d21 100644 --- a/src/renderer/components/shared/OverlayScene.tsx +++ b/src/renderer/components/shared/OverlayScene.tsx @@ -192,14 +192,18 @@ const OverlayScene = ({ statType === 'kpsAvg' ? 'AVG' : statType === 'kpsMax' - ? 'MAX' - : statType === 'total' - ? 'Total' - : 'KPS'; + ? 'MAX' + : statType === 'total' + ? 'Total' + : 'KPS'; const label = - (((pos as { displayText?: string }).displayText || '') as string) - .trim() || defaultLabel; - const position = { ...pos, zIndex: (pos as { zIndex?: number }).zIndex ?? index }; + ( + ((pos as { displayText?: string }).displayText || '') as string + ).trim() || defaultLabel; + const position = { + ...pos, + zIndex: (pos as { zIndex?: number }).zIndex ?? index, + }; return ( { if (!pos || (pos as { hidden?: boolean }).hidden) return null; - const graphPosition = { ...pos, zIndex: (pos as { zIndex?: number }).zIndex ?? index }; + const graphPosition = { + ...pos, + zIndex: (pos as { zIndex?: number }).zIndex ?? index, + }; return ( ({}); const [selectedKeyType, setSelectedKeyType] = useState('4key'); const [noteEffect, setNoteEffect] = useState(true); - const [noteSettings, setNoteSettings] = useState(NOTE_SETTINGS_DEFAULTS); + const [noteSettings, setNoteSettings] = useState( + NOTE_SETTINGS_DEFAULTS, + ); const [backgroundColor, setBackgroundColor] = useState('transparent'); const [keyCounterEnabled, setKeyCounterEnabled] = useState(false); const [initialized, setInitialized] = useState(false); @@ -71,7 +76,10 @@ export default function App() { if (settings) { setNoteEffect(settings.noteEffect ?? true); setNoteSettings( - mergeNoteSettings(settings.noteSettings ?? NOTE_SETTINGS_DEFAULTS, null), + mergeNoteSettings( + settings.noteSettings ?? NOTE_SETTINGS_DEFAULTS, + null, + ), ); setBackgroundColor(settings.backgroundColor ?? 'transparent'); setKeyCounterEnabled(settings.keyCounterEnabled ?? false); @@ -103,28 +111,25 @@ export default function App() { }, []); // 설정 변경 - const onSettingsDiff = useCallback( - (diff: Record) => { - if ('noteEffect' in diff) setNoteEffect(diff.noteEffect as boolean); - if ('noteSettings' in diff) - setNoteSettings((prev) => - mergeNoteSettings({ ...prev, ...(diff.noteSettings as Partial) }, null), - ); - if ('backgroundColor' in diff) - setBackgroundColor(diff.backgroundColor as string); - if ('keyCounterEnabled' in diff) - setKeyCounterEnabled(diff.keyCounterEnabled as boolean); - }, - [], - ); + const onSettingsDiff = useCallback((diff: Record) => { + if ('noteEffect' in diff) setNoteEffect(diff.noteEffect as boolean); + if ('noteSettings' in diff) + setNoteSettings((prev) => + mergeNoteSettings( + { ...prev, ...(diff.noteSettings as Partial) }, + null, + ), + ); + if ('backgroundColor' in diff) + setBackgroundColor(diff.backgroundColor as string); + if ('keyCounterEnabled' in diff) + setKeyCounterEnabled(diff.keyCounterEnabled as boolean); + }, []); // 카운터 업데이트 - const onCounterUpdate = useCallback( - (data: Record) => { - applyCounterSnapshot(data as Record>); - }, - [], - ); + const onCounterUpdate = useCallback((data: Record) => { + applyCounterSnapshot(data as Record>); + }, []); useObsWebSocket({ url: wsUrl, @@ -140,7 +145,8 @@ export default function App() { const currentStatPositions = statPositions[selectedKeyType] ?? []; const currentGraphPositions = graphPositions[selectedKeyType] ?? []; - const trackHeight = noteSettings?.trackHeight ?? DEFAULT_NOTE_SETTINGS.trackHeight; + const trackHeight = + noteSettings?.trackHeight ?? DEFAULT_NOTE_SETTINGS.trackHeight; // bounds 계산 const bounds = (() => { diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index 3e4a08d1..0499741c 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -30,7 +30,9 @@ import type { KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; -import OverlayScene, { FALLBACK_POSITION } from '@components/shared/OverlayScene'; +import OverlayScene, { + FALLBACK_POSITION, +} from '@components/shared/OverlayScene'; const PADDING = 30; From 7f2fff2956d72ec4753f1d1c9f85c758df656f68 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 16:53:12 +0900 Subject: [PATCH 08/56] =?UTF-8?q?fix:=20ObsBridge=20clippy=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 불필요한 .to_string().into() → .to_string() 변환 - 미사용 메서드에 #[allow(dead_code)] 추가 (v2 예정) Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/services/obs_bridge.rs | 39 ++++++++++++---------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index a83ff85d..416d68b6 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -12,8 +12,7 @@ use tokio::sync::{broadcast, oneshot}; use tokio_tungstenite::tungstenite::Message; use crate::models::obs::{ - make_envelope, HelloAckPayload, KeyEventPayload, KeyState, ObsBroadcast, ObsEnvelope, - ObsStatus, + make_envelope, HelloAckPayload, KeyEventPayload, KeyState, ObsBroadcast, ObsEnvelope, ObsStatus, }; /// OBS WebSocket 서버 @@ -44,6 +43,7 @@ impl ObsBridgeService { } } + #[allow(dead_code)] // v2: HTTP 정적 서빙 pub fn set_static_dir(&self, dir: PathBuf) { *self.static_dir.write() = Some(dir); } @@ -69,13 +69,16 @@ impl ObsBridgeService { } pub fn broadcast_key_event(&self, key: String, state: KeyState, mode: String) { - let _ = self.broadcast_tx.send(ObsBroadcast::KeyEvent { key, state, mode }); + let _ = self + .broadcast_tx + .send(ObsBroadcast::KeyEvent { key, state, mode }); } pub fn broadcast_settings_diff(&self, diff: Value) { let _ = self.broadcast_tx.send(ObsBroadcast::SettingsDiff(diff)); } + #[allow(dead_code)] // v2: layout 변경 broadcast pub fn broadcast_layout_diff(&self, diff: Value) { let _ = self.broadcast_tx.send(ObsBroadcast::LayoutDiff(diff)); } @@ -237,7 +240,7 @@ impl ObsBridgeService { .unwrap_or_default(); let ack_msg = make_envelope("hello_ack", next_seq(), ack_payload); if ws_tx - .send(Message::Text(ack_msg.to_string().into())) + .send(Message::Text(ack_msg.to_string())) .await .is_err() { @@ -249,7 +252,7 @@ impl ObsBridgeService { let snapshot = self.cached_snapshot.read().clone(); let snapshot_msg = make_envelope("snapshot", next_seq(), snapshot); if ws_tx - .send(Message::Text(snapshot_msg.to_string().into())) + .send(Message::Text(snapshot_msg.to_string())) .await .is_err() { @@ -268,7 +271,7 @@ impl ObsBridgeService { Ok(ObsBroadcast::Shutdown) => break, Ok(broadcast) => { let msg = broadcast_to_envelope(&broadcast, next_seq()); - if ws_tx.send(Message::Text(msg.to_string().into())).await.is_err() { + if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } } @@ -276,7 +279,7 @@ impl ObsBridgeService { log::warn!("[ObsBridge] {addr}: {n}개 메시지 누락, 스냅샷 재전송"); let snapshot = self.cached_snapshot.read().clone(); let msg = make_envelope("snapshot", next_seq(), snapshot); - if ws_tx.send(Message::Text(msg.to_string().into())).await.is_err() { + if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } } @@ -292,14 +295,14 @@ impl ObsBridgeService { match envelope.msg_type.as_str() { "ping" => { let pong = make_envelope("pong", next_seq(), Value::Null); - if ws_tx.send(Message::Text(pong.to_string().into())).await.is_err() { + 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); - if ws_tx.send(Message::Text(msg.to_string().into())).await.is_err() { + if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { break; } } @@ -317,7 +320,7 @@ impl ObsBridgeService { // 서버 주도 ping (연결 유지) _ = ping_interval.tick() => { let ping_msg = make_envelope("ping", next_seq(), Value::Null); - if ws_tx.send(Message::Text(ping_msg.to_string().into())).await.is_err() { + if ws_tx.send(Message::Text(ping_msg.to_string())).await.is_err() { break; } } @@ -344,18 +347,10 @@ fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { .unwrap_or_default(); make_envelope("key_event", seq, payload) } - ObsBroadcast::SettingsDiff(diff) => { - make_envelope("settings_diff", seq, diff.clone()) - } - ObsBroadcast::LayoutDiff(diff) => { - make_envelope("layout_diff", seq, diff.clone()) - } - ObsBroadcast::CounterUpdate(data) => { - make_envelope("counter_update", seq, data.clone()) - } - ObsBroadcast::Snapshot(snapshot) => { - make_envelope("snapshot", seq, snapshot.clone()) - } + ObsBroadcast::SettingsDiff(diff) => make_envelope("settings_diff", seq, diff.clone()), + ObsBroadcast::LayoutDiff(diff) => make_envelope("layout_diff", seq, diff.clone()), + ObsBroadcast::CounterUpdate(data) => make_envelope("counter_update", seq, data.clone()), + ObsBroadcast::Snapshot(snapshot) => make_envelope("snapshot", seq, snapshot.clone()), ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"), } } From 8a0065113337d62a9cbea3fc11f94343e866826b Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 17:03:19 +0900 Subject: [PATCH 09/56] =?UTF-8?q?chore:=20Rust=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models/obs.rs: ObsEnvelope, ObsInMessage 미사용 필드 dead_code 허용 - commands/app/obs.rs, state/app_state.rs: cargo fmt 적용 - docs/obs-mode-design.md: v1 완료 항목 표시 및 v2 로드맵 추가 Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 470 ++++++++++++++++++++++++++++++ src-tauri/src/commands/app/obs.rs | 14 +- src-tauri/src/models/obs.rs | 2 + src-tauri/src/state/app_state.rs | 21 +- 4 files changed, 484 insertions(+), 23 deletions(-) create mode 100644 docs/obs-mode-design.md diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md new file mode 100644 index 00000000..63b99c44 --- /dev/null +++ b/docs/obs-mode-design.md @@ -0,0 +1,470 @@ +# OBS 모드 설계 문서 + +> 작성일: 2026-03-07 +> 목표: OBS 브라우저 소스로 키뷰어를 표시하여 게임 FPS 영향 완전 제거 +> 상태: **v1 구현 완료** (`feat/obs-mode` 브랜치) + +--- + +## 1. 배경 및 동기 + +현재 오버레이 윈도우는 투명 + always-on-top으로 게임 위에 직접 렌더링됨. +이로 인해 다음과 같은 게임 FPS 저하 요인이 존재: + +| 요인 | 영향 | +|------|------| +| DWM 합성 | 투명 창 존재 자체로 매 프레임 합성 비용 | +| GPU 경쟁 | 오버레이 WebGL이 게임과 동일 GPU 공유 | +| fill-rate | glow 효과로 렌더링 면적 확장 | + +**OBS 모드**는 오버레이 창을 완전히 제거하고, OBS 브라우저 소스가 키뷰어를 렌더링하도록 하여 +게임 프로세스에 대한 영향을 **원천 차단**하는 것이 목표. + +--- + +## 2. 전체 아키텍처 + +``` +[Keyboard Daemon] + → [AppState (단일 상태 허브)] + ├─ [기존 overlay window] ← OBS 모드 OFF + ├─ [ObsBridgeService] ← OBS 모드 ON + │ └─ WebSocket 서버 (localhost:PORT) + │ ├─ HTTP: OBS 페이지 정적 파일 서빙 ← v2 예정 + │ └─ WS: 키 이벤트 / 설정 / 레이아웃 브로드캐스트 + └─ [Main window] + └─ OBS 모드 설정 UI / 연결 상태 / URL 표시 +``` + +### 핵심 원칙 + +1. **AppState가 단일 상태 소스** — 키보드 데몬이 직접 WS로 보내지 않음 ✅ +2. **렌더링 코드 재사용** — useNoteSystem, noteBuffer, WebGLTracksOGL 공유 ✅ +3. **OBS 페이지는 Tauri API 무의존** — window.api.* 참조 없음 ✅ + +### 데이터 흐름 + +``` +입력: Keyboard daemon → AppState → ObsBridgeService → OBS 페이지 ✅ +설정: settings/preset/mode 변경 → AppState emit → ObsBridge 캐시 갱신 ✅ (settings_diff, counter_update) + ⚠️ (layout_diff 미연동) +``` + +--- + +## 3. WebSocket 프로토콜 + +### 3.1 공통 Envelope ✅ + +```json +{ + "v": 1, + "type": "key_event", + "seq": 10241, + "ts": 1741339200123, + "payload": {} +} +``` + +- `v`: 프로토콜 버전 (하위 호환용) +- `seq`: 단조 증가 시퀀스 (gap 감지용) +- `ts`: 서버 타임스탬프 (ms) + +### 3.2 메시지 타입 + +| 방향 | 타입 | 용도 | 빈도 | v1 상태 | +|------|------|------|------|---------| +| C→S | `hello` | 최초 접속 핸드셰이크 | 1회 | ✅ | +| S→C | `hello_ack` | 프로토콜 승인 | 1회 | ✅ | +| S→C | `snapshot` | 전체 상태 동기화 | 접속 시 + resync | ✅ | +| S→C | `key_event` | 키 입력 이벤트 | 매우 빈번 | ✅ | +| S→C | `settings_diff` | 설정 변경분 | 가끔 | ✅ | +| S→C | `layout_diff` | 레이아웃/모드/탭 변경 | 가끔 | ⚠️ 서버 구현됨, 클라이언트 미처리 | +| S→C | `counter_update` | 키 카운터 갱신 | 주기적 | ✅ | +| 양방향 | `ping` / `pong` | 연결 상태 확인 | 주기적 | ✅ | +| C→S | `resync_request` | 상태 재동기화 요청 | 드묾 | ✅ | + +### 3.3 핸드셰이크 시퀀스 ✅ + +``` +1. OBS 페이지 접속 (WS 직접 연결) ← v1: HTTP upgrade 없이 직접 WS +2. 클라이언트 → hello { client, protocol, appVersion } +3. 서버 → hello_ack { serverVersion, obsMode } +4. 서버 → snapshot { 전체 상태 } +5. 이후 key_event, settings_diff, counter_update 스트리밍 +6. seq gap 감지 시 → resync_request → snapshot 재전송 +``` + +### 3.4 주요 메시지 상세 + +#### hello (C→S) ✅ +```json +{ + "v": 1, + "type": "hello", + "payload": { + "client": "obs-browser", + "protocol": 1, + "appVersion": "1.5.2", + "resumeFromSeq": 0 + } +} +``` + +#### snapshot (S→C) ✅ +```json +{ + "v": 1, + "type": "snapshot", + "seq": 10, + "payload": { + "mode": "4key", + "settings": { "noteEffect": true, "noteSettings": { "speed": 400, "trackHeight": 300 } }, + "keys": { "4key": ["A", "S", "D", "F"] }, + "positions": { "4key": [] }, + "statPositions": { "4key": [] }, + "graphPositions": { "4key": [] }, + "tabNoteOverrides": {}, + "customTabs": [], + "keyCounters": {}, + "overlayState": { "backgroundColor": "transparent" } + } +} +``` + +#### key_event (S→C) ✅ +```json +{ + "v": 1, + "type": "key_event", + "seq": 11, + "payload": { + "key": "A", + "state": "DOWN", + "mode": "4key" + } +} +``` + +#### layout_diff (S→C) ⚠️ 서버 구현 완료, 클라이언트 미처리 +```json +{ + "v": 1, + "type": "layout_diff", + "seq": 15, + "payload": { + "reason": "preset_loaded", + "selectedKeyType": "6key", + "keys": {}, + "positions": {}, + "statPositions": {}, + "graphPositions": {}, + "tabNoteOverrides": {} + } +} +``` + +### 3.5 상태 일관성 ⚠️ 부분 구현 + +프리셋 로드처럼 여러 Tauri 이벤트가 연속 발생하는 경우: +- ✅ 서버에서 프리셋 로드 후 `snapshot` 재전송 (refresh_obs_snapshot) +- ⚠️ `layout_diff` 배치 합산 미구현 — 현재는 snapshot 재전송으로 대체 + +--- + +## 4. Rust 백엔드 변경사항 + +### 4.1 새 모듈 구조 ✅ + +``` +src-tauri/src/ +├── services/ +│ └── obs_bridge.rs ✅ 신설: WebSocket 서버 lifecycle +├── models/ +│ └── obs.rs ✅ 신설: WS 메시지 타입 +├── commands/ +│ └── app/ +│ └── obs.rs ✅ 신설: OBS 모드 on/off, status +└── state/ + └── app_state.rs ✅ 수정: obs_bridge 필드, refresh_obs_snapshot, obs_broadcast_counters +``` + +### 4.2 ObsBridgeService 설계 ✅ + +```rust +pub struct ObsBridgeService { + running: AtomicBool, + port: RwLock, + client_count: AtomicU32, + cached_snapshot: RwLock, // ← ObsSnapshot 대신 serde_json::Value 사용 + broadcast_tx: broadcast::Sender, + shutdown_tx: RwLock>>, + server_version: String, + static_dir: RwLock>, // v2: HTTP 정적 서빙용 +} +``` + +주요 API: +- `start(port: u16)` — WS 서버 bind ✅ +- `stop()` — Shutdown broadcast → 서버 shutdown ✅ +- `broadcast_key_event(key, state, mode)` — 키 이벤트 전송 ✅ +- `broadcast_settings_diff(diff)` — 설정 변경 전송 ✅ +- `broadcast_layout_diff(diff)` — 레이아웃 변경 전송 ✅ (서버측, 호출 지점 미연동) +- `broadcast_counter_update(data)` — 카운터 갱신 전송 ✅ +- `broadcast_snapshot(snapshot)` — 스냅샷 전송 ✅ +- `update_snapshot(snapshot)` — 캐시 갱신 ✅ +- `status()` — 실행 상태 + 포트 + 클라이언트 수 조회 ✅ + +### 4.3 크레이트 의존성 ✅ + +```toml +tokio-tungstenite = "0.26" +futures-util = "0.3" +``` +> tokio는 기존에 이미 포함됨 + +### 4.4 기존 코드 연동 지점 + +| 기존 코드 위치 | 추가할 호출 | v1 상태 | +|----------------|-------------|---------| +| `app_state.rs` 키 입력 처리 루프 (~L813) | `obs_bridge.broadcast_key_event()` | ✅ | +| `app_state.rs` emit_settings_changed (~L252) | `obs_bridge.broadcast_settings_diff()` | ✅ | +| `commands/preset/load.rs` 프리셋 로드 후 | `refresh_obs_snapshot()` + `broadcast_snapshot()` | ✅ | +| `commands/keys/keys.rs` 카운터 emit 지점 (9개) | `obs_broadcast_counters()` | ✅ | +| `commands/keys/keys.rs` 모드 변경 | `obs_bridge.broadcast_layout_diff()` | ❌ 미연동 | +| `commands/layout/*` 레이아웃 변경 | `obs_bridge.broadcast_layout_diff()` | ❌ 미연동 | + +--- + +## 5. 프론트엔드 번들 전략 + +### 5.1 코드 분리 구조 ✅ (설계 대비 단순화) + +``` +src/renderer/ +├── windows/ +│ ├── overlay/App.tsx ✅ 기존 (OverlayScene 사용으로 리팩터링) +│ └── obs/ +│ ├── App.tsx ✅ 신설 (WebSocket + OverlayScene) +│ ├── index.tsx ✅ 신설 (bootstrap) +│ └── index.html ✅ 신설 +├── components/shared/ +│ └── OverlayScene.tsx ✅ 신설 (공용 렌더링 컴포넌트) +├── hooks/obs/ +│ └── useObsWebSocket.ts ✅ 신설 (WS 연결 + auto-reconnect) +├── hooks/overlay/ +│ └── useNoteSystem.ts ✅ 그대로 재사용 +├── stores/signals/ +│ └── noteBuffer.ts ✅ 그대로 재사용 +├── api/modules/ +│ └── obsApi.ts ✅ 신설 (Tauri 커맨드 래퍼) +└── components/overlay/ + └── WebGLTracksOGL.tsx ✅ 그대로 재사용 +``` + +> 설계 문서의 adapter 패턴 대신, OBS App.tsx에서 직접 상태 관리하는 단순한 구조로 구현 + +### 5.2 OverlayScene 추출 ✅ + +| 책임 | OverlayScene (공용) | overlay/App.tsx (Tauri) | obs/App.tsx (OBS) | +|------|:---:|:---:|:---:| +| 키 UI 렌더링 | ✅ | | | +| 노트 효과 (WebGL) | ✅ | | | +| bounds/position 계산 | | ✅ | ✅ (중복) | +| 통계/그래프 표시 | ✅ | | | +| 플러그인 엘리먼트 | ✅ (props로 제어) | | | +| 창 드래그/리사이즈 | | ✅ | | +| 컨텍스트 메뉴 | | ✅ | | +| window.api.* 구독 | | ✅ | | +| WebSocket 연결/동기화 | | | ✅ | + +### 5.3 Vite 멀티 엔트리 ✅ + +```js +// vite.config.ts +rollupOptions: { + input: { + main: path.resolve(windowsRoot, "main/index.html"), + overlay: path.resolve(windowsRoot, "overlay/index.html"), + obs: path.resolve(windowsRoot, "obs/index.html"), // ← 추가됨 + }, +}, +``` + +### 5.4 OBS 페이지 서빙 ❌ v2 예정 + +설계: 같은 포트에서 HTTP + WS 함께 서빙 +v1 현실: WS 전용 서버만 구현, HTTP 정적 파일 서빙 없음 + +--- + +## 6. 설정 동기화 + +### 6.1 동기화 대상 계층 + +| 계층 | 데이터 | 변경 빈도 | v1 상태 | +|------|--------|-----------|---------| +| 글로벌 설정 | noteEffect, noteSettings, backgroundColor | 드묾 | ✅ settings_diff | +| 레이아웃 | selectedKeyType, keys, positions, statPositions, graphPositions | 가끔 | ⚠️ 프리셋 로드 시 snapshot으로 대체 | +| 탭/프리셋 | customTabs, tabNoteOverrides | 가끔 | ⚠️ snapshot으로만 | +| 런타임 | keyCounters, active mode | 실시간 | ✅ counter_update | +| 키 입력 | key, state | 매우 빈번 | ✅ key_event | + +### 6.2 동기화 전략 + +- **최초 접속**: `snapshot` (전체 상태) ✅ +- **이후 변경**: `settings_diff` ✅ / `layout_diff` ⚠️ +- **대규모 변경** (프리셋 로드): `snapshot` 재전송 ✅ +- **연결 끊김 후 재접속**: `snapshot` 재전송 ✅ (auto-reconnect 3초) + +### 6.3 OBS 클라이언트 상태 관리 ✅ (설계 대비 단순화) + +설계: Zustand store 사용 +v1 구현: React useState로 직접 관리 (obs/App.tsx) + +--- + +## 7. OBS 모드 UX + +### 7.1 메인 윈도우 UI ✅ (설계 대비 간소화) + +v1 구현: +- OBS 모드 섹션 (bg-primary 카드) +- 상단: "OBS 모드" 라벨 + 실행 상태 (초록/회색) + 클라이언트 수 +- 하단: 포트 입력 + URL 복사 버튼 + 시작/중지 버튼 +- 3초 주기 상태 폴링 (shallow 비교로 불필요한 리렌더 방지) + +설계와 차이: +- ❌ 라디오 버튼 모드 전환 (오버레이 ↔ OBS) 미구현 +- ❌ OBS 모드 시 오버레이 창 자동 숨김 미구현 +- ❌ 안내 문구 (OBS 설정 방법) 미표시 + +### 7.2 모드 전환 동작 ⚠️ 부분 구현 + +1. ✅ OBS 모드 ON → WS 서버 bind → URL 표시 +2. ✅ OBS 클라이언트 접속 → 상태 점등 +3. ✅ 연결 끊김 → 서버 유지, 상태 표시 갱신 +4. ❌ 오버레이 창 자동 숨김/복구 미구현 +5. ❌ OBS 설정이 백엔드 설정 모델과 분리 (런타임 토글만, 재시작 시 초기화) + +### 7.3 포트 충돌 처리 ✅ + +- 기본값: `34891` +- 충돌 시: 명시적 실패 + 에러 표시 (자동 fallback 없음) + +--- + +## 8. 리스크 및 제약사항 + +### 8.1 기술적 리스크 + +| 리스크 | 심각도 | 대응 | v1 상태 | +|--------|--------|------|---------| +| OBS CEF Chromium 버전 차이 | 중 | WebGL 1.0 기준 유지 | ⚠️ 미검증 | +| 키 이벤트 지연 (WS 전송) | 낮 | localhost <1ms, seq+ts로 모니터링 | ✅ | +| 상태 일관성 (프리셋 로드 시) | 중 | snapshot 재전송으로 대응 | ✅ | +| 포트 보안 | 중 | 랜덤 세션 토큰 검토 필요 | ❌ 미구현 | +| tokio 런타임 추가 | 낮 | 기존 tokio 재사용 | ✅ | + +### 8.2 기능 제약 (1차 버전) + +| 기능 | 지원 여부 | +|------|-----------| +| 키 UI + 노트 효과 | ✅ 지원 | +| 통계/그래프 표시 | ✅ 지원 (렌더링만, KPS 값은 항상 0) | +| 키 카운터 | ✅ 지원 | +| 커스텀 CSS | × 미지원 | +| 커스텀 JS (플러그인) | × 미지원 (Tauri API 의존) | +| 플러그인 엘리먼트 | × 미지원 (bridge API 의존) | +| HTTP 정적 서빙 | × 미지원 | + +### 8.3 성능 참고 + +- **게임 FPS**: 오버레이 창 제거로 DWM 합성 + GPU 경쟁 **완전 제거** +- **OBS 렌더링**: 브라우저 소스 자체의 GPU 비용은 존재하나, OBS 프로세스에서 분리 처리 +- **시스템 전체**: 렌더링 비용이 0이 되는 것은 아니지만, 게임 프로세스와 합성 경로가 분리됨 + +--- + +## 9. 구현 우선순위 + +| 순서 | 작업 | 설명 | v1 상태 | +|------|------|------|---------| +| 1 | Rust ObsBridgeService | tokio WS 서버, hello/snapshot/key_event | ✅ | +| 2 | OBS standalone 페이지 | obs/App.tsx, WebSocket 연결, useNoteSystem 재사용 | ✅ | +| 3 | OverlayScene 추출 | 기존 overlay/App.tsx에서 공용 렌더링 분리 | ✅ | +| 4 | 설정/레이아웃 동기화 | settings_diff, counter_update, preset snapshot | ✅ (layout_diff 제외) | +| 5 | 메인 UI OBS 설정 | 시작/중지, 포트, 연결 상태, URL 복사 | ✅ | +| 6 | HTTP 정적 파일 서빙 | 같은 포트에서 OBS 페이지 제공 | ❌ v2 | +| 7 | 플러그인/커스텀 지원 여부 | bridge 없는 환경 대응 검토 | ❌ v2+ | + +--- + +## 10. 요약 + +``` +핵심 가치: +게임 FPS 영향 = 0 (오버레이 창 자체가 없으므로) + +구현 키포인트: +1. ✅ AppState를 단일 상태 허브로 유지 +2. ✅ ObsBridgeService로 WS 브로드캐스트 +3. ✅ useNoteSystem + WebGLTracksOGL 코드 재사용 +4. ✅ OverlayScene 추출로 Tauri/OBS 공용화 +5. ❌ 같은 포트에서 HTTP + WS 서빙 (v2) +``` + +--- + +## 11. v2 로드맵 — 남은 작업 및 우선순위 + +### 11.1 v1 → v2 차이점 요약 + +| 영역 | 설계 의도 | v1 현실 | 해야 할 작업 | +|------|-----------|---------|-------------| +| HTTP 서빙 | 같은 포트에서 HTML/JS/CSS 제공 | WS 전용 서버만 | hyper/axum으로 HTTP + WS 통합 서버 | +| OBS 접속 | `http://localhost:PORT/obs` 한 줄 입력 | 별도 웹 서버 필요 (Vite dev 등) | HTTP 서빙 구현 시 자동 해결 | +| layout_diff | 모드/탭/키/위치 변경 시 전송 | 서버 API만 구현, 호출 지점 미연동 | keys_set_mode, layout 변경 emit에 broadcast 추가 | +| Stats (KPS/total) | OBS에서도 실시간 표시 | 항상 0 (subscription 없음) | stats 전용 broadcast 추가 또는 counter_update에 포함 | +| cached_snapshot 동기화 | 최신 상태 항상 캐시 | 초기 + 프리셋 로드 시만 갱신 | settings_diff/counter_update 시 캐시도 갱신 | +| 설정 영속화 | obsModeEnabled/obsPort 저장 | 런타임 토글만 (재시작 시 초기화) | useSettingsStore + 백엔드 settings.update 연동 | +| 오버레이 연동 | OBS 모드 ON 시 오버레이 숨김 | 독립 동작 | obs_start 시 overlay hide, obs_stop 시 overlay restore | +| 보안 | 세션 토큰으로 접근 제한 | 인증 없음 (localhost 바인딩만) | URL query에 랜덤 토큰 포함 + 서버 검증 | +| keyDisplayDelayMs | 키 표시 지연 옵션 | OBS에서 미반영 | obs/App.tsx에 delay 로직 추가 | +| 개별 키 noteEffectEnabled | 키별 노트 효과 on/off | OBS에서 미반영 | key_event에 noteEffectEnabled 포함 또는 snapshot에서 추출 | + +### 11.2 우선순위별 작업 목록 + +#### P0 — 핵심 기능 완성 (v2 필수) + +| # | 작업 | 설명 | 예상 난이도 | +|---|------|------|------------| +| 1 | **HTTP 정적 파일 서빙** | obs_bridge.rs에 hyper 또는 axum 통합, dist/renderer/obs/ 디렉토리 서빙. OBS 브라우저 소스에서 URL 한 줄로 접속 가능하게 됨 | 중 | +| 2 | **layout_diff 연동** | keys_set_mode, keys_update, positions_update, custom_tabs 변경 시 broadcast_layout_diff 호출. OBS 클라이언트에서 layout_diff 메시지 처리 핸들러 추가 | 중 | +| 3 | **cached_snapshot 증분 갱신** | settings_diff/layout_diff/counter_update broadcast 시 cached_snapshot도 함께 갱신. 새 클라이언트 접속 시 항상 최신 스냅샷 제공 | 낮 | + +#### P1 — 사용성 개선 + +| # | 작업 | 설명 | 예상 난이도 | +|---|------|------|------------| +| 4 | **설정 영속화** | useSettingsStore에 obsModeEnabled/obsPort 추가, 백엔드 settings.update 경로에 obs 필드 반영. 앱 재시작 시 자동 서버 시작 옵션 | 낮 | +| 5 | **오버레이 연동** | OBS 모드 ON 시 오버레이 창 자동 숨김, OFF 시 복원. `overlay_set_visible` 커맨드 활용 | 낮 | +| 6 | **Stats 실시간 동기화** | KPS/kpsAvg/kpsMax/total을 OBS에 전송. key_stats_service의 주기적 emit에 obs_bridge.broadcast_stats_update 추가 | 중 | +| 7 | **UI 안내 문구** | OBS 설정 방법 가이드 텍스트, 모드 전환 라디오 버튼 UI 개선 | 낮 | + +#### P2 — 고급 기능 + +| # | 작업 | 설명 | 예상 난이도 | +|---|------|------|------------| +| 8 | **keyDisplayDelayMs 반영** | obs/App.tsx에서 키 이벤트 수신 시 delay 적용 로직 추가. 기존 overlay의 useKeyDelay 훅 참조 | 중 | +| 9 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off를 OBS에서도 반영. snapshot의 키 매핑 데이터에서 추출 | 낮 | +| 10 | **커스텀 CSS 지원** | OBS 페이지에서 CSS 주입 경로 구현. 보안 검토 필요 (XSS 등) | 중 | +| 11 | **보안 토큰** | 서버 시작 시 랜덤 토큰 생성, URL query에 포함, WS hello에서 검증 | 낮 | + +#### P3 — 장기 검토 + +| # | 작업 | 설명 | 예상 난이도 | +|---|------|------|------------| +| 12 | **플러그인 엘리먼트** | bridge API 없이 플러그인 display element를 OBS에서 렌더링하는 방안 검토. 서버에서 HTML 스냅샷 전송 등 | 높 | +| 13 | **커스텀 JS 플러그인** | Tauri API 없는 환경에서 플러그인 실행 가능 범위 정의 | 높 | +| 14 | **OBS CEF 호환 테스트** | OBS 28+ 브라우저 소스에서 WebGL/CSS 호환성 실제 검증 | 중 | diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 12a4c86d..cd20ef57 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -1,17 +1,15 @@ use tauri::State; -use crate::{ - errors::CmdResult, - models::obs::ObsStatus, - state::AppState, -}; +use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; #[tauri::command] pub async fn obs_start(state: State<'_, AppState>, port: Option) -> CmdResult { let port = port.unwrap_or(state.store.with_state(|s| s.obs_port)); - state.obs_bridge.start(port).await.map_err(|e| { - crate::errors::CommandError::msg(e) - })?; + state + .obs_bridge + .start(port) + .await + .map_err(|e| crate::errors::CommandError::msg(e))?; // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) state.refresh_obs_snapshot(); Ok(state.obs_bridge.status()) diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index 27ff5719..8f6e231a 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -10,6 +10,7 @@ pub const DEFAULT_OBS_PORT: u16 = 34891; // ── 공통 Envelope (수신 파싱용) ── #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct ObsEnvelope { #[serde(default)] pub v: u32, @@ -23,6 +24,7 @@ pub struct ObsEnvelope { #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] +#[allow(dead_code)] pub enum ObsInMessage { Hello { payload: HelloPayload }, Ping, diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index dddfa91a..34054d43 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -31,10 +31,9 @@ use crate::{ audio::{KeySoundEngine, KeySoundStatus}, keyboard::KeyboardManager, models::{ - obs::KeyState as ObsKeyState, - overlay_resize_anchor_from_str, BootstrapOverlayState, BootstrapPayload, DefaultsPayload, - KeyCounterSettings, KeyCounters, KeyMappings, OverlayBounds, OverlayResizeAnchor, - SettingsDiff, SettingsState, + obs::KeyState as ObsKeyState, overlay_resize_anchor_from_str, BootstrapOverlayState, + BootstrapPayload, DefaultsPayload, KeyCounterSettings, KeyCounters, KeyMappings, + OverlayBounds, OverlayResizeAnchor, SettingsDiff, SettingsState, }, services::{css_watcher::CssWatcher, obs_bridge::ObsBridgeService, settings::SettingsService}, }; @@ -478,8 +477,7 @@ impl AppState { if delta != 0.0 { match anchor { OverlayResizeAnchor::Center => new_y -= delta / 2.0, - OverlayResizeAnchor::BottomLeft - | OverlayResizeAnchor::BottomRight => {} + OverlayResizeAnchor::BottomLeft | OverlayResizeAnchor::BottomRight => {} OverlayResizeAnchor::FixedPosition => new_y -= delta, _ => new_y -= delta, } @@ -1196,8 +1194,7 @@ impl AppState { if let Some(best) = monitors.find_best_overlap(bounds.x, bounds.y, bounds.width, bounds.height) { - let area = - best.intersection_area(bounds.x, bounds.y, bounds.width, bounds.height); + let area = best.intersection_area(bounds.x, bounds.y, bounds.width, bounds.height); if area >= min_visible_area { // 충분히 보이므로 저장 좌표 그대로 복원 return OverlayPosition { @@ -1933,13 +1930,7 @@ impl MonitorData { } /// 주어진 사각형과 가장 많이 겹치는 모니터를 반환 - fn find_best_overlap( - &self, - x: f64, - y: f64, - width: f64, - height: f64, - ) -> Option<&MonitorSpec> { + fn find_best_overlap(&self, x: f64, y: f64, width: f64, height: f64) -> Option<&MonitorSpec> { self.specs .iter() .max_by(|a, b| { From 5fdb3d8daa4ef2579d3b4bb9023e551ce991c0f9 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 17:30:36 +0900 Subject: [PATCH 10/56] =?UTF-8?q?feat:=20OBS=20v2=20=E2=80=94=20HTTP=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=EC=84=9C=EB=B9=99=20+=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=8F=99=EA=B8=B0=ED=99=94=20+?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obs_bridge.rs: TCP peek로 HTTP/WS 분기, 정적 파일 서빙 구현 - obs_start: AppHandle에서 OBS static dir 경로 자동 탐색 - 모든 레이아웃 변경 커맨드에 refresh_obs_snapshot() 추가 (keys_set_mode, keys_update, positions_update, stat/graph_positions, custom_tabs_create/delete/select/restore, reset_all/mode, note_tab) - emit_settings_changed에서 cached_snapshot 증분 갱신 - refresh_obs_snapshot이 캐시 갱신 + broadcast 통합 - 프론트엔드 URL 복사: ws:// → http:// - OBS 페이지 WS URL: window.location 기반 자동 연결 Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 136 +++++++++---------- src-tauri/src/commands/app/obs.rs | 44 +++++- src-tauri/src/commands/editor/note_tab.rs | 2 + src-tauri/src/commands/keys/keys.rs | 10 ++ src-tauri/src/commands/layout/graph_items.rs | 1 + src-tauri/src/commands/layout/stat_items.rs | 1 + src-tauri/src/commands/preset/load.rs | 2 - src-tauri/src/services/obs_bridge.rs | 133 ++++++++++++++++-- src-tauri/src/state/app_state.rs | 10 +- src/renderer/components/main/Settings.tsx | 3 +- src/renderer/windows/obs/App.tsx | 8 +- 11 files changed, 253 insertions(+), 97 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index 63b99c44..a060dd2d 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -2,7 +2,7 @@ > 작성일: 2026-03-07 > 목표: OBS 브라우저 소스로 키뷰어를 표시하여 게임 FPS 영향 완전 제거 -> 상태: **v1 구현 완료** (`feat/obs-mode` 브랜치) +> 상태: **v2 구현 완료** (`feat/obs-mode` 브랜치) --- @@ -30,7 +30,7 @@ ├─ [기존 overlay window] ← OBS 모드 OFF ├─ [ObsBridgeService] ← OBS 모드 ON │ └─ WebSocket 서버 (localhost:PORT) - │ ├─ HTTP: OBS 페이지 정적 파일 서빙 ← v2 예정 + │ ├─ HTTP: OBS 페이지 정적 파일 서빙 ✅ │ └─ WS: 키 이벤트 / 설정 / 레이아웃 브로드캐스트 └─ [Main window] └─ OBS 모드 설정 UI / 연결 상태 / URL 표시 @@ -46,8 +46,7 @@ ``` 입력: Keyboard daemon → AppState → ObsBridgeService → OBS 페이지 ✅ -설정: settings/preset/mode 변경 → AppState emit → ObsBridge 캐시 갱신 ✅ (settings_diff, counter_update) - ⚠️ (layout_diff 미연동) +설정: settings/preset/mode 변경 → AppState emit → ObsBridge 캐시 갱신 ✅ (settings_diff, counter_update, layout snapshot) ``` --- @@ -79,7 +78,7 @@ | S→C | `snapshot` | 전체 상태 동기화 | 접속 시 + resync | ✅ | | S→C | `key_event` | 키 입력 이벤트 | 매우 빈번 | ✅ | | S→C | `settings_diff` | 설정 변경분 | 가끔 | ✅ | -| S→C | `layout_diff` | 레이아웃/모드/탭 변경 | 가끔 | ⚠️ 서버 구현됨, 클라이언트 미처리 | +| S→C | `layout_diff` | 레이아웃/모드/탭 변경 | 가끔 | ✅ snapshot 재전송으로 대체 | | S→C | `counter_update` | 키 카운터 갱신 | 주기적 | ✅ | | 양방향 | `ping` / `pong` | 연결 상태 확인 | 주기적 | ✅ | | C→S | `resync_request` | 상태 재동기화 요청 | 드묾 | ✅ | @@ -146,7 +145,7 @@ } ``` -#### layout_diff (S→C) ⚠️ 서버 구현 완료, 클라이언트 미처리 +#### layout_diff (S→C) ✅ snapshot 재전송으로 대체 ```json { "v": 1, @@ -164,11 +163,11 @@ } ``` -### 3.5 상태 일관성 ⚠️ 부분 구현 +### 3.5 상태 일관성 ✅ 프리셋 로드처럼 여러 Tauri 이벤트가 연속 발생하는 경우: - ✅ 서버에서 프리셋 로드 후 `snapshot` 재전송 (refresh_obs_snapshot) -- ⚠️ `layout_diff` 배치 합산 미구현 — 현재는 snapshot 재전송으로 대체 +- ✅ 모든 레이아웃 변경 시 `refresh_obs_snapshot` 호출로 캐시 + 클라이언트 동기화 --- @@ -231,8 +230,8 @@ futures-util = "0.3" | `app_state.rs` emit_settings_changed (~L252) | `obs_bridge.broadcast_settings_diff()` | ✅ | | `commands/preset/load.rs` 프리셋 로드 후 | `refresh_obs_snapshot()` + `broadcast_snapshot()` | ✅ | | `commands/keys/keys.rs` 카운터 emit 지점 (9개) | `obs_broadcast_counters()` | ✅ | -| `commands/keys/keys.rs` 모드 변경 | `obs_bridge.broadcast_layout_diff()` | ❌ 미연동 | -| `commands/layout/*` 레이아웃 변경 | `obs_bridge.broadcast_layout_diff()` | ❌ 미연동 | +| `commands/keys/keys.rs` 모드 변경 | `refresh_obs_snapshot()` | ✅ | +| `commands/layout/*` 레이아웃 변경 | `refresh_obs_snapshot()` | ✅ | --- @@ -291,10 +290,10 @@ rollupOptions: { }, ``` -### 5.4 OBS 페이지 서빙 ❌ v2 예정 +### 5.4 OBS 페이지 서빙 ✅ -설계: 같은 포트에서 HTTP + WS 함께 서빙 -v1 현실: WS 전용 서버만 구현, HTTP 정적 파일 서빙 없음 +같은 포트에서 HTTP(정적 파일) + WS(실시간 통신) 통합. +TCP 스트림을 peek하여 `Upgrade: websocket` 헤더 유무로 분기. --- @@ -305,15 +304,15 @@ v1 현실: WS 전용 서버만 구현, HTTP 정적 파일 서빙 없음 | 계층 | 데이터 | 변경 빈도 | v1 상태 | |------|--------|-----------|---------| | 글로벌 설정 | noteEffect, noteSettings, backgroundColor | 드묾 | ✅ settings_diff | -| 레이아웃 | selectedKeyType, keys, positions, statPositions, graphPositions | 가끔 | ⚠️ 프리셋 로드 시 snapshot으로 대체 | -| 탭/프리셋 | customTabs, tabNoteOverrides | 가끔 | ⚠️ snapshot으로만 | +| 레이아웃 | selectedKeyType, keys, positions, statPositions, graphPositions | 가끔 | ✅ 변경 시 snapshot 재전송 | +| 탭/프리셋 | customTabs, tabNoteOverrides | 가끔 | ✅ 변경 시 snapshot 재전송 | | 런타임 | keyCounters, active mode | 실시간 | ✅ counter_update | | 키 입력 | key, state | 매우 빈번 | ✅ key_event | ### 6.2 동기화 전략 - **최초 접속**: `snapshot` (전체 상태) ✅ -- **이후 변경**: `settings_diff` ✅ / `layout_diff` ⚠️ +- **이후 변경**: `settings_diff` ✅ / layout 변경 시 `snapshot` 재전송 ✅ - **대규모 변경** (프리셋 로드): `snapshot` 재전송 ✅ - **연결 끊김 후 재접속**: `snapshot` 재전송 ✅ (auto-reconnect 3초) @@ -366,17 +365,19 @@ v1 구현: | 포트 보안 | 중 | 랜덤 세션 토큰 검토 필요 | ❌ 미구현 | | tokio 런타임 추가 | 낮 | 기존 tokio 재사용 | ✅ | -### 8.2 기능 제약 (1차 버전) +### 8.2 기능 제약 (v2 기준) | 기능 | 지원 여부 | |------|-----------| | 키 UI + 노트 효과 | ✅ 지원 | | 통계/그래프 표시 | ✅ 지원 (렌더링만, KPS 값은 항상 0) | | 키 카운터 | ✅ 지원 | +| HTTP 정적 서빙 | ✅ 지원 | +| 레이아웃 동기화 | ✅ 지원 (snapshot 재전송) | | 커스텀 CSS | × 미지원 | +| 배경 미디어 서빙 | × 미지원 | | 커스텀 JS (플러그인) | × 미지원 (Tauri API 의존) | | 플러그인 엘리먼트 | × 미지원 (bridge API 의존) | -| HTTP 정적 서빙 | × 미지원 | ### 8.3 성능 참고 @@ -393,10 +394,10 @@ v1 구현: | 1 | Rust ObsBridgeService | tokio WS 서버, hello/snapshot/key_event | ✅ | | 2 | OBS standalone 페이지 | obs/App.tsx, WebSocket 연결, useNoteSystem 재사용 | ✅ | | 3 | OverlayScene 추출 | 기존 overlay/App.tsx에서 공용 렌더링 분리 | ✅ | -| 4 | 설정/레이아웃 동기화 | settings_diff, counter_update, preset snapshot | ✅ (layout_diff 제외) | +| 4 | 설정/레이아웃 동기화 | settings_diff, counter_update, preset snapshot, layout snapshot | ✅ | | 5 | 메인 UI OBS 설정 | 시작/중지, 포트, 연결 상태, URL 복사 | ✅ | -| 6 | HTTP 정적 파일 서빙 | 같은 포트에서 OBS 페이지 제공 | ❌ v2 | -| 7 | 플러그인/커스텀 지원 여부 | bridge 없는 환경 대응 검토 | ❌ v2+ | +| 6 | HTTP 정적 파일 서빙 | 같은 포트에서 OBS 페이지 제공 | ✅ | +| 7 | 플러그인/커스텀 지원 여부 | bridge 없는 환경 대응 검토 | ❌ v3+ | --- @@ -411,60 +412,47 @@ v1 구현: 2. ✅ ObsBridgeService로 WS 브로드캐스트 3. ✅ useNoteSystem + WebGLTracksOGL 코드 재사용 4. ✅ OverlayScene 추출로 Tauri/OBS 공용화 -5. ❌ 같은 포트에서 HTTP + WS 서빙 (v2) +5. ✅ 같은 포트에서 HTTP + WS 서빙 ``` --- -## 11. v2 로드맵 — 남은 작업 및 우선순위 - -### 11.1 v1 → v2 차이점 요약 - -| 영역 | 설계 의도 | v1 현실 | 해야 할 작업 | -|------|-----------|---------|-------------| -| HTTP 서빙 | 같은 포트에서 HTML/JS/CSS 제공 | WS 전용 서버만 | hyper/axum으로 HTTP + WS 통합 서버 | -| OBS 접속 | `http://localhost:PORT/obs` 한 줄 입력 | 별도 웹 서버 필요 (Vite dev 등) | HTTP 서빙 구현 시 자동 해결 | -| layout_diff | 모드/탭/키/위치 변경 시 전송 | 서버 API만 구현, 호출 지점 미연동 | keys_set_mode, layout 변경 emit에 broadcast 추가 | -| Stats (KPS/total) | OBS에서도 실시간 표시 | 항상 0 (subscription 없음) | stats 전용 broadcast 추가 또는 counter_update에 포함 | -| cached_snapshot 동기화 | 최신 상태 항상 캐시 | 초기 + 프리셋 로드 시만 갱신 | settings_diff/counter_update 시 캐시도 갱신 | -| 설정 영속화 | obsModeEnabled/obsPort 저장 | 런타임 토글만 (재시작 시 초기화) | useSettingsStore + 백엔드 settings.update 연동 | -| 오버레이 연동 | OBS 모드 ON 시 오버레이 숨김 | 독립 동작 | obs_start 시 overlay hide, obs_stop 시 overlay restore | -| 보안 | 세션 토큰으로 접근 제한 | 인증 없음 (localhost 바인딩만) | URL query에 랜덤 토큰 포함 + 서버 검증 | -| keyDisplayDelayMs | 키 표시 지연 옵션 | OBS에서 미반영 | obs/App.tsx에 delay 로직 추가 | -| 개별 키 noteEffectEnabled | 키별 노트 효과 on/off | OBS에서 미반영 | key_event에 noteEffectEnabled 포함 또는 snapshot에서 추출 | - -### 11.2 우선순위별 작업 목록 - -#### P0 — 핵심 기능 완성 (v2 필수) - -| # | 작업 | 설명 | 예상 난이도 | -|---|------|------|------------| -| 1 | **HTTP 정적 파일 서빙** | obs_bridge.rs에 hyper 또는 axum 통합, dist/renderer/obs/ 디렉토리 서빙. OBS 브라우저 소스에서 URL 한 줄로 접속 가능하게 됨 | 중 | -| 2 | **layout_diff 연동** | keys_set_mode, keys_update, positions_update, custom_tabs 변경 시 broadcast_layout_diff 호출. OBS 클라이언트에서 layout_diff 메시지 처리 핸들러 추가 | 중 | -| 3 | **cached_snapshot 증분 갱신** | settings_diff/layout_diff/counter_update broadcast 시 cached_snapshot도 함께 갱신. 새 클라이언트 접속 시 항상 최신 스냅샷 제공 | 낮 | - -#### P1 — 사용성 개선 - -| # | 작업 | 설명 | 예상 난이도 | -|---|------|------|------------| -| 4 | **설정 영속화** | useSettingsStore에 obsModeEnabled/obsPort 추가, 백엔드 settings.update 경로에 obs 필드 반영. 앱 재시작 시 자동 서버 시작 옵션 | 낮 | -| 5 | **오버레이 연동** | OBS 모드 ON 시 오버레이 창 자동 숨김, OFF 시 복원. `overlay_set_visible` 커맨드 활용 | 낮 | -| 6 | **Stats 실시간 동기화** | KPS/kpsAvg/kpsMax/total을 OBS에 전송. key_stats_service의 주기적 emit에 obs_bridge.broadcast_stats_update 추가 | 중 | -| 7 | **UI 안내 문구** | OBS 설정 방법 가이드 텍스트, 모드 전환 라디오 버튼 UI 개선 | 낮 | - -#### P2 — 고급 기능 - -| # | 작업 | 설명 | 예상 난이도 | -|---|------|------|------------| -| 8 | **keyDisplayDelayMs 반영** | obs/App.tsx에서 키 이벤트 수신 시 delay 적용 로직 추가. 기존 overlay의 useKeyDelay 훅 참조 | 중 | -| 9 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off를 OBS에서도 반영. snapshot의 키 매핑 데이터에서 추출 | 낮 | -| 10 | **커스텀 CSS 지원** | OBS 페이지에서 CSS 주입 경로 구현. 보안 검토 필요 (XSS 등) | 중 | -| 11 | **보안 토큰** | 서버 시작 시 랜덤 토큰 생성, URL query에 포함, WS hello에서 검증 | 낮 | - -#### P3 — 장기 검토 - -| # | 작업 | 설명 | 예상 난이도 | -|---|------|------|------------| -| 12 | **플러그인 엘리먼트** | bridge API 없이 플러그인 display element를 OBS에서 렌더링하는 방안 검토. 서버에서 HTML 스냅샷 전송 등 | 높 | -| 13 | **커스텀 JS 플러그인** | Tauri API 없는 환경에서 플러그인 실행 가능 범위 정의 | 높 | -| 14 | **OBS CEF 호환 테스트** | OBS 28+ 브라우저 소스에서 WebGL/CSS 호환성 실제 검증 | 중 | +## 11. v2 로드맵 + +### 11.1 v2 구현 범위 + +v2는 **OBS 브라우저 소스에서 바로 사용 가능**한 수준까지 완성하는 것이 목표. + +#### v2 포함 (P0) + +| # | 작업 | 설명 | v2 상태 | +|---|------|------|---------| +| 1 | **HTTP 정적 파일 서빙** | 같은 포트에서 HTTP(정적 파일) + WS(실시간 통신) 통합. `http://localhost:PORT` 입력만으로 OBS 접속 | ✅ | +| 2 | **layout_diff 연동** | 모드/키/위치/탭 변경 시 refresh_obs_snapshot으로 전체 상태 동기화 | ✅ | +| 3 | **cached_snapshot 증분 갱신** | settings_diff/layout 변경 시 캐시도 함께 갱신, 새 클라이언트에 최신 상태 제공 | ✅ | + +#### v2 제외 — 후속 버전으로 이관 + +| 영역 | 현재 상태 | 미구현 이유 | 우선순위 | +|------|-----------|-------------|----------| +| **커스텀 CSS** | OBS 페이지에 사용자 CSS 주입 없음 | HTTP 서빙 이후 CSS 파일 서빙 경로 설계 필요 | P2 | +| **배경 이미지/영상** | 미디어 파일 HTTP 서빙 없음 | 사용자 미디어 파일 경로 해석 + 보안 검토 필요 | P2 | +| **keyDisplayDelayMs** | OBS에서 키 표시 지연 미반영 | obs/App.tsx에 delay 로직 추가 필요 | P2 | +| **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 미반영 | snapshot에서 키 매핑 데이터 추출 필요 | P2 | +| **보안 토큰** | 인증 없음 (localhost 바인딩만) | 랜덤 세션 토큰 생성 + WS hello 검증 | P2 | +| **설정 영속화** | 런타임 토글만 (재시작 시 초기화) | useSettingsStore + 백엔드 settings.update 연동 | P1 | +| **오버레이 연동** | OBS 모드와 오버레이 독립 동작 | obs_start 시 overlay 숨김/복원 로직 | P1 | +| **Stats (KPS) 동기화** | KPS 값 항상 0 | stats broadcast 추가 또는 counter_update에 포함 | P1 | +| **UI 안내 문구** | OBS 설정 방법 미표시 | 가이드 텍스트 + 모드 전환 라디오 버튼 | P1 | +| **플러그인 엘리먼트** | OBS에서 렌더링 불가 | bridge API 없는 환경 대응 — 서버에서 HTML 스냅샷 등 | P3 | +| **커스텀 JS (플러그인)** | Tauri API 의존으로 불가 | bridge API WebSocket 프록시 레이어 필요 | P3 | +| **OBS CEF 호환 테스트** | 미검증 | OBS 28+ 브라우저 소스 WebGL/CSS 실제 테스트 | P3 | + +### 11.2 v1 → v2 변경 요약 + +| 영역 | v1 | v2 | +|------|-----|-----| +| OBS 접속 방식 | WS URL 직접 연결 (별도 웹 서버 필요) | `http://localhost:PORT` 한 줄 입력 | +| layout_diff | 서버 API만 구현 | 모드/키/위치/탭 변경 시 자동 broadcast | +| cached_snapshot | 초기 + 프리셋 로드 시만 갱신 | 모든 diff 시 증분 갱신 | +| 메인 UI URL 복사 | `ws://localhost:PORT` | `http://localhost:PORT` | diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index cd20ef57..0f328492 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -1,15 +1,53 @@ -use tauri::State; +use std::path::PathBuf; + +use tauri::{AppHandle, Manager, State}; use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; +/// OBS 빌드 정적 파일 경로 탐색 +fn resolve_obs_static_dir(app: &AppHandle) -> Option { + // 1. Tauri resource_dir/obs/ (프로덕션 번들) + if let Ok(res) = app.path().resource_dir() { + let obs = res.join("obs"); + if obs.join("index.html").exists() { + return Some(obs); + } + } + + // 2. 실행 파일 기준 탐색 (dev mode: src-tauri/target/debug/) + if let Ok(exe) = std::env::current_exe() { + if let Some(exe_dir) = exe.parent() { + let dev = exe_dir.join("../../../dist/renderer/obs"); + if dev.join("index.html").exists() { + return dev.canonicalize().ok(); + } + } + } + + None +} + #[tauri::command] -pub async fn obs_start(state: State<'_, AppState>, port: Option) -> CmdResult { +pub async fn obs_start( + app: AppHandle, + state: State<'_, AppState>, + port: Option, +) -> CmdResult { let port = port.unwrap_or(state.store.with_state(|s| s.obs_port)); + + // OBS 정적 파일 경로 설정 + if let Some(dir) = resolve_obs_static_dir(&app) { + log::info!("[ObsBridge] static_dir: {}", dir.display()); + state.obs_bridge.set_static_dir(dir); + } else { + log::warn!("[ObsBridge] OBS 정적 파일 디렉토리를 찾을 수 없음 (HTTP 서빙 비활성)"); + } + state .obs_bridge .start(port) .await - .map_err(|e| crate::errors::CommandError::msg(e))?; + .map_err(crate::errors::CommandError::msg)?; // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) state.refresh_obs_snapshot(); Ok(state.obs_bridge.status()) diff --git a/src-tauri/src/commands/editor/note_tab.rs b/src-tauri/src/commands/editor/note_tab.rs index 68c36fcb..1a9e3b5a 100644 --- a/src-tauri/src/commands/editor/note_tab.rs +++ b/src-tauri/src/commands/editor/note_tab.rs @@ -69,6 +69,7 @@ pub fn note_tab_set( settings: settings.clone(), }; app.emit("tabNote:changed", &response)?; + state.refresh_obs_snapshot(); Ok(TabNoteSetResponse { success: true, @@ -93,6 +94,7 @@ pub fn note_tab_clear( settings: None, }; app.emit("tabNote:changed", &response)?; + state.refresh_obs_snapshot(); Ok(TabNoteClearResponse { success: true, diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 1de73771..350dee27 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -80,6 +80,7 @@ pub fn keys_update( state.sync_counters_with_keys(&updated); app.emit("keys:counters", &state.snapshot_key_counters())?; state.obs_broadcast_counters(); + state.refresh_obs_snapshot(); Ok(updated) } @@ -91,6 +92,7 @@ pub fn positions_update( ) -> CmdResult { let updated = state.store.update_positions(positions)?; app.emit("positions:changed", &updated)?; + state.refresh_obs_snapshot(); Ok(updated) } @@ -114,6 +116,7 @@ pub fn keys_set_mode( "keys:mode-changed", &serde_json::json!({ "mode": &effective }), )?; + state.refresh_obs_snapshot(); Ok(ModeResponse { success, mode: effective, @@ -222,6 +225,7 @@ pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult CmdResult { let updated = state.store.update_layer_groups(groups)?; app.emit("layerGroups:changed", &updated)?; + state.refresh_obs_snapshot(); Ok(updated) } diff --git a/src-tauri/src/commands/layout/graph_items.rs b/src-tauri/src/commands/layout/graph_items.rs index 02c14e9c..2c7a0628 100644 --- a/src-tauri/src/commands/layout/graph_items.rs +++ b/src-tauri/src/commands/layout/graph_items.rs @@ -15,5 +15,6 @@ pub fn graph_positions_update( ) -> CmdResult { let updated = state.store.update_graph_positions(positions)?; app.emit("graphPositions:changed", &updated)?; + state.refresh_obs_snapshot(); Ok(updated) } diff --git a/src-tauri/src/commands/layout/stat_items.rs b/src-tauri/src/commands/layout/stat_items.rs index 195cba4e..06294986 100644 --- a/src-tauri/src/commands/layout/stat_items.rs +++ b/src-tauri/src/commands/layout/stat_items.rs @@ -15,5 +15,6 @@ pub fn stat_positions_update( ) -> CmdResult { let updated = state.store.update_stat_positions(positions)?; app.emit("statPositions:changed", &updated)?; + state.refresh_obs_snapshot(); Ok(updated) } diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index d11b8044..60541948 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -163,7 +163,6 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, stream: TcpStream, addr: SocketAddr) { - let ws_stream = match tokio_tungstenite::accept_async(stream).await { - Ok(ws) => ws, - Err(_) => { - // WS 핸드셰이크 실패 (일반 HTTP 등) — 현재는 무시 - // HTTP 정적 파일 서빙은 후속 구현 (hyper/axum 통합) - log::debug!("[ObsBridge] non-WS connection from {addr}"); + async fn handle_connection(self: &Arc, mut stream: TcpStream, addr: SocketAddr) { + // TCP 스트림을 peek하여 WebSocket upgrade 요청인지 판별 + let mut peek_buf = [0u8; 4096]; + let n = match stream.peek(&mut peek_buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + + let request_preview = String::from_utf8_lossy(&peek_buf[..n]); + let is_websocket = request_preview.lines().any(|line| { + line.to_ascii_lowercase().starts_with("upgrade:") + && line.to_ascii_lowercase().contains("websocket") + }); + + if is_websocket { + // WebSocket 핸드셰이크 + let ws_stream = match tokio_tungstenite::accept_async(stream).await { + Ok(ws) => ws, + Err(e) => { + log::debug!("[ObsBridge] WS 핸드셰이크 실패 from {addr}: {e}"); + return; + } + }; + self.handle_ws_client(ws_stream, addr).await; + } else { + // HTTP 정적 파일 서빙 + self.handle_http_request(&mut stream, &request_preview) + .await; + } + } + + /// HTTP GET 요청에 대해 정적 파일 서빙 + async fn handle_http_request(&self, stream: &mut TcpStream, request: &str) { + // 요청 소비 (peek 데이터를 실제로 읽어야 함) + let mut discard = vec![0u8; request.len()]; + let _ = stream.read(&mut discard).await; + + // RwLockReadGuard를 await 전에 해제하기 위해 즉시 clone + let static_dir = self.static_dir.read().clone(); + let static_dir = match static_dir.as_ref() { + Some(dir) => dir.clone(), + None => { + let body = "OBS bridge: static directory not configured"; + let response = format!( + "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; return; } }; - self.handle_ws_client(ws_stream, addr).await; + + // GET 경로 파싱 + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + + // 경로 정규화: "/" → "/index.html", 디렉토리 탐색 방지 + let normalized = if path == "/" || path.is_empty() { + "index.html" + } else { + path.trim_start_matches('/') + }; + + // 경로 탐색 공격 방지 (.. 포함 시 거부) + if normalized.contains("..") { + let _ = stream + .write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + return; + } + + let file_path = static_dir.join(normalized); + + match tokio::fs::read(&file_path).await { + Ok(content) => { + let mime = guess_mime(normalized); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", + content.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&content).await; + } + Err(_) => { + // SPA fallback: 존재하지 않는 경로 → index.html + let index_path = static_dir.join("index.html"); + match tokio::fs::read(&index_path).await { + Ok(content) => { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\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; + } + } + } + } } async fn handle_ws_client( @@ -335,6 +431,23 @@ impl ObsBridgeService { } } +/// 파일 확장자로 MIME 타입 추정 +fn guess_mime(path: &str) -> &'static str { + match path.rsplit('.').next().unwrap_or("") { + "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", + "svg" => "image/svg+xml", + "woff2" => "font/woff2", + "woff" => "font/woff", + "wasm" => "application/wasm", + _ => "application/octet-stream", + } +} + /// ObsBroadcast → JSON envelope 변환 fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { match broadcast { diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 34054d43..be27f9b9 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -264,11 +264,16 @@ impl AppState { if let Some(value) = diff.changed.key_counter_enabled { self.key_counter_enabled.store(value, Ordering::SeqCst); } - // OBS 브릿지 설정 변경 브로드캐스트 + // OBS 브릿지 설정 변경 브로드캐스트 + 캐시 갱신 if self.obs_bridge.is_running() { if let Ok(diff_json) = serde_json::to_value(&diff.changed) { self.obs_bridge.broadcast_settings_diff(diff_json); } + // cached_snapshot도 갱신 (새 클라이언트 접속 시 최신 설정 제공) + 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(); @@ -277,7 +282,7 @@ impl AppState { Ok(()) } - /// OBS 브릿지용 전체 스냅샷 빌드 + 캐시 갱신 + /// OBS 브릿지용 전체 스냅샷 빌드 + 캐시 갱신 + 연결된 클라이언트에 broadcast pub fn refresh_obs_snapshot(&self) { if !self.obs_bridge.is_running() { return; @@ -285,6 +290,7 @@ impl AppState { 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(); } } diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index c26d1538..3c276b0e 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -588,8 +588,7 @@ const Settings = ({ }; const handleObsCopyUrl = async (): Promise => { - // v1: HTTP 정적 서빙 미구현 — WS 엔드포인트만 제공 - const url = `ws://localhost:${obsStatus.port}`; + const url = `http://localhost:${obsStatus.port}`; try { await navigator.clipboard.writeText(url); showAlert?.(t('settings.obsCopied')); diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx index 567ad561..fcab60ee 100644 --- a/src/renderer/windows/obs/App.tsx +++ b/src/renderer/windows/obs/App.tsx @@ -26,11 +26,11 @@ import { DEFAULT_NOTE_BORDER_RADIUS } from '@constants/overlayDefaults'; const PADDING = 30; export default function App() { - // URL에서 포트와 호스트 결정 + // WS 연결 URL: HTTP로 서빙된 경우 같은 호스트:포트, 아닌 경우 query param fallback const params = new URLSearchParams(window.location.search); - const port = params.get('port') || '34891'; - const host = params.get('host') || '127.0.0.1'; - const wsUrl = `ws://${host}:${port}/ws`; + const host = params.get('host') || window.location.hostname || '127.0.0.1'; + const port = params.get('port') || window.location.port || '34891'; + const wsUrl = `ws://${host}:${port}`; // 상태 const [keyMappings, setKeyMappings] = useState({}); From 88dff32835d0870df33f9e933e7685d8ae513b99 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 18:02:54 +0900 Subject: [PATCH 11/56] =?UTF-8?q?fix:=20Codex=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20OBS=20=EB=AA=A8=EB=93=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88/=EC=95=88=EC=A0=95=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HTTP path traversal 방어 강화 (절대경로/드라이브/역슬래시 거부 + canonicalize 재검증) - X-Content-Type-Options: nosniff 헤더 추가 - stop→start 포트 경쟁 조건 수정 (server_handle JoinHandle 대기) - server_loop running 플래그 경쟁 조건 수정 (stop()에서만 관리) - port=0 입력 시 실제 바인딩 포트 감지 (local_addr) - obs/index.html 엔트리 확장자 수정 (jsx→tsx) - innerHTML XSS → textContent 기반 렌더링 - ObsInMessage/HelloPayload dead code 제거 - v3 로드맵 추가 (설계 문서) Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 35 ++++++++++++++++++- src-tauri/src/models/obs.rs | 22 ------------ src-tauri/src/services/obs_bridge.rs | 50 +++++++++++++++++++++++----- src/renderer/windows/obs/index.html | 2 +- src/renderer/windows/obs/index.tsx | 5 ++- 5 files changed, 81 insertions(+), 33 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index a060dd2d..60dcd520 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -448,7 +448,40 @@ v2는 **OBS 브라우저 소스에서 바로 사용 가능**한 수준까지 완 | **커스텀 JS (플러그인)** | Tauri API 의존으로 불가 | bridge API WebSocket 프록시 레이어 필요 | P3 | | **OBS CEF 호환 테스트** | 미검증 | OBS 28+ 브라우저 소스 WebGL/CSS 실제 테스트 | P3 | -### 11.2 v1 → v2 변경 요약 +### 11.2 v3 구현 범위 + +v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하는 것이 목표. + +#### v3 포함 (P1) + +| # | 작업 | 설명 | 상태 | +|---|------|------|------| +| 1 | **설정 영속화** | OBS 포트/활성 상태를 useSettingsStore + 백엔드 settings에 저장, 재시작 시 복원 | ❌ | +| 2 | **오버레이 연동** | OBS 모드 시작 시 오버레이 창 자동 숨김, 중지 시 복원 | ❌ | +| 3 | **Stats (KPS) 동기화** | KPS 값을 OBS 클라이언트에 실시간 전송 (stats broadcast 또는 counter_update 확장) | ❌ | +| 4 | **UI 안내 문구** | OBS 설정 방법 가이드 텍스트 + 오버레이↔OBS 모드 전환 라디오 버튼 | ❌ | + +#### v3 포함 (P2) + +| # | 작업 | 설명 | 상태 | +|---|------|------|------| +| 5 | **커스텀 CSS** | OBS 페이지에 사용자 CSS 주입, HTTP 서빙 경로로 제공 | ❌ | +| 6 | **배경 이미지/영상** | 사용자 미디어 파일 HTTP 서빙 + 경로 해석 | ❌ | +| 7 | **keyDisplayDelayMs** | OBS에서 키 표시 지연 반영 | ❌ | +| 8 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 반영 | ❌ | +| 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ❌ | +| 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ❌ | +| 11 | **DataSource 호환성 레이어** | Tauri API / WebSocket 통합 인터페이스 (DataSource adapter) 도입, overlay/obs 공용 레이아웃 훅 추출로 중복 제거 | ❌ | + +#### v3+ 이후 (P3) + +| # | 작업 | 설명 | 상태 | +|---|------|------|------| +| 10 | **플러그인 엘리먼트** | bridge API 없는 환경에서 플러그인 UI 렌더링 (서버 HTML 스냅샷 등) | ❌ | +| 11 | **커스텀 JS (플러그인)** | bridge API WebSocket 프록시 레이어로 Tauri API 의존 해소 | ❌ | +| 12 | **OBS CEF 호환 테스트** | OBS 28+ 브라우저 소스에서 WebGL/CSS 실제 검증 | ❌ | + +### 11.3 v1 → v2 변경 요약 | 영역 | v1 | v2 | |------|-----|-----| diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index 8f6e231a..2e059046 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -20,30 +20,8 @@ pub struct ObsEnvelope { pub payload: Value, } -// ── 클라이언트 → 서버 메시지 ── - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[allow(dead_code)] -pub enum ObsInMessage { - Hello { payload: HelloPayload }, - Ping, - ResyncRequest, -} - // ── Payload 타입 ── -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HelloPayload { - pub client: String, - pub protocol: u32, - #[serde(default)] - pub app_version: String, - #[serde(default)] - pub resume_from_seq: u64, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct HelloAckPayload { diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 2c6fdc28..0d01c75e 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -24,6 +24,8 @@ pub struct ObsBridgeService { cached_snapshot: RwLock, broadcast_tx: broadcast::Sender, shutdown_tx: RwLock>>, + /// 서버 루프 태스크 핸들 (stop→start 경쟁 조건 방지) + server_handle: tokio::sync::Mutex>>, /// 빌드된 OBS 정적 파일 경로 (dist/renderer/obs) static_dir: RwLock>, server_version: String, @@ -39,6 +41,7 @@ impl ObsBridgeService { cached_snapshot: RwLock::new(Value::Null), broadcast_tx, shutdown_tx: RwLock::new(None), + server_handle: tokio::sync::Mutex::new(None), static_dir: RwLock::new(None), server_version: version.to_string(), } @@ -104,6 +107,14 @@ impl ObsBridgeService { return Err("OBS bridge already running".to_string()); } + // 이전 서버 태스크 종료 대기 (stop→start 경쟁 조건 방지) + { + let mut handle = self.server_handle.lock().await; + if let Some(h) = handle.take() { + let _ = h.await; + } + } + let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = match TcpListener::bind(addr).await { Ok(l) => l, @@ -113,16 +124,23 @@ impl ObsBridgeService { } }; + // 실제 바인딩된 포트 저장 (port=0인 경우 OS 할당 포트 반영) + let actual_port = listener + .local_addr() + .map(|a| a.port()) + .unwrap_or(port); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); *self.shutdown_tx.write() = Some(shutdown_tx); - *self.port.write() = port; + *self.port.write() = actual_port; let bridge = Arc::clone(self); - tokio::spawn(async move { + let handle = tokio::spawn(async move { bridge.server_loop(listener, shutdown_rx).await; }); + *self.server_handle.lock().await = Some(handle); - log::info!("[ObsBridge] 서버 시작: http://127.0.0.1:{port}"); + log::info!("[ObsBridge] 서버 시작: http://127.0.0.1:{actual_port}"); Ok(()) } @@ -166,7 +184,7 @@ impl ObsBridgeService { } } } - self.running.store(false, Ordering::Relaxed); + // running 플래그는 stop()에서 관리 (server_loop에서 해제하면 restart 시 경쟁 조건 발생) } async fn handle_connection(self: &Arc, mut stream: TcpStream, addr: SocketAddr) { @@ -236,8 +254,12 @@ impl ObsBridgeService { path.trim_start_matches('/') }; - // 경로 탐색 공격 방지 (.. 포함 시 거부) - if normalized.contains("..") { + // 경로 탐색 공격 방지 (.., 절대경로, 드라이브 경로 거부) + if normalized.contains("..") + || normalized.starts_with('/') + || normalized.starts_with('\\') + || normalized.contains(':') + { let _ = stream .write_all( b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", @@ -248,11 +270,23 @@ impl ObsBridgeService { let file_path = static_dir.join(normalized); + // canonicalize 후 static_dir 하위인지 재검증 + if let Ok(canonical) = file_path.canonicalize() { + if !canonical.starts_with(&static_dir) { + 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(&file_path).await { Ok(content) => { let mime = guess_mime(normalized); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", content.len() ); let _ = stream.write_all(response.as_bytes()).await; @@ -264,7 +298,7 @@ impl ObsBridgeService { match tokio::fs::read(&index_path).await { Ok(content) => { let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", content.len() ); let _ = stream.write_all(response.as_bytes()).await; diff --git a/src/renderer/windows/obs/index.html b/src/renderer/windows/obs/index.html index 4cc981b3..87e41bf0 100644 --- a/src/renderer/windows/obs/index.html +++ b/src/renderer/windows/obs/index.html @@ -7,6 +7,6 @@
- + diff --git a/src/renderer/windows/obs/index.tsx b/src/renderer/windows/obs/index.tsx index ee988286..50e2ba4a 100644 --- a/src/renderer/windows/obs/index.tsx +++ b/src/renderer/windows/obs/index.tsx @@ -11,7 +11,10 @@ async function bootstrap() { } catch (error) { const err = error as Error; console.error('[OBS] Failed to mount React app:', err); - document.body.innerHTML = `
OBS Error: ${err.message}\n${err.stack}
`; + const pre = document.createElement('pre'); + pre.style.cssText = 'color: red; padding: 20px;'; + pre.textContent = `OBS Error: ${err.message}\n${err.stack}`; + document.body.replaceChildren(pre); } } From 1c89ed9d2c72ea42159aedefd988f78e899e0781 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 18:22:59 +0900 Subject: [PATCH 12/56] =?UTF-8?q?feat:=20OBS=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=98=81=EC=86=8D=ED=99=94=20+=20=EB=B6=80=ED=8C=85=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings_update 경로에 obs_mode_enabled/obs_port 와이어링 - Zustand 스토어에 obsModeEnabled/obsPort 추가 + 부트스트랩 매핑 - handleObsToggle에서 시작 성공 후 settings_update 호출 (실패 시 잔류 방지) - 부팅 시 obs_mode_enabled=true이면 자동 시작, 실패 시 false로 복구 - Codex 리뷰 반영: obsPort useState 동기화, 매직넘버 제거, 호출 순서 수정 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 2 +- src-tauri/src/models/mod.rs | 8 ++++++ src-tauri/src/services/settings.rs | 14 ++++++++++ src-tauri/src/state/app_state.rs | 34 +++++++++++++++++++++++ src/renderer/components/main/Settings.tsx | 13 ++++++++- src/renderer/hooks/app/useAppBootstrap.ts | 3 ++ src/renderer/stores/useSettingsStore.ts | 11 ++++++++ 7 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 0f328492..dee57fcd 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -5,7 +5,7 @@ use tauri::{AppHandle, Manager, State}; use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; /// OBS 빌드 정적 파일 경로 탐색 -fn resolve_obs_static_dir(app: &AppHandle) -> Option { +pub fn resolve_obs_static_dir(app: &AppHandle) -> Option { // 1. Tauri resource_dir/obs/ (프로덕션 번들) if let Ok(res) = app.path().resource_dir() { let obs = res.join("obs"); diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 1e1fadc2..3103aeaa 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1719,6 +1719,8 @@ pub struct SettingsPatchInput { pub key_counter_enabled: Option, pub grid_settings: Option, pub shortcuts: Option, + pub obs_mode_enabled: Option, + pub obs_port: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -1774,6 +1776,8 @@ impl SettingsDiff { p.key_counter_enabled.is_some(), p.grid_settings.is_some(), p.shortcuts.is_some(), + p.obs_mode_enabled.is_some(), + p.obs_port.is_some(), ] .iter() .filter(|&&x| x) @@ -1830,4 +1834,8 @@ pub struct SettingsPatch { 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub obs_port: Option, } diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 92aa78a6..ac6f8a33 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -49,6 +49,8 @@ impl SettingsService { state.key_counter_enabled = next.key_counter_enabled; state.grid_settings = next.grid_settings.clone(); state.shortcuts = next.shortcuts.clone(); + state.obs_mode_enabled = next.obs_mode_enabled; + state.obs_port = next.obs_port; })?; Ok(SettingsDiff { @@ -177,6 +179,12 @@ fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> Setti } normalized.shortcuts = Some(merged); } + if let Some(value) = patch.obs_mode_enabled { + normalized.obs_mode_enabled = Some(value); + } + if let Some(value) = patch.obs_port { + normalized.obs_port = Some(value); + } normalized } @@ -244,6 +252,12 @@ fn apply_changes(mut current: SettingsState, patch: &SettingsPatch) -> SettingsS if let Some(value) = patch.shortcuts.as_ref() { current.shortcuts = value.clone(); } + if let Some(value) = patch.obs_mode_enabled { + current.obs_mode_enabled = value; + } + if let Some(value) = patch.obs_port { + current.obs_port = value; + } current } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index be27f9b9..7949ed23 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -116,6 +116,10 @@ impl AppState { self.start_keyboard_hook(app.clone())?; // CSS 핫리로딩 워처 초기화 self.initialize_css_watcher(app); + // OBS 모드 자동 복원 + if snapshot.obs_mode_enabled { + self.auto_start_obs(app); + } Ok(()) } @@ -282,6 +286,36 @@ impl AppState { Ok(()) } + /// 부팅 시 OBS 모드 자동 시작 (obs_mode_enabled=true일 때) + fn auto_start_obs(&self, app: &AppHandle) { + let bridge = self.obs_bridge.clone(); + let store = self.store.clone(); + let port = store.with_state(|s| s.obs_port); + let app_handle = app.clone(); + + // 정적 파일 경로 설정 + if let Some(dir) = crate::commands::app::obs::resolve_obs_static_dir(&app_handle) { + log::info!("[ObsBridge] auto-start static_dir: {}", dir.display()); + bridge.set_static_dir(dir); + } + + // async start를 tokio 런타임에서 실행 + tauri::async_runtime::spawn(async move { + match bridge.start(port).await { + Ok(()) => { + log::info!("[ObsBridge] auto-start 성공 (port={})", port); + } + Err(e) => { + log::error!("[ObsBridge] auto-start 실패: {} — obs_mode_enabled를 false로 복구", e); + // 실패 시 obs_mode_enabled를 false로 복구 + let _ = store.update(|state| { + state.obs_mode_enabled = false; + }); + } + } + }); + } + /// OBS 브릿지용 전체 스냅샷 빌드 + 캐시 갱신 + 연결된 클라이언트에 broadcast pub fn refresh_obs_snapshot(&self) { if !self.obs_bridge.is_running() { diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 3c276b0e..8a3db633 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -110,6 +110,7 @@ const Settings = ({ setKeyCounterEnabled, shortcuts, setShortcuts, + obsPort: storedObsPort, } = useSettingsStore(); const { checkForUpdates, isChecking } = useUpdateCheck(); @@ -133,9 +134,16 @@ const Settings = ({ port: DEFAULT_OBS_PORT, clientCount: 0, }); - const [obsPort, setObsPort] = useState(String(DEFAULT_OBS_PORT)); + const [obsPort, setObsPort] = useState(String(storedObsPort)); const [obsLoading, setObsLoading] = useState(false); + // 스토어 포트 변경 시 로컬 상태 동기화 (bootstrap 비동기 로딩 대응) + useEffect(() => { + if (!obsStatus.running) { + setObsPort(String(storedObsPort)); + } + }, [storedObsPort, obsStatus.running]); + // Lenis smooth scroll 적용 (전역 설정 사용) const { scrollContainerRef } = useLenis(); @@ -566,6 +574,7 @@ const Settings = ({ if (obsStatus.running) { const status = await obsApi.stop(); setObsStatus(status); + await window.api.settings.update({ obsModeEnabled: false }); } else { const port = parseInt(obsPort, 10); if (isNaN(port) || port < 1024 || port > 65535) { @@ -574,6 +583,8 @@ const Settings = ({ } const status = await obsApi.start(port); setObsStatus(status); + // 시작 성공 후에만 영속화 (실패 시 obsModeEnabled=true 잔류 방지) + await window.api.settings.update({ obsModeEnabled: true, obsPort: port }); } } catch (error) { console.error('Failed to toggle OBS mode', error); diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index 719e5c3f..29a71175 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -27,6 +27,7 @@ import { refreshCursorSettings, } from '@utils/grid/cursorUtils'; import type { CustomJs, JsPlugin } from '@src/types/plugin/js'; +import { DEFAULT_OBS_PORT } from '@src/types/obs'; function clonePlugins(source?: CustomJs | null): JsPlugin[] { if (!source) return []; @@ -207,6 +208,8 @@ export function useAppBootstrap() { gridSettings: bootstrap.settings.gridSettings ?? getDefaultGridSettings(), shortcuts: bootstrap.settings.shortcuts ?? getDefaultShortcuts(), + obsModeEnabled: bootstrap.settings.obsModeEnabled ?? false, + obsPort: bootstrap.settings.obsPort ?? DEFAULT_OBS_PORT, }); useFontStore.setState({ customFonts: bootstrap.settings.fontSettings.customFonts.map( diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts index d114d241..2ae6d2a7 100644 --- a/src/renderer/stores/useSettingsStore.ts +++ b/src/renderer/stores/useSettingsStore.ts @@ -7,6 +7,7 @@ import type { FontSettings } from '@src/types/settings/fonts'; import type { OverlayResizeAnchor } from '@src/types/settings/settings'; import type { JsPlugin } from '@src/types/plugin/js'; import type { ShortcutsState } from '@src/types/settings/shortcuts'; +import { DEFAULT_OBS_PORT } from '@src/types/obs'; import { getDefaultNoteSettings, getDefaultFontSettings, @@ -46,6 +47,8 @@ interface SettingsState { keyCounterEnabled: boolean; gridSettings: GridSettings; shortcuts: ShortcutsState; + obsModeEnabled: boolean; + obsPort: number; setAll: (payload: SettingsStateSnapshot) => void; merge: (payload: Partial) => void; setLaboratoryEnabled: (value: boolean) => void; @@ -71,6 +74,8 @@ interface SettingsState { setKeyCounterEnabled: (value: boolean) => void; setGridSettings: (value: GridSettings) => void; setShortcuts: (value: ShortcutsState) => void; + setObsModeEnabled: (value: boolean) => void; + setObsPort: (value: number) => void; } export type SettingsStateSnapshot = Omit< @@ -100,6 +105,8 @@ export type SettingsStateSnapshot = Omit< | 'setDeveloperModeEnabled' | 'setGridSettings' | 'setShortcuts' + | 'setObsModeEnabled' + | 'setObsPort' >; const initialState: SettingsStateSnapshot = { @@ -126,6 +133,8 @@ const initialState: SettingsStateSnapshot = { keyCounterEnabled: false, gridSettings: getDefaultGridSettings(), shortcuts: getDefaultShortcuts(), + obsModeEnabled: false, + obsPort: DEFAULT_OBS_PORT, }; function mergeSnapshot( @@ -204,4 +213,6 @@ export const useSettingsStore = create((set) => ({ setKeyCounterEnabled: (value) => set({ keyCounterEnabled: value }), setGridSettings: (value) => set({ gridSettings: value }), setShortcuts: (value) => set({ shortcuts: value }), + setObsModeEnabled: (value) => set({ obsModeEnabled: value }), + setObsPort: (value) => set({ obsPort: value }), })); From 2ffad954337526cb28c1fcf5474768abc13ca92c Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 18:40:22 +0900 Subject: [PATCH 13/56] =?UTF-8?q?feat:=20OBS=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=97=B0=EB=8F=99=20+?= =?UTF-8?q?=20KPS=20=EB=A1=9C=EC=BB=AC=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obs_start 시 오버레이 자동 숨김, obs_stop 시 복원 - overlay_set_visible에 OBS 모드 중 수동 토글 차단 가드 - ToggleOverlay 데몬 핸들러에 OBS 모드 가드 - auto_start_obs 실패 시 오버레이 실제 윈도우까지 복원 - OBS 클라이언트 KPS 1초 슬라이딩 윈도우 (50ms 샘플링) - held-key dedup via activeKeys Set - onCounterUpdate에서 tracker.total 재계산 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 6 +- src-tauri/src/commands/layout/overlay.rs | 6 ++ src-tauri/src/state/app_state.rs | 50 ++++++++++++++- src/renderer/windows/obs/App.tsx | 80 +++++++++++++++++++++++- 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index dee57fcd..8071339c 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -50,12 +50,16 @@ pub async fn obs_start( .map_err(crate::errors::CommandError::msg)?; // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) state.refresh_obs_snapshot(); + // 오버레이 숨김 (이전 상태 보존) + state.obs_hide_overlay(&app); Ok(state.obs_bridge.status()) } #[tauri::command] -pub fn obs_stop(state: State<'_, AppState>) -> CmdResult { +pub fn obs_stop(app: AppHandle, state: State<'_, AppState>) -> CmdResult { state.obs_bridge.stop(); + // 오버레이 복원 + state.obs_restore_overlay(&app); Ok(state.obs_bridge.status()) } diff --git a/src-tauri/src/commands/layout/overlay.rs b/src-tauri/src/commands/layout/overlay.rs index 6e1aea55..82175dbd 100644 --- a/src-tauri/src/commands/layout/overlay.rs +++ b/src-tauri/src/commands/layout/overlay.rs @@ -33,6 +33,12 @@ pub fn overlay_set_visible( app: AppHandle, visible: bool, ) -> CmdResult<()> { + // OBS 모드 활성화 중에는 오버레이 수동 토글 차단 + if state.is_obs_mode_active() { + return Err(crate::errors::CommandError::msg( + "OBS 모드 활성화 중에는 오버레이를 수동으로 전환할 수 없습니다", + )); + } Ok(state.set_overlay_visibility(&app, visible)?) } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 7949ed23..1afad188 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -65,6 +65,8 @@ pub struct AppState { css_watcher: RwLock>, /// OBS WebSocket 브릿지 pub obs_bridge: Arc, + /// OBS 모드 시작 전 오버레이 가시성 상태 (복원용) + obs_previous_overlay_visible: Arc>>, } impl AppState { @@ -97,6 +99,7 @@ impl AppState { key_sound, css_watcher: RwLock::new(None), obs_bridge, + obs_previous_overlay_visible: Arc::new(RwLock::new(None)), }) } @@ -299,6 +302,12 @@ impl AppState { bridge.set_static_dir(dir); } + // 오버레이 숨김 (부팅 시 flash 방지) + self.obs_hide_overlay(app); + + let obs_previous_overlay_visible = self.obs_previous_overlay_visible.clone(); + let overlay_visible = self.overlay_visible.clone(); + // async start를 tokio 런타임에서 실행 tauri::async_runtime::spawn(async move { match bridge.start(port).await { @@ -307,15 +316,50 @@ impl AppState { } Err(e) => { log::error!("[ObsBridge] auto-start 실패: {} — obs_mode_enabled를 false로 복구", e); - // 실패 시 obs_mode_enabled를 false로 복구 let _ = store.update(|state| { state.obs_mode_enabled = false; }); + // 실패 시 오버레이 상태 복원 (플래그 + 실제 윈도우) + if let Some(true) = obs_previous_overlay_visible.write().take() { + *overlay_visible.write() = true; + let _ = store.update(|state| { + state.overlay_visible = true; + }); + if let Some(window) = app_handle.get_webview_window(OVERLAY_LABEL) { + let _ = window.show(); + } + } } } }); } + /// OBS 시작 시 오버레이 숨김 (이전 상태 보존) + pub fn obs_hide_overlay(&self, app: &AppHandle) { + let was_visible = *self.overlay_visible.read(); + *self.obs_previous_overlay_visible.write() = Some(was_visible); + if was_visible { + if let Err(e) = self.set_overlay_visibility(app, false) { + log::warn!("[ObsBridge] 오버레이 숨기기 실패: {}", e); + } + } + } + + /// OBS 중지 시 오버레이 복원 + pub fn obs_restore_overlay(&self, app: &AppHandle) { + let prev = self.obs_previous_overlay_visible.write().take(); + if let Some(true) = prev { + if let Err(e) = self.set_overlay_visibility(app, true) { + log::warn!("[ObsBridge] 오버레이 복원 실패: {}", e); + } + } + } + + /// 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() { @@ -664,10 +708,14 @@ impl AppState { 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"); diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx index fcab60ee..bba6e798 100644 --- a/src/renderer/windows/obs/App.tsx +++ b/src/renderer/windows/obs/App.tsx @@ -64,6 +64,48 @@ export default function App() { handleKeyUpRef.current = handleKeyUp; }, [handleKeyDown, handleKeyUp]); + // selectedKeyType를 ref로 유지 (콜백에서 최신 값 참조) + const selectedKeyTypeRef = useRef(selectedKeyType); + useEffect(() => { + selectedKeyTypeRef.current = selectedKeyType; + }, [selectedKeyType]); + + // KPS 로컬 계산 (1초 슬라이딩 윈도우) + const kpsRef = useRef({ + timestamps: [] as number[], + total: 0, + kpsMax: 0, + kpsSumForAvg: 0, + kpsNonZeroCount: 0, + activeKeys: new Set(), + }); + + useEffect(() => { + const interval = setInterval(() => { + const tracker = kpsRef.current; + const now = performance.now(); + while (tracker.timestamps.length > 0 && now - tracker.timestamps[0] > 1000) { + tracker.timestamps.shift(); + } + const kps = tracker.timestamps.length; + if (kps > tracker.kpsMax) tracker.kpsMax = kps; + if (kps > 0) { + tracker.kpsSumForAvg += kps; + tracker.kpsNonZeroCount++; + } + const kpsAvg = tracker.kpsNonZeroCount > 0 + ? Math.round(tracker.kpsSumForAvg / tracker.kpsNonZeroCount) + : 0; + applyStatsSnapshot({ + kps, + kpsAvg, + kpsMax: tracker.kpsMax, + total: tracker.total, + }); + }, 50); + return () => clearInterval(interval); + }, []); + // 스냅샷 수신 const onSnapshot = useCallback((payload: BootstrapPayload) => { setKeyMappings(payload.keys ?? {}); @@ -90,8 +132,23 @@ export default function App() { applyCounterSnapshot(payload.keyCounters); } - // 통계 초기화 - applyStatsSnapshot({ kps: 0, kpsAvg: 0, kpsMax: 0, total: 0 }); + // KPS 트래커 리셋 + const tracker = kpsRef.current; + tracker.timestamps = []; + tracker.kpsMax = 0; + tracker.kpsSumForAvg = 0; + tracker.kpsNonZeroCount = 0; + tracker.activeKeys.clear(); + // total은 카운터 합산으로 초기화 + let totalFromCounters = 0; + if (payload.keyCounters) { + const modeCounters = payload.keyCounters[payload.selectedKeyType ?? '4key']; + if (modeCounters) { + totalFromCounters = Object.values(modeCounters).reduce((sum, v) => sum + v, 0); + } + } + tracker.total = totalFromCounters; + applyStatsSnapshot({ kps: 0, kpsAvg: 0, kpsMax: 0, total: totalFromCounters }); resetAllKeySignals(); setInitialized(true); @@ -103,9 +160,17 @@ export default function App() { const isDown = state === 'DOWN'; setKeyActiveSignal(key, isDown); + // KPS 로컬 계산 피드 + const tracker = kpsRef.current; if (isDown) { + if (!tracker.activeKeys.has(key)) { + tracker.activeKeys.add(key); + tracker.timestamps.push(performance.now()); + tracker.total++; + } requestAnimationFrame(() => handleKeyDownRef.current(key)); } else { + tracker.activeKeys.delete(key); requestAnimationFrame(() => handleKeyUpRef.current(key)); } }, []); @@ -128,7 +193,16 @@ export default function App() { // 카운터 업데이트 const onCounterUpdate = useCallback((data: Record) => { - applyCounterSnapshot(data as Record>); + const counters = data as Record>; + applyCounterSnapshot(counters); + // 카운터 리셋/수정 시 total 재계산 + const modeCounters = counters[selectedKeyTypeRef.current]; + if (modeCounters) { + kpsRef.current.total = Object.values(modeCounters).reduce( + (sum, v) => sum + v, + 0, + ); + } }, []); useObsWebSocket({ From 3bd6a15e40c8a2e62e7b2ef9284b0bba4fa55ca2 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 18:46:15 +0900 Subject: [PATCH 14/56] =?UTF-8?q?feat:=20OBS=20=EB=AA=A8=EB=93=9C=20UI=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OBS 실행 중 브라우저 소스 설정 가이드 텍스트 표시 - 오버레이 숨김 경고 메시지 (amber 색상) - role="status" 접근성 속성 추가 - 5개 locale (en/ko/zh-cn/zh-Hant/ru) 문자열 추가 Co-Authored-By: Claude Opus 4.6 --- src/renderer/components/main/Settings.tsx | 10 ++++++++++ src/renderer/locales/en.json | 4 +++- src/renderer/locales/ko.json | 4 +++- src/renderer/locales/ru.json | 4 +++- src/renderer/locales/zh-Hant.json | 4 +++- src/renderer/locales/zh-cn.json | 4 +++- 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 8a3db633..a992e0c9 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -971,6 +971,16 @@ const Settings = ({
+ {obsStatus.running && ( +
+

+ {t('settings.obsGuide')} +

+

+ {t('settings.obsOverlayHidden')} +

+
+ )} {/* 기타 설정 */}
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 2c06e680..c520f4fe 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -83,7 +83,9 @@ "obsCopyUrl": "Copy URL", "obsCopied": "OBS URL copied to clipboard.", "obsStartFailed": "Failed to start OBS server.", - "obsStopFailed": "Failed to stop OBS server." + "obsStopFailed": "Failed to stop OBS server.", + "obsGuide": "Add a Browser Source in OBS and paste the URL above. Set the width/height to match your overlay size.", + "obsOverlayHidden": "Overlay is hidden while OBS mode is active." }, "shortcutSetting": { "title": "Shortcut Settings", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index e883d50b..9c70929d 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -83,7 +83,9 @@ "obsCopyUrl": "URL 복사", "obsCopied": "OBS URL이 클립보드에 복사되었습니다.", "obsStartFailed": "OBS 서버 시작에 실패했습니다.", - "obsStopFailed": "OBS 서버 중지에 실패했습니다." + "obsStopFailed": "OBS 서버 중지에 실패했습니다.", + "obsGuide": "OBS에서 브라우저 소스를 추가하고 위 URL을 붙여넣으세요. 너비/높이를 오버레이 크기에 맞게 설정하세요.", + "obsOverlayHidden": "OBS 모드 활성화 중에는 오버레이가 숨겨집니다." }, "shortcutSetting": { "title": "단축키 설정", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index fef7db6f..441854ce 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -83,7 +83,9 @@ "obsCopyUrl": "Copy URL", "obsCopied": "OBS URL copied to clipboard.", "obsStartFailed": "Failed to start OBS server.", - "obsStopFailed": "Failed to stop OBS server." + "obsStopFailed": "Failed to stop OBS server.", + "obsGuide": "Добавьте источник «Браузер» в OBS и вставьте URL выше. Установите ширину/высоту в соответствии с размером оверлея.", + "obsOverlayHidden": "Оверлей скрыт, пока активен режим OBS." }, "shortcutSetting": { "title": "Горячие клавиши", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 430148f1..5a78c22f 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -83,7 +83,9 @@ "obsCopyUrl": "Copy URL", "obsCopied": "OBS URL copied to clipboard.", "obsStartFailed": "Failed to start OBS server.", - "obsStopFailed": "Failed to stop OBS server." + "obsStopFailed": "Failed to stop OBS server.", + "obsGuide": "在 OBS 中新增瀏覽器來源並貼上上方 URL。將寬度/高度設定為與覆蓋層大小一致。", + "obsOverlayHidden": "OBS 模式啟用期間覆蓋層已隱藏。" }, "shortcutSetting": { "title": "快捷鍵設定", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index fa80cbdb..e38745b8 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -83,7 +83,9 @@ "obsCopyUrl": "Copy URL", "obsCopied": "OBS URL copied to clipboard.", "obsStartFailed": "Failed to start OBS server.", - "obsStopFailed": "Failed to stop OBS server." + "obsStopFailed": "Failed to stop OBS server.", + "obsGuide": "在 OBS 中添加浏览器源并粘贴上方 URL。将宽度/高度设置为与叠加层大小一致。", + "obsOverlayHidden": "OBS 模式启用期间叠加层已隐藏。" }, "shortcutSetting": { "title": "快捷键设置", From a99e63898781c68f17ccc71305bd8fdea082e3ab Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 18:47:02 +0900 Subject: [PATCH 15/56] =?UTF-8?q?docs:=20OBS=20v3=20=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A7=B5=20=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index 60dcd520..67f55982 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -333,10 +333,10 @@ v1 구현: - 하단: 포트 입력 + URL 복사 버튼 + 시작/중지 버튼 - 3초 주기 상태 폴링 (shallow 비교로 불필요한 리렌더 방지) -설계와 차이: -- ❌ 라디오 버튼 모드 전환 (오버레이 ↔ OBS) 미구현 -- ❌ OBS 모드 시 오버레이 창 자동 숨김 미구현 -- ❌ 안내 문구 (OBS 설정 방법) 미표시 +v3 추가: +- ✅ OBS 모드 시 오버레이 창 자동 숨김/복원 +- ✅ 안내 문구 (OBS 설정 방법 가이드 + 오버레이 숨김 경고) +- 라디오 버튼 모드 전환은 자동 숨김/복원으로 대체 ### 7.2 모드 전환 동작 ⚠️ 부분 구현 @@ -456,10 +456,10 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 | # | 작업 | 설명 | 상태 | |---|------|------|------| -| 1 | **설정 영속화** | OBS 포트/활성 상태를 useSettingsStore + 백엔드 settings에 저장, 재시작 시 복원 | ❌ | -| 2 | **오버레이 연동** | OBS 모드 시작 시 오버레이 창 자동 숨김, 중지 시 복원 | ❌ | -| 3 | **Stats (KPS) 동기화** | KPS 값을 OBS 클라이언트에 실시간 전송 (stats broadcast 또는 counter_update 확장) | ❌ | -| 4 | **UI 안내 문구** | OBS 설정 방법 가이드 텍스트 + 오버레이↔OBS 모드 전환 라디오 버튼 | ❌ | +| 1 | **설정 영속화** | OBS 포트/활성 상태를 useSettingsStore + 백엔드 settings에 저장, 재시작 시 복원 | ✅ | +| 2 | **오버레이 연동** | OBS 모드 시작 시 오버레이 창 자동 숨김, 중지 시 복원 | ✅ | +| 3 | **Stats (KPS) 동기화** | KPS 값을 OBS 클라이언트 로컬에서 계산 (1초 슬라이딩 윈도우) | ✅ | +| 4 | **UI 안내 문구** | OBS 설정 방법 가이드 텍스트 + 오버레이 숨김 경고 | ✅ | #### v3 포함 (P2) From 4d1e38f97f454662589436221be262931b8bc768 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 18:58:50 +0900 Subject: [PATCH 16/56] =?UTF-8?q?feat:=20OBS=20keyDisplayDelayMs=20+=20per?= =?UTF-8?q?-key=20noteEffectEnabled=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keyDisplayDelayMs: 메인 오버레이와 동일한 딜레이 패턴 적용 - per-key noteEffectEnabled: 키별 노트 효과 on/off 필터링 - onSnapshot에서 딜레이 타이머 정리 + ref 즉시 동기화 - ESLint cleanup ref 경고 수정 Co-Authored-By: Claude Opus 4.6 --- src/renderer/windows/obs/App.tsx | 92 ++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx index bba6e798..46b28588 100644 --- a/src/renderer/windows/obs/App.tsx +++ b/src/renderer/windows/obs/App.tsx @@ -17,7 +17,7 @@ import OverlayScene, { } from '@components/shared/OverlayScene'; import type { BootstrapPayload } from '@src/types/app'; import type { KeyEventPayload } from '@src/types/obs'; -import type { KeyMappings, KeyPositions } from '@src/types/key/keys'; +import type { KeyMappings, KeyPosition, KeyPositions } from '@src/types/key/keys'; import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { NoteSettings } from '@src/types/settings/noteSettings'; @@ -70,6 +70,37 @@ export default function App() { selectedKeyTypeRef.current = selectedKeyType; }, [selectedKeyType]); + // 현재 모드의 키/포지션을 ref로 유지 (이벤트 콜백에서 최신 값 참조) + const keyMappingsRef = useRef([]); + const positionsRef = useRef([]); + useEffect(() => { + keyMappingsRef.current = keyMappings[selectedKeyType] ?? []; + positionsRef.current = positions[selectedKeyType] ?? []; + }, [keyMappings, positions, selectedKeyType]); + + // 키 딜레이 설정 + const keyDisplayDelayMsRef = useRef(0); + const keyDelayTimersRef = useRef< + Map> }> + >(new Map()); + + useEffect(() => { + keyDisplayDelayMsRef.current = Number( + noteSettings?.keyDisplayDelayMs ?? 0, + ); + }, [noteSettings?.keyDisplayDelayMs]); + + // 딜레이 타이머 클린업 + useEffect(() => { + const timers = keyDelayTimersRef.current; + return () => { + timers.forEach((entry) => { + entry.timers.forEach((timer) => clearTimeout(timer)); + }); + timers.clear(); + }; + }, []); + // KPS 로컬 계산 (1초 슬라이딩 윈도우) const kpsRef = useRef({ timestamps: [] as number[], @@ -150,15 +181,61 @@ export default function App() { tracker.total = totalFromCounters; applyStatsSnapshot({ kps: 0, kpsAvg: 0, kpsMax: 0, total: totalFromCounters }); + // 딜레이 타이머 정리 (resync 시 이전 타이머 잔류 방지) + keyDelayTimersRef.current.forEach((entry) => { + entry.timers.forEach((timer) => clearTimeout(timer)); + entry.timers.clear(); + }); + + // ref 즉시 동기화 (useEffect 지연 없이 첫 key_event에서 최신 값 사용) + const mode = payload.selectedKeyType ?? '4key'; + selectedKeyTypeRef.current = mode; + keyMappingsRef.current = (payload.keys ?? {})[mode] ?? []; + positionsRef.current = (payload.positions ?? {})[mode] ?? []; + if (payload.settings?.noteSettings) { + keyDisplayDelayMsRef.current = Number( + payload.settings.noteSettings.keyDisplayDelayMs ?? 0, + ); + } + resetAllKeySignals(); setInitialized(true); }, []); + // 키 딜레이 적용 신호 업데이트 + const updateKeySignalWithDelay = useCallback( + (key: string, isDown: boolean) => { + const delayMs = keyDisplayDelayMsRef.current; + + let timerEntry = keyDelayTimersRef.current.get(key); + if (!timerEntry) { + timerEntry = { timers: new Set() }; + keyDelayTimersRef.current.set(key, timerEntry); + } + + if (delayMs <= 0) { + timerEntry.timers.forEach((timer) => clearTimeout(timer)); + timerEntry.timers.clear(); + setKeyActiveSignal(key, isDown); + return; + } + + const timer = setTimeout(() => { + setKeyActiveSignal(key, isDown); + timerEntry?.timers.delete(timer); + }, delayMs); + timerEntry.timers.add(timer); + }, + [], + ); + // 키 이벤트 수신 const onKeyEvent = useCallback((payload: KeyEventPayload) => { const { key, state } = payload; const isDown = state === 'DOWN'; - setKeyActiveSignal(key, isDown); + + // 키 UI 업데이트 (딜레이 적용) + updateKeySignalWithDelay(key, isDown); // KPS 로컬 계산 피드 const tracker = kpsRef.current; @@ -168,12 +245,19 @@ export default function App() { tracker.timestamps.push(performance.now()); tracker.total++; } - requestAnimationFrame(() => handleKeyDownRef.current(key)); + // 개별 키의 noteEffectEnabled 확인 + const keys = keyMappingsRef.current; + const pos = positionsRef.current; + const keyIndex = keys.indexOf(key); + const keyPosition = pos[keyIndex]; + if (keyPosition?.noteEffectEnabled !== false) { + requestAnimationFrame(() => handleKeyDownRef.current(key)); + } } else { tracker.activeKeys.delete(key); requestAnimationFrame(() => handleKeyUpRef.current(key)); } - }, []); + }, [updateKeySignalWithDelay]); // 설정 변경 const onSettingsDiff = useCallback((diff: Record) => { From 90f508d84bc2f851c96a172310b56dba37939d06 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 19:01:00 +0900 Subject: [PATCH 17/56] =?UTF-8?q?docs:=20OBS=20v3=20P2=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EB=A7=B5=20=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index 67f55982..f4fd42c5 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -467,8 +467,8 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 |---|------|------|------| | 5 | **커스텀 CSS** | OBS 페이지에 사용자 CSS 주입, HTTP 서빙 경로로 제공 | ❌ | | 6 | **배경 이미지/영상** | 사용자 미디어 파일 HTTP 서빙 + 경로 해석 | ❌ | -| 7 | **keyDisplayDelayMs** | OBS에서 키 표시 지연 반영 | ❌ | -| 8 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 반영 | ❌ | +| 7 | **keyDisplayDelayMs** | OBS에서 키 표시 지연 반영 (메인 오버레이와 동일 패턴) | ✅ | +| 8 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 반영 | ✅ | | 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ❌ | | 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ❌ | | 11 | **DataSource 호환성 레이어** | Tauri API / WebSocket 통합 인터페이스 (DataSource adapter) 도입, overlay/obs 공용 레이아웃 훅 추출로 중복 제거 | ❌ | From 4bbd90e915a5c007af0d9642b298dcccfb538a0f Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 19:23:54 +0900 Subject: [PATCH 18/56] =?UTF-8?q?refactor:=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20computeLayout()=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EC=B6=9C=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overlay/obs App.tsx에서 ~330줄의 중복 레이아웃 계산 코드를 hooks/shared/useLayoutComputation.ts의 순수 함수로 통합. pluginElements는 optional param으로 overlay 전용 지원 유지. Co-Authored-By: Claude Opus 4.6 --- .../hooks/shared/useLayoutComputation.ts | 221 ++++++++++++++++++ src/renderer/windows/obs/App.tsx | 151 ++---------- src/renderer/windows/overlay/App.tsx | 218 ++--------------- 3 files changed, 256 insertions(+), 334 deletions(-) create mode 100644 src/renderer/hooks/shared/useLayoutComputation.ts diff --git a/src/renderer/hooks/shared/useLayoutComputation.ts b/src/renderer/hooks/shared/useLayoutComputation.ts new file mode 100644 index 00000000..3f20d156 --- /dev/null +++ b/src/renderer/hooks/shared/useLayoutComputation.ts @@ -0,0 +1,221 @@ +import { + DEFAULT_NOTE_BORDER_RADIUS, + DEFAULT_NOTE_SETTINGS, +} from '@constants/overlayDefaults'; +import { FALLBACK_POSITION } from '@components/shared/OverlayScene'; +import type { KeyPosition } from '@src/types/key/keys'; +import type { StatItemPosition } from '@src/types/key/statItems'; +import type { GraphItemPosition } from '@src/types/key/graphItems'; +import type { NoteSettings } from '@src/types/settings/noteSettings'; + +const PADDING = 30; + +interface PluginElement { + hidden?: boolean; + tabId?: string; + position: { x: number; y: number }; + anchor?: { + keyCode?: string; + offset?: { x?: number; y?: number }; + }; + measuredSize?: { width?: number; height?: number }; + estimatedSize?: { width?: number; height?: number }; +} + +interface LayoutInput { + currentKeys: string[]; + currentPositions: KeyPosition[]; + currentStatPositions: StatItemPosition[]; + currentGraphPositions: GraphItemPosition[]; + trackHeight: number; + noteSettings: NoteSettings; + selectedKeyType?: string; + pluginElements?: PluginElement[]; +} + +interface Bounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export function computeLayout(input: LayoutInput) { + const { + currentKeys, + currentPositions, + currentStatPositions, + currentGraphPositions, + trackHeight, + noteSettings, + selectedKeyType, + pluginElements, + } = input; + + // bounds 계산 + const bounds: Bounds | null = (() => { + const hasContent = + currentPositions.length > 0 || + currentStatPositions.length > 0 || + currentGraphPositions.length > 0 || + (pluginElements && pluginElements.length > 0); + if (!hasContent) return null; + + const xs: number[] = []; + const ys: number[] = []; + const widths: number[] = []; + const heights: number[] = []; + + currentPositions.forEach((pos) => { + if (pos.hidden) return; + xs.push(pos.dx); + ys.push(pos.dy); + widths.push(pos.dx + pos.width); + heights.push(pos.dy + pos.height); + }); + + currentStatPositions.forEach((pos) => { + if (!pos || pos.hidden) return; + xs.push(pos.dx); + ys.push(pos.dy); + widths.push(pos.dx + (pos.width ?? 60)); + heights.push(pos.dy + (pos.height ?? 60)); + }); + + currentGraphPositions.forEach((pos) => { + if (!pos || pos.hidden) return; + xs.push(pos.dx); + ys.push(pos.dy); + widths.push(pos.dx + (pos.width ?? 200)); + heights.push(pos.dy + (pos.height ?? 100)); + }); + + // 플러그인 요소 위치 (앵커 기반 계산 포함) + if (pluginElements && selectedKeyType) { + pluginElements + .filter( + (el) => !el.hidden && (!el.tabId || el.tabId === selectedKeyType), + ) + .forEach((element) => { + let x = element.position.x; + let y = element.position.y; + + if (element.anchor?.keyCode) { + const keyIndex = currentKeys.findIndex( + (key) => key === element.anchor?.keyCode, + ); + if (keyIndex >= 0 && currentPositions[keyIndex]) { + const keyPosition = currentPositions[keyIndex]; + const offsetX = element.anchor.offset?.x ?? 0; + const offsetY = element.anchor.offset?.y ?? 0; + x = keyPosition.dx + offsetX; + y = keyPosition.dy + offsetY; + } + } + + const width = + element.measuredSize?.width ?? + element.estimatedSize?.width ?? + 200; + const height = + element.measuredSize?.height ?? + element.estimatedSize?.height ?? + 150; + + xs.push(x); + ys.push(y); + widths.push(x + width); + heights.push(y + height); + }); + } + + if (xs.length === 0) return null; + + return { + minX: Math.min(...xs), + minY: Math.min(...ys), + maxX: Math.max(...widths), + maxY: Math.max(...heights), + }; + })(); + + // 오프셋 계산 + const topOffset = trackHeight + PADDING; + const offsetX = bounds ? PADDING - bounds.minX : 0; + const offsetY = bounds ? topOffset - bounds.minY : 0; + + const applyOffset = ( + items: T[], + ): T[] => { + if (!bounds || !items.length) return items; + return items.map((item) => ({ + ...item, + dx: item.dx + offsetX, + dy: item.dy + offsetY, + })); + }; + + const displayPositions = applyOffset(currentPositions); + const displayStatPositions = applyOffset(currentStatPositions); + const displayGraphPositions = applyOffset(currentGraphPositions); + + const positionOffset = bounds + ? { x: offsetX, y: offsetY } + : { x: 0, y: 0 }; + + const topMostY = bounds ? topOffset : 0; + + // WebGL 트랙 계산 + const webglTracks = currentKeys + .map((key, index) => { + const originalPosition = currentPositions[index] ?? FALLBACK_POSITION; + if (originalPosition.hidden) return null; + const position = displayPositions[index] ?? originalPosition; + const useAutoCorrection = position.noteAutoYCorrection !== false; + const trackStartY = useAutoCorrection ? topMostY : position.dy; + const keyWidth = position.width; + const desiredNoteWidth = + typeof position.noteWidth === 'number' && + Number.isFinite(position.noteWidth) + ? Math.max(1, Math.round(position.noteWidth)) + : keyWidth; + const noteOffsetX = (keyWidth - desiredNoteWidth) / 2; + + return { + trackKey: key, + trackIndex: position.zIndex ?? index, + position: { + ...position, + dx: position.dx + noteOffsetX, + dy: trackStartY, + }, + width: desiredNoteWidth, + height: trackHeight, + noteColor: position.noteColor, + noteOpacity: position.noteOpacity, + noteOpacityTop: position.noteOpacityTop ?? position.noteOpacity, + noteOpacityBottom: position.noteOpacityBottom ?? position.noteOpacity, + noteGlowEnabled: position.noteGlowEnabled ?? false, + noteGlowSize: position.noteGlowSize ?? 20, + noteGlowOpacity: position.noteGlowOpacity ?? 70, + noteGlowOpacityTop: + position.noteGlowOpacityTop ?? position.noteGlowOpacity ?? 70, + noteGlowOpacityBottom: + position.noteGlowOpacityBottom ?? position.noteGlowOpacity ?? 70, + noteGlowColor: position.noteGlowColor ?? position.noteColor, + flowSpeed: noteSettings?.speed ?? DEFAULT_NOTE_SETTINGS.speed, + borderRadius: position.noteBorderRadius ?? DEFAULT_NOTE_BORDER_RADIUS, + }; + }) + .filter(Boolean); + + return { + bounds, + displayPositions, + displayStatPositions, + displayGraphPositions, + positionOffset, + topMostY, + webglTracks, + }; +} diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx index 46b28588..4487a8b4 100644 --- a/src/renderer/windows/obs/App.tsx +++ b/src/renderer/windows/obs/App.tsx @@ -12,18 +12,14 @@ import { } from '@stores/signals/keySignals'; import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; import { applyStatsSnapshot } from '@stores/signals/statsSignals'; -import OverlayScene, { - FALLBACK_POSITION, -} from '@components/shared/OverlayScene'; +import OverlayScene from '@components/shared/OverlayScene'; +import { computeLayout } from '@hooks/shared/useLayoutComputation'; import type { BootstrapPayload } from '@src/types/app'; import type { KeyEventPayload } from '@src/types/obs'; import type { KeyMappings, KeyPosition, KeyPositions } from '@src/types/key/keys'; import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { NoteSettings } from '@src/types/settings/noteSettings'; -import { DEFAULT_NOTE_BORDER_RADIUS } from '@constants/overlayDefaults'; - -const PADDING = 30; export default function App() { // WS 연결 URL: HTTP로 서빙된 경우 같은 호스트:포트, 아닌 경우 query param fallback @@ -306,136 +302,19 @@ export default function App() { const trackHeight = noteSettings?.trackHeight ?? DEFAULT_NOTE_SETTINGS.trackHeight; - // bounds 계산 - const bounds = (() => { - if ( - !currentPositions.length && - !currentStatPositions.length && - !currentGraphPositions.length - ) - return null; - - const xs: number[] = []; - const ys: number[] = []; - const widths: number[] = []; - const heights: number[] = []; - - currentPositions.forEach((pos) => { - if (pos.hidden) return; - xs.push(pos.dx); - ys.push(pos.dy); - widths.push(pos.dx + pos.width); - heights.push(pos.dy + pos.height); - }); - - currentStatPositions.forEach((pos) => { - if (!pos || pos.hidden) return; - xs.push(pos.dx); - ys.push(pos.dy); - widths.push(pos.dx + (pos.width ?? 60)); - heights.push(pos.dy + (pos.height ?? 60)); - }); - - currentGraphPositions.forEach((pos) => { - if (!pos || pos.hidden) return; - xs.push(pos.dx); - ys.push(pos.dy); - widths.push(pos.dx + (pos.width ?? 200)); - heights.push(pos.dy + (pos.height ?? 100)); - }); - - if (xs.length === 0) return null; - - return { - minX: Math.min(...xs), - minY: Math.min(...ys), - maxX: Math.max(...widths), - maxY: Math.max(...heights), - }; - })(); - - // 표시 위치 계산 - const displayPositions = (() => { - if (!bounds || !currentPositions.length) return currentPositions; - const topOffset = trackHeight + PADDING; - const offsetX = PADDING - bounds.minX; - const offsetY = topOffset - bounds.minY; - return currentPositions.map((pos) => ({ - ...pos, - dx: pos.dx + offsetX, - dy: pos.dy + offsetY, - })); - })(); - - const displayStatPositions = (() => { - if (!bounds || !currentStatPositions.length) return currentStatPositions; - const topOffset = trackHeight + PADDING; - const offsetX = PADDING - bounds.minX; - const offsetY = topOffset - bounds.minY; - return currentStatPositions.map((pos) => ({ - ...pos, - dx: pos.dx + offsetX, - dy: pos.dy + offsetY, - })); - })(); - - const displayGraphPositions = (() => { - if (!bounds || !currentGraphPositions.length) return currentGraphPositions; - const topOffset = trackHeight + PADDING; - const offsetX = PADDING - bounds.minX; - const offsetY = topOffset - bounds.minY; - return currentGraphPositions.map((pos) => ({ - ...pos, - dx: pos.dx + offsetX, - dy: pos.dy + offsetY, - })); - })(); - - const topMostY = bounds ? trackHeight + PADDING : 0; - - // WebGL 트랙 계산 - const webglTracks = currentKeys - .map((key, index) => { - const originalPosition = currentPositions[index] ?? FALLBACK_POSITION; - if (originalPosition.hidden) return null; - const position = displayPositions[index] ?? originalPosition; - const useAutoCorrection = position.noteAutoYCorrection !== false; - const trackStartY = useAutoCorrection ? topMostY : position.dy; - const keyWidth = position.width; - const desiredNoteWidth = - typeof position.noteWidth === 'number' && - Number.isFinite(position.noteWidth) - ? Math.max(1, Math.round(position.noteWidth)) - : keyWidth; - const noteOffsetX = (keyWidth - desiredNoteWidth) / 2; - - return { - trackKey: key, - trackIndex: position.zIndex ?? index, - position: { - ...position, - dx: position.dx + noteOffsetX, - dy: trackStartY, - }, - width: desiredNoteWidth, - height: trackHeight, - noteColor: position.noteColor, - noteOpacity: position.noteOpacity, - noteOpacityTop: position.noteOpacityTop ?? position.noteOpacity, - noteOpacityBottom: position.noteOpacityBottom ?? position.noteOpacity, - noteGlowEnabled: position.noteGlowEnabled ?? false, - noteGlowSize: position.noteGlowSize ?? 20, - noteGlowOpacity: position.noteGlowOpacity ?? 70, - noteGlowOpacityTop: - position.noteGlowOpacityTop ?? position.noteGlowOpacity ?? 70, - noteGlowOpacityBottom: - position.noteGlowOpacityBottom ?? position.noteGlowOpacity ?? 70, - noteGlowColor: position.noteGlowColor ?? position.noteColor, - flowSpeed: noteSettings?.speed ?? DEFAULT_NOTE_SETTINGS.speed, - borderRadius: position.noteBorderRadius ?? DEFAULT_NOTE_BORDER_RADIUS, - }; - }) - .filter(Boolean); + const { + displayPositions, + displayStatPositions, + displayGraphPositions, + webglTracks, + } = computeLayout({ + currentKeys, + currentPositions, + currentStatPositions, + currentGraphPositions, + trackHeight, + noteSettings, + }); useEffect(() => { updateTrackLayouts(webglTracks); diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index 0499741c..1d30b670 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -7,10 +7,7 @@ import { import { LogicalPosition, PhysicalPosition } from '@tauri-apps/api/dpi'; import { Menu } from '@tauri-apps/api/menu'; import { useTranslation } from '@contexts/useTranslation'; -import { - DEFAULT_NOTE_BORDER_RADIUS, - DEFAULT_NOTE_SETTINGS, -} from '@constants/overlayDefaults'; +import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; import { mergeNoteSettings } from '@src/types/settings/noteSettings'; import { useCustomCssInjection } from '@hooks/app/useCustomCssInjection'; import { useCustomJsInjection } from '@hooks/app/useCustomJsInjection'; @@ -30,9 +27,8 @@ import type { KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; -import OverlayScene, { - FALLBACK_POSITION, -} from '@components/shared/OverlayScene'; +import OverlayScene from '@components/shared/OverlayScene'; +import { computeLayout } from '@hooks/shared/useLayoutComputation'; const PADDING = 30; @@ -488,201 +484,27 @@ export default function App() { ]); const currentKeys = keyMappings[selectedKeyType] ?? []; - const currentPositions = positions[selectedKeyType] ?? []; - const currentStatPositions = statPositions[selectedKeyType] ?? []; - const currentGraphPositions = graphPositions[selectedKeyType] ?? []; - const bounds = (() => { - if ( - !currentPositions.length && - !currentStatPositions.length && - !currentGraphPositions.length && - !pluginElements.length - ) - return null; - - const xs: number[] = []; - const ys: number[] = []; - const widths: number[] = []; - const heights: number[] = []; - - // 키 위치 - currentPositions.forEach((pos) => { - if (pos.hidden) return; - xs.push(pos.dx); - ys.push(pos.dy); - widths.push(pos.dx + pos.width); - heights.push(pos.dy + pos.height); - }); - - // 통계 요소 위치 - currentStatPositions.forEach((pos) => { - if (!pos || pos.hidden) return; - xs.push(pos.dx); - ys.push(pos.dy); - widths.push(pos.dx + (pos.width ?? 60)); - heights.push(pos.dy + (pos.height ?? 60)); - }); - - // 그래프 요소 위치 - currentGraphPositions.forEach((pos) => { - if (!pos || pos.hidden) return; - xs.push(pos.dx); - ys.push(pos.dy); - widths.push(pos.dx + (pos.width ?? 200)); - heights.push(pos.dy + (pos.height ?? 100)); - }); - - // 플러그인 요소 위치 (앵커 기반 계산 포함) - pluginElements - .filter((el) => !el.hidden && (!el.tabId || el.tabId === selectedKeyType)) - .forEach((element) => { - let x = element.position.x; - let y = element.position.y; - - // 앵커 기반 위치 계산 - if (element.anchor?.keyCode && selectedKeyType) { - const keyIndex = currentKeys.findIndex( - (key) => key === element.anchor?.keyCode, - ); - if (keyIndex >= 0 && currentPositions[keyIndex]) { - const keyPosition = currentPositions[keyIndex]; - const offsetX = element.anchor.offset?.x ?? 0; - const offsetY = element.anchor.offset?.y ?? 0; - x = keyPosition.dx + offsetX; - y = keyPosition.dy + offsetY; - } - } - - // 실제 측정된 크기 또는 추정 크기 사용 - const width = - element.measuredSize?.width ?? element.estimatedSize?.width ?? 200; - const height = - element.measuredSize?.height ?? element.estimatedSize?.height ?? 150; - - xs.push(x); - ys.push(y); - widths.push(x + width); - heights.push(y + height); - }); - - if (xs.length === 0) return null; - - return { - minX: Math.min(...xs), - minY: Math.min(...ys), - maxX: Math.max(...widths), - maxY: Math.max(...heights), - }; - })(); - - const displayPositions = (() => { - if (!bounds || !currentPositions.length) { - return currentPositions; - } - - const topOffset = trackHeight + PADDING; - const offsetX = PADDING - bounds.minX; - const offsetY = topOffset - bounds.minY; - - return currentPositions.map((position) => ({ - ...position, - dx: position.dx + offsetX, - dy: position.dy + offsetY, - })); - })(); - - const displayStatPositions = (() => { - if (!bounds || !currentStatPositions.length) { - return currentStatPositions; - } - - const topOffset = trackHeight + PADDING; - const offsetX = PADDING - bounds.minX; - const offsetY = topOffset - bounds.minY; - - return currentStatPositions.map((position) => ({ - ...position, - dx: position.dx + offsetX, - dy: position.dy + offsetY, - })); - })(); - - const displayGraphPositions = (() => { - if (!bounds || !currentGraphPositions.length) { - return currentGraphPositions; - } - - const topOffset = trackHeight + PADDING; - const offsetX = PADDING - bounds.minX; - const offsetY = topOffset - bounds.minY; - - return currentGraphPositions.map((position) => ({ - ...position, - dx: position.dx + offsetX, - dy: position.dy + offsetY, - })); - })(); - - // 오버레이의 위치 오프셋 계산 - const positionOffset = (() => { - if (!bounds) return { x: 0, y: 0 }; - const topOffset = trackHeight + PADDING; - return { - x: PADDING - bounds.minX, - y: topOffset - bounds.minY, - }; - })(); - - // 키+통계+그래프+플러그인 모두 포함한 최상단 Y (bounds 기반) - const topMostY = bounds ? trackHeight + PADDING : 0; - - const webglTracks = currentKeys - .map((key, index) => { - const originalPosition = currentPositions[index] ?? FALLBACK_POSITION; - if (originalPosition.hidden) return null; - const position = displayPositions[index] ?? originalPosition; - // noteAutoYCorrection이 false면 원래 위치 사용, 아니면 topMostY로 보정 - const useAutoCorrection = position.noteAutoYCorrection !== false; - const trackStartY = useAutoCorrection ? topMostY : position.dy; - const keyWidth = position.width; - const desiredNoteWidth = - typeof position.noteWidth === 'number' && - Number.isFinite(position.noteWidth) - ? Math.max(1, Math.round(position.noteWidth)) - : keyWidth; - const noteOffsetX = (keyWidth - desiredNoteWidth) / 2; - - return { - trackKey: key, - trackIndex: position.zIndex ?? index, - position: { - ...position, - dx: position.dx + noteOffsetX, - dy: trackStartY, - }, - width: desiredNoteWidth, - height: trackHeight, - noteColor: position.noteColor, - noteOpacity: position.noteOpacity, - noteOpacityTop: position.noteOpacityTop ?? position.noteOpacity, - noteOpacityBottom: position.noteOpacityBottom ?? position.noteOpacity, - noteGlowEnabled: position.noteGlowEnabled ?? false, - noteGlowSize: position.noteGlowSize ?? 20, - noteGlowOpacity: position.noteGlowOpacity ?? 70, - noteGlowOpacityTop: - position.noteGlowOpacityTop ?? position.noteGlowOpacity ?? 70, - noteGlowOpacityBottom: - position.noteGlowOpacityBottom ?? position.noteGlowOpacity ?? 70, - noteGlowColor: position.noteGlowColor ?? position.noteColor, - flowSpeed: noteSettings?.speed ?? DEFAULT_NOTE_SETTINGS.speed, - borderRadius: position.noteBorderRadius ?? DEFAULT_NOTE_BORDER_RADIUS, - }; - }) - .filter(Boolean); + const { + bounds, + displayPositions, + displayStatPositions, + displayGraphPositions, + positionOffset, + webglTracks, + } = computeLayout({ + currentKeys, + currentPositions, + currentStatPositions, + currentGraphPositions, + trackHeight, + noteSettings, + selectedKeyType, + pluginElements, + }); useEffect(() => { updateTrackLayouts(webglTracks); From 6d47c42077fa6e574376e2404d836f385b71b320 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 19:37:51 +0900 Subject: [PATCH 19/56] =?UTF-8?q?feat:=20OBS=20WS=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=ED=86=A0=ED=81=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 start() 시 UUID v4 세션 토큰 생성, stop() 시 클리어 - WS hello 핸드셰이크에서 토큰 검증, 불일치 시 AUTH_FAILED 에러 전송 - OBS 페이지는 URL query param (?token=xxx)에서 토큰 추출하여 전송 - Settings UI에서 URL 복사 시 토큰 포함 - 클라이언트 AUTH_FAILED 수신 시 재연결 루프 중단 - bind 실패 시 stale 토큰 정리 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/models/obs.rs | 3 +++ src-tauri/src/services/obs_bridge.rs | 32 ++++++++++++++++++++++- src/renderer/components/main/Settings.tsx | 6 +++-- src/renderer/hooks/obs/useObsWebSocket.ts | 13 ++++++++- src/renderer/windows/obs/App.tsx | 2 ++ src/types/obs.ts | 5 +++- 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index 2e059046..4a588db7 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -69,6 +69,9 @@ pub struct ObsStatus { pub running: bool, pub port: u16, pub client_count: u32, + /// 세션 보안 토큰 (서버 시작 시 생성, WS hello에서 검증) + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, } /// JSON envelope 생성 헬퍼 diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 0d01c75e..4a72b884 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -7,6 +7,7 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use parking_lot::RwLock; use serde_json::Value; +use uuid::Uuid; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, oneshot}; @@ -29,6 +30,8 @@ pub struct ObsBridgeService { /// 빌드된 OBS 정적 파일 경로 (dist/renderer/obs) static_dir: RwLock>, server_version: String, + /// 세션 보안 토큰 (서버 시작 시 랜덤 생성) + session_token: RwLock, } impl ObsBridgeService { @@ -44,6 +47,7 @@ impl ObsBridgeService { server_handle: tokio::sync::Mutex::new(None), static_dir: RwLock::new(None), server_version: version.to_string(), + session_token: RwLock::new(String::new()), } } @@ -60,10 +64,15 @@ impl ObsBridgeService { } pub fn status(&self) -> ObsStatus { + let token = { + let t = self.session_token.read(); + if t.is_empty() { None } else { Some(t.clone()) } + }; ObsStatus { running: self.is_running(), port: *self.port.read(), client_count: self.client_count(), + token, } } @@ -115,11 +124,15 @@ impl ObsBridgeService { } } + // 세션 토큰 생성 (UUID v4, 하이픈 제거 = 32자 hex) + *self.session_token.write() = Uuid::new_v4().simple().to_string(); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = match TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { self.running.store(false, Ordering::Relaxed); + self.session_token.write().clear(); return Err(format!("포트 {port} 바인드 실패: {e}")); } }; @@ -155,6 +168,7 @@ impl ObsBridgeService { let _ = tx.send(()); } self.running.store(false, Ordering::Relaxed); + self.session_token.write().clear(); log::info!("[ObsBridge] 서버 종료"); } @@ -353,7 +367,7 @@ impl ObsBridgeService { }) .await; - let _hello = match hello_result { + let hello = match hello_result { Ok(Some(envelope)) => envelope, _ => { log::warn!("[ObsBridge] {addr}: hello 타임아웃 또는 연결 종료"); @@ -362,6 +376,22 @@ impl ObsBridgeService { } }; + // 보안 토큰 검증 + let expected_token = self.session_token.read().clone(); + if !expected_token.is_empty() { + let client_token = hello.payload.get("token") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if client_token != expected_token { + log::warn!("[ObsBridge] {addr}: 토큰 불일치, 연결 거부"); + let err_msg = make_envelope("error", 0, + serde_json::json!({"code": "AUTH_FAILED", "message": "Invalid token"})); + let _ = ws_tx.send(Message::Text(err_msg.to_string())).await; + self.client_count.fetch_sub(1, Ordering::Relaxed); + return; + } + } + // hello_ack 전송 let ack_payload = serde_json::to_value(HelloAckPayload { server_version: self.server_version.clone(), diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index a992e0c9..c1af1ec6 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -184,7 +184,8 @@ const Settings = ({ setObsStatus((prev) => prev.running === status.running && prev.port === status.port && - prev.clientCount === status.clientCount + prev.clientCount === status.clientCount && + prev.token === status.token ? prev : status, ); @@ -599,7 +600,8 @@ const Settings = ({ }; const handleObsCopyUrl = async (): Promise => { - const url = `http://localhost:${obsStatus.port}`; + const tokenParam = obsStatus.token ? `?token=${obsStatus.token}` : ''; + const url = `http://localhost:${obsStatus.port}${tokenParam}`; try { await navigator.clipboard.writeText(url); showAlert?.(t('settings.obsCopied')); diff --git a/src/renderer/hooks/obs/useObsWebSocket.ts b/src/renderer/hooks/obs/useObsWebSocket.ts index ad1e3945..7e0c468b 100644 --- a/src/renderer/hooks/obs/useObsWebSocket.ts +++ b/src/renderer/hooks/obs/useObsWebSocket.ts @@ -7,6 +7,7 @@ type ConnectionState = 'connecting' | 'connected' | 'disconnected'; interface UseObsWebSocketOptions { url: string; + token?: string; onSnapshot: (payload: BootstrapPayload) => void; onKeyEvent: (payload: KeyEventPayload) => void; onSettingsDiff: (diff: Record) => void; @@ -15,6 +16,7 @@ interface UseObsWebSocketOptions { function useObsWebSocket({ url, + token, onSnapshot, onKeyEvent, onSettingsDiff, @@ -78,6 +80,7 @@ function useObsWebSocket({ protocol: OBS_PROTOCOL_VERSION, appVersion: '', resumeFromSeq: 0, + token: token || undefined, }); }; @@ -111,6 +114,14 @@ function useObsWebSocket({ case 'ping': sendMessage('pong'); break; + case 'error': { + const payload = envelope.payload as Record; + if (payload?.code === 'AUTH_FAILED') { + console.warn('[ObsWS] 토큰 인증 실패, 재연결 중단'); + disposed = true; // 재연결 루프 방지 + } + break; + } } } catch { // 파싱 실패 무시 @@ -142,7 +153,7 @@ function useObsWebSocket({ wsRef.current = null; } }; - }, [url]); + }, [url, token]); const requestResync = () => { const ws = wsRef.current; diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx index 4487a8b4..1ae2398b 100644 --- a/src/renderer/windows/obs/App.tsx +++ b/src/renderer/windows/obs/App.tsx @@ -26,6 +26,7 @@ export default function App() { const params = new URLSearchParams(window.location.search); const host = params.get('host') || window.location.hostname || '127.0.0.1'; const port = params.get('port') || window.location.port || '34891'; + const token = params.get('token') || ''; const wsUrl = `ws://${host}:${port}`; // 상태 @@ -287,6 +288,7 @@ export default function App() { useObsWebSocket({ url: wsUrl, + token, onSnapshot, onKeyEvent, onSettingsDiff, diff --git a/src/types/obs.ts b/src/types/obs.ts index 6bd312d9..f76589f2 100644 --- a/src/types/obs.ts +++ b/src/types/obs.ts @@ -18,6 +18,7 @@ export interface HelloPayload { protocol: number; appVersion: string; resumeFromSeq: number; + token?: string; } // ── 서버 → 클라이언트 ── @@ -37,6 +38,7 @@ export interface ObsStatus { running: boolean; port: number; clientCount: number; + token?: string; } // ── WS 메시지 타입 문자열 ── @@ -51,4 +53,5 @@ export type ObsMessageType = | 'counter_update' | 'ping' | 'pong' - | 'resync_request'; + | 'resync_request' + | 'error'; From ec15b2ec1af3f9058eedbf1cddd89880bf29be1c Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 19:58:40 +0900 Subject: [PATCH 20/56] =?UTF-8?q?feat:=20OBS=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20CSS=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=A3=BC=EC=9E=85=20(#5?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSS 커맨드(toggle/reset/set_content/load) 변경 시 OBS 클라이언트에 settings_diff 전파 - CSS 핫리로드 워처에서도 OBS 브릿지 알림 추가 - OBS App.tsx에 CSS 주입 로직 (cssStateRef + applyCssToDOM) - notify_obs_settings_diff: 전체 스냅샷 대신 settings_diff만 전송하여 키 상태/KPS 리셋 방지 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/editor/css.rs | 15 ++++++++ src-tauri/src/services/css_watcher.rs | 13 ++++++- src-tauri/src/state/app_state.rs | 14 +++++++ src/renderer/windows/obs/App.tsx | 54 ++++++++++++++++++++++++++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/editor/css.rs b/src-tauri/src/commands/editor/css.rs index 9db06760..2b90ad74 100644 --- a/src-tauri/src/commands/editor/css.rs +++ b/src-tauri/src/commands/editor/css.rs @@ -10,6 +10,16 @@ use crate::{ state::AppState, }; +/// OBS 브릿지에 CSS 설정 변경을 settings_diff로 전달 (전체 스냅샷 브로드캐스트 방지) +fn notify_obs_css(state: &AppState) { + let snap = state.store.snapshot(); + let diff = serde_json::json!({ + "useCustomCSS": snap.use_custom_css, + "customCSS": snap.custom_css, + }); + state.notify_obs_settings_diff(diff); +} + #[derive(Serialize)] pub struct CssToggleResponse { pub enabled: bool, @@ -114,6 +124,7 @@ pub fn css_toggle( state.unwatch_global_css(); } + notify_obs_css(&state); Ok(CssToggleResponse { enabled }) } @@ -129,6 +140,8 @@ pub fn css_reset(state: State<'_, AppState>, app: AppHandle) -> CmdResult<()> { app.emit("css:use", &CssToggleResponse { enabled: false })?; app.emit("css:content", &CustomCss::default())?; + + notify_obs_css(&state); Ok(()) } @@ -147,6 +160,7 @@ pub fn css_set_content( app.emit("css:content", ¤t)?; + notify_obs_css(&state); Ok(CssSetContentResponse { success: true, error: None, @@ -189,6 +203,7 @@ pub fn css_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult Result<() app.emit("css:content", &css).map_err(|e| e.to_string())?; + // OBS 브릿지에 CSS 변경 알림 (settings_diff 방식 — 전체 스냅샷은 키 상태 리셋 유발) + let app_state = app.state::(); + let snap = store.snapshot(); + let diff = serde_json::json!({ + "useCustomCSS": snap.use_custom_css, + "customCSS": snap.custom_css, + }); + app_state.notify_obs_settings_diff(diff); + log::info!("[CssWatcher] Reloaded global CSS from: {}", path); Ok(()) } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 1afad188..2adc17e7 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -372,6 +372,20 @@ impl AppState { } } + /// OBS 브릿지에 설정 diff 전송 + 캐시 스냅샷 갱신 (전체 스냅샷 broadcast 없음) + /// CSS 등 개별 설정 변경이 OBS 런타임 상태(키 시그널, KPS)를 리셋하지 않도록 사용 + pub fn notify_obs_settings_diff(&self, diff: serde_json::Value) { + if !self.obs_bridge.is_running() { + return; + } + self.obs_bridge.broadcast_settings_diff(diff); + // 캐시 스냅샷 갱신 (새 클라이언트 접속 시 최신 설정 제공) + 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() { diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx index 1ae2398b..ebc041bb 100644 --- a/src/renderer/windows/obs/App.tsx +++ b/src/renderer/windows/obs/App.tsx @@ -20,6 +20,9 @@ import type { KeyMappings, KeyPosition, KeyPositions } from '@src/types/key/keys import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { NoteSettings } from '@src/types/settings/noteSettings'; +import type { CustomCss } from '@src/types/plugin/css'; + +const OBS_CUSTOM_CSS_ID = 'dmn-obs-custom-css'; export default function App() { // WS 연결 URL: HTTP로 서빙된 경우 같은 호스트:포트, 아닌 경우 query param fallback @@ -43,6 +46,31 @@ export default function App() { const [keyCounterEnabled, setKeyCounterEnabled] = useState(false); const [initialized, setInitialized] = useState(false); + // 커스텀 CSS 주입 + const cssStateRef = useRef({ enabled: false, content: '' }); + const cssStyleElRef = useRef(null); + + const applyCssToDOM = useCallback(() => { + let el = cssStyleElRef.current; + if (!el) { + el = document.getElementById(OBS_CUSTOM_CSS_ID) as HTMLStyleElement | null; + if (!el) { + el = document.createElement('style'); + el.id = OBS_CUSTOM_CSS_ID; + document.head.appendChild(el); + } + cssStyleElRef.current = el; + } + const { enabled, content } = cssStateRef.current; + if (enabled && content) { + el.textContent = content; + el.disabled = false; + } else { + el.textContent = ''; + el.disabled = true; + } + }, []); + // 노트 시스템 const { notesRef, @@ -153,6 +181,13 @@ export default function App() { ); setBackgroundColor(settings.backgroundColor ?? 'transparent'); setKeyCounterEnabled(settings.keyCounterEnabled ?? false); + + // 커스텀 CSS 적용 + cssStateRef.current = { + enabled: settings.useCustomCSS ?? false, + content: (settings.customCSS as CustomCss | undefined)?.content ?? '', + }; + applyCssToDOM(); } // 카운터 초기화 @@ -197,7 +232,7 @@ export default function App() { resetAllKeySignals(); setInitialized(true); - }, []); + }, [applyCssToDOM]); // 키 딜레이 적용 신호 업데이트 const updateKeySignalWithDelay = useCallback( @@ -270,7 +305,22 @@ export default function App() { setBackgroundColor(diff.backgroundColor as string); if ('keyCounterEnabled' in diff) setKeyCounterEnabled(diff.keyCounterEnabled as boolean); - }, []); + + // 커스텀 CSS 변경 + let cssChanged = false; + if ('useCustomCSS' in diff) { + cssStateRef.current.enabled = diff.useCustomCSS as boolean; + cssChanged = true; + } + if ('customCSS' in diff) { + const css = diff.customCSS as Partial | undefined; + if (css?.content !== undefined) { + cssStateRef.current.content = css.content; + cssChanged = true; + } + } + if (cssChanged) applyCssToDOM(); + }, [applyCssToDOM]); // 카운터 업데이트 const onCounterUpdate = useCallback((data: Record) => { From 71879fd7dbdcfa3b3589b05164fa2a9da58029c8 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 20:09:41 +0900 Subject: [PATCH 21/56] =?UTF-8?q?feat:=20OBS=20=EB=AF=B8=EB=94=94=EC=96=B4?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20HTTP=20=EC=84=9C=EB=B9=99=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /media/?token=xxx 라우트로 로컬 미디어 파일 서빙 - 세션 토큰 검증으로 무인증 파일 유출 방지 - 허용 확장자 화이트리스트 (이미지/영상/폰트만) - Access-Control-Allow-Origin: * 제거 (same-origin만) - resolveImageSource: Tauri API 실패 시 OBS HTTP fallback - guess_mime 확장 (gif/webp/mp4/webm/ttf/otf) Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/services/obs_bridge.rs | 128 ++++++++++++++++++++++++- src/renderer/utils/core/imageSource.ts | 28 +++++- 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 4a72b884..d74a66bc 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -261,6 +261,12 @@ impl ObsBridgeService { .and_then(|line| line.split_whitespace().nth(1)) .unwrap_or("/"); + // /media/?token=xxx — 사용자 로컬 미디어 파일 서빙 + if let Some(rest) = path.strip_prefix("/media/") { + self.handle_media_request(stream, rest).await; + return; + } + // 경로 정규화: "/" → "/index.html", 디렉토리 탐색 방지 let normalized = if path == "/" || path.is_empty() { "index.html" @@ -493,25 +499,145 @@ impl ObsBridgeService { self.client_count() ); } + + /// /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; + } + }, + 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; + } + }; + + // 허용 확장자 화이트리스트 (미디어/폰트 파일만) + let ext = 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(&file_path).await { + Ok(content) => { + let mime = guess_mime(&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("") { + 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 { diff --git a/src/renderer/utils/core/imageSource.ts b/src/renderer/utils/core/imageSource.ts index 0d354a0c..748a3336 100644 --- a/src/renderer/utils/core/imageSource.ts +++ b/src/renderer/utils/core/imageSource.ts @@ -15,6 +15,28 @@ function isLikelyLocalPath(value: string): boolean { return false; } +/** base64url 인코딩 (패딩 없음) */ +function toBase64Url(str: string): string { + const bytes = new TextEncoder().encode(str); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** OBS 환경에서 미디어 파일 URL 생성 (토큰 포함) */ +function resolveForObs(path: string): string { + const encoded = toBase64Url(path); + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + const tokenQuery = token ? `?token=${token}` : ''; + return `${window.location.origin}/media/${encoded}${tokenQuery}`; +} + export function resolveImageSource(value?: string | null): string | null { const raw = typeof value === 'string' ? value.trim() : ''; if (!raw) return null; @@ -28,11 +50,15 @@ export function resolveImageSource(value?: string | null): string | null { return cached; } + // Tauri API 시도 → 실패 시 OBS HTTP fallback try { const converted = convertFileSrc(raw); imageSrcCache.set(raw, converted); return converted; } catch { - return raw; + // OBS 환경 (Tauri API 없음): HTTP /media/ 경로로 서빙 + const url = resolveForObs(raw); + imageSrcCache.set(raw, url); + return url; } } From 4522ad097927ed6e371c2cd361a9ca71315a8a3a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 20:17:57 +0900 Subject: [PATCH 22/56] =?UTF-8?q?feat:=20OBS=20dev=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?Vite=20dev=20server=20=ED=94=84=EB=A1=9D=EC=8B=9C=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obs_bridge에 dev_url 필드 및 set_dev_url() 추가 - HTTP 요청 시 static_dir 없으면 dev_url로 302 리다이렉트 - obs_start에서 cfg!(debug_assertions) 시 자동 dev_url 설정 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 5 ++++ src-tauri/src/services/obs_bridge.rs | 34 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 8071339c..aecffdc2 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -39,6 +39,11 @@ pub async fn obs_start( if let Some(dir) = resolve_obs_static_dir(&app) { log::info!("[ObsBridge] static_dir: {}", dir.display()); state.obs_bridge.set_static_dir(dir); + } else if cfg!(debug_assertions) { + // dev 모드: Vite dev server로 리다이렉트 + let dev_url = "http://localhost:3400".to_string(); + log::info!("[ObsBridge] dev 모드: Vite dev server로 리다이렉트 ({dev_url})"); + state.obs_bridge.set_dev_url(dev_url); } else { log::warn!("[ObsBridge] OBS 정적 파일 디렉토리를 찾을 수 없음 (HTTP 서빙 비활성)"); } diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index d74a66bc..1787f5d5 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -29,6 +29,8 @@ pub struct ObsBridgeService { server_handle: tokio::sync::Mutex>>, /// 빌드된 OBS 정적 파일 경로 (dist/renderer/obs) static_dir: RwLock>, + /// dev 모드 Vite dev server URL (예: "http://localhost:3400") + dev_url: RwLock>, server_version: String, /// 세션 보안 토큰 (서버 시작 시 랜덤 생성) session_token: RwLock, @@ -46,6 +48,7 @@ impl ObsBridgeService { shutdown_tx: RwLock::new(None), server_handle: tokio::sync::Mutex::new(None), static_dir: RwLock::new(None), + dev_url: RwLock::new(None), server_version: version.to_string(), session_token: RwLock::new(String::new()), } @@ -55,6 +58,10 @@ impl ObsBridgeService { *self.static_dir.write() = Some(dir); } + pub fn set_dev_url(&self, url: String) { + *self.dev_url.write() = Some(url); + } + pub fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } @@ -240,9 +247,36 @@ impl ObsBridgeService { // RwLockReadGuard를 await 전에 해제하기 위해 즉시 clone let static_dir = self.static_dir.read().clone(); + let dev_url = self.dev_url.read().clone(); let static_dir = match static_dir.as_ref() { Some(dir) => dir.clone(), None => { + // dev 모드: Vite dev server로 리다이렉트 + if let Some(dev_base) = &dev_url { + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + // /media/ 경로는 이미 위에서 처리됨 → 정적 파일만 리다이렉트 + let obs_path = if path == "/" || path.is_empty() { + "/obs/index.html" + } else { + path + }; + // OBS 정적 파일 경로가 /obs/로 시작하지 않으면 추가 + let redirect_path = if obs_path.starts_with("/obs/") { + obs_path.to_string() + } else { + format!("/obs{obs_path}") + }; + let location = format!("{dev_base}{redirect_path}"); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } let body = "OBS bridge: static directory not configured"; let response = format!( "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", From 51c59e876e839666ea26455691a65b07858faba4 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 20:18:28 +0900 Subject: [PATCH 23/56] =?UTF-8?q?docs:=20OBS=20v3=20P2=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EB=A7=B5=20=EC=A0=84=EC=B2=B4=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index f4fd42c5..b0dc85d3 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -465,13 +465,13 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 | # | 작업 | 설명 | 상태 | |---|------|------|------| -| 5 | **커스텀 CSS** | OBS 페이지에 사용자 CSS 주입, HTTP 서빙 경로로 제공 | ❌ | -| 6 | **배경 이미지/영상** | 사용자 미디어 파일 HTTP 서빙 + 경로 해석 | ❌ | +| 5 | **커스텀 CSS** | OBS 페이지에 사용자 CSS 주입, HTTP 서빙 경로로 제공 | ✅ | +| 6 | **배경 이미지/영상** | 사용자 미디어 파일 HTTP 서빙 + 경로 해석 | ✅ | | 7 | **keyDisplayDelayMs** | OBS에서 키 표시 지연 반영 (메인 오버레이와 동일 패턴) | ✅ | | 8 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 반영 | ✅ | -| 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ❌ | -| 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ❌ | -| 11 | **DataSource 호환성 레이어** | Tauri API / WebSocket 통합 인터페이스 (DataSource adapter) 도입, overlay/obs 공용 레이아웃 훅 추출로 중복 제거 | ❌ | +| 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ✅ | +| 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ✅ | +| 11 | **DataSource 호환성 레이어** | Tauri API / WebSocket 통합 인터페이스 (DataSource adapter) 도입, overlay/obs 공용 레이아웃 훅 추출로 중복 제거 | ✅ | #### v3+ 이후 (P3) From 9b65c61619c87a4fdb48bae0e26f675727e08e25 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 20:33:49 +0900 Subject: [PATCH 24/56] =?UTF-8?q?fix:=20OBS=20SPA=20fallback=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=EB=A6=AC=EC=86=8C=EC=8A=A4=EC=97=90=20ind?= =?UTF-8?q?ex.html=20=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확장자가 있는 파일 요청(.js, .css 등)이 실패할 때 index.html을 text/html로 반환하여 MIME type 불일치 에러 발생. 확장자 있는 요청은 SPA fallback 대신 404 반환하도록 변경. 또한 obs-mode-design.md의 v2 기준 섹션들을 v3 구현 현황에 맞게 일괄 업데이트 (7.2 모드 전환, 8.1 포트 보안, 8.2 기능 제약, 4.2 struct 필드, 4.3 버전, 5.2 레이아웃 공유, 11.2 #11 상태). Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 23 +++++++++++++---------- src-tauri/src/services/obs_bridge.rs | 12 +++++++++++- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index b0dc85d3..30b5eb24 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -200,6 +200,9 @@ pub struct ObsBridgeService { shutdown_tx: RwLock>>, server_version: String, static_dir: RwLock>, // v2: HTTP 정적 서빙용 + server_handle: tokio::sync::Mutex>>, // v3: stop→start 경쟁 방지 + dev_url: RwLock>, // v3: dev 모드 Vite dev server URL + session_token: RwLock, // v3: UUID v4 세션 토큰 } ``` @@ -217,7 +220,7 @@ pub struct ObsBridgeService { ### 4.3 크레이트 의존성 ✅ ```toml -tokio-tungstenite = "0.26" +tokio-tungstenite = "0.24" futures-util = "0.3" ``` > tokio는 기존에 이미 포함됨 @@ -269,7 +272,7 @@ src/renderer/ |------|:---:|:---:|:---:| | 키 UI 렌더링 | ✅ | | | | 노트 효과 (WebGL) | ✅ | | | -| bounds/position 계산 | | ✅ | ✅ (중복) | +| bounds/position 계산 | | ✅ | ✅ (공유: computeLayout) | | 통계/그래프 표시 | ✅ | | | | 플러그인 엘리먼트 | ✅ (props로 제어) | | | | 창 드래그/리사이즈 | | ✅ | | @@ -338,13 +341,13 @@ v3 추가: - ✅ 안내 문구 (OBS 설정 방법 가이드 + 오버레이 숨김 경고) - 라디오 버튼 모드 전환은 자동 숨김/복원으로 대체 -### 7.2 모드 전환 동작 ⚠️ 부분 구현 +### 7.2 모드 전환 동작 ✅ 1. ✅ OBS 모드 ON → WS 서버 bind → URL 표시 2. ✅ OBS 클라이언트 접속 → 상태 점등 3. ✅ 연결 끊김 → 서버 유지, 상태 표시 갱신 -4. ❌ 오버레이 창 자동 숨김/복구 미구현 -5. ❌ OBS 설정이 백엔드 설정 모델과 분리 (런타임 토글만, 재시작 시 초기화) +4. ✅ 오버레이 창 자동 숨김/복구 (v3: obs_hide_overlay / obs_restore_overlay) +5. ✅ 설정 영속화 (v3: obs_port, obs_mode_enabled store 저장, 재시작 시 복원) ### 7.3 포트 충돌 처리 ✅ @@ -362,7 +365,7 @@ v3 추가: | OBS CEF Chromium 버전 차이 | 중 | WebGL 1.0 기준 유지 | ⚠️ 미검증 | | 키 이벤트 지연 (WS 전송) | 낮 | localhost <1ms, seq+ts로 모니터링 | ✅ | | 상태 일관성 (프리셋 로드 시) | 중 | snapshot 재전송으로 대응 | ✅ | -| 포트 보안 | 중 | 랜덤 세션 토큰 검토 필요 | ❌ 미구현 | +| 포트 보안 | 중 | UUID v4 세션 토큰 + WS hello/HTTP 검증 | ✅ (v3) | | tokio 런타임 추가 | 낮 | 기존 tokio 재사용 | ✅ | ### 8.2 기능 제약 (v2 기준) @@ -370,12 +373,12 @@ v3 추가: | 기능 | 지원 여부 | |------|-----------| | 키 UI + 노트 효과 | ✅ 지원 | -| 통계/그래프 표시 | ✅ 지원 (렌더링만, KPS 값은 항상 0) | +| 통계/그래프 표시 | ✅ 지원 (v3: KPS 로컬 1초 슬라이딩 윈도우 계산) | | 키 카운터 | ✅ 지원 | | HTTP 정적 서빙 | ✅ 지원 | | 레이아웃 동기화 | ✅ 지원 (snapshot 재전송) | -| 커스텀 CSS | × 미지원 | -| 배경 미디어 서빙 | × 미지원 | +| 커스텀 CSS | ✅ 지원 (v3: settings_diff 경유 실시간 주입) | +| 배경 미디어 서빙 | ✅ 지원 (v3: /media/ 엔드포인트 + 토큰 검증) | | 커스텀 JS (플러그인) | × 미지원 (Tauri API 의존) | | 플러그인 엘리먼트 | × 미지원 (bridge API 의존) | @@ -471,7 +474,7 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 | 8 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 반영 | ✅ | | 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ✅ | | 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ✅ | -| 11 | **DataSource 호환성 레이어** | Tauri API / WebSocket 통합 인터페이스 (DataSource adapter) 도입, overlay/obs 공용 레이아웃 훅 추출로 중복 제거 | ✅ | +| 11 | **DataSource 호환성 레이어** | Tauri API / WebSocket 통합 인터페이스 (DataSource adapter) 도입, overlay/obs 공용 레이아웃 훅 추출로 중복 제거 | ⚠️ 레이아웃 추출만 완료, adapter 미구현 | #### v3+ 이후 (P3) diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 1787f5d5..bbddc88d 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -347,7 +347,17 @@ impl ObsBridgeService { let _ = stream.write_all(&content).await; } Err(_) => { - // SPA fallback: 존재하지 않는 경로 → index.html + // 확장자가 있는 정적 리소스 요청은 SPA fallback 하지 않음 (404) + let has_extension = normalized + .rsplit('/') + .next() + .map_or(false, |filename| filename.contains('.')); + if has_extension { + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await; + return; + } + + // SPA fallback: 확장자 없는 경로 → index.html let index_path = static_dir.join("index.html"); match tokio::fs::read(&index_path).await { Ok(content) => { From 79cfe15739d9d01888914b3fcc70a25061376f6a Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 20:37:27 +0900 Subject: [PATCH 25/56] =?UTF-8?q?fix:=20dev=20=EB=AA=A8=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Vite=20dev=20server=EB=A5=BC=20static=5Fdir?= =?UTF-8?q?=EB=B3=B4=EB=8B=A4=20=EC=9A=B0=EC=84=A0=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stale 빌드 디렉토리가 남아있을 때 resolve_obs_static_dir()이 오래된 경로를 찾아 dev_url 리다이렉트 분기를 타지 않는 문제. cfg!(debug_assertions)일 때 항상 Vite dev server를 우선 사용. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index aecffdc2..f655773a 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -36,14 +36,14 @@ pub async fn obs_start( let port = port.unwrap_or(state.store.with_state(|s| s.obs_port)); // OBS 정적 파일 경로 설정 - if let Some(dir) = resolve_obs_static_dir(&app) { - log::info!("[ObsBridge] static_dir: {}", dir.display()); - state.obs_bridge.set_static_dir(dir); - } else if cfg!(debug_assertions) { - // dev 모드: Vite dev server로 리다이렉트 + // dev 모드에서는 Vite dev server 우선 사용 (stale 빌드 디렉토리 회피) + if cfg!(debug_assertions) { let dev_url = "http://localhost:3400".to_string(); log::info!("[ObsBridge] dev 모드: Vite dev server로 리다이렉트 ({dev_url})"); state.obs_bridge.set_dev_url(dev_url); + } else if let Some(dir) = resolve_obs_static_dir(&app) { + log::info!("[ObsBridge] static_dir: {}", dir.display()); + state.obs_bridge.set_static_dir(dir); } else { log::warn!("[ObsBridge] OBS 정적 파일 디렉토리를 찾을 수 없음 (HTTP 서빙 비활성)"); } From e0218bd76371a8d9de7f36f6136d23b2a6c5054e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 21:07:22 +0900 Subject: [PATCH 26/56] =?UTF-8?q?fix:=20OBS=20HTTP=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?static=5Fdir=EC=9D=84=20renderer=20=EB=A3=A8=ED=8A=B8=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20asset=20404=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빌드된 obs/index.html이 ../assets/obs-xxx.js를 참조하는데, static_dir이 obs/ 하위를 가리켜서 assets/ 접근 불가 → 빈 화면 발생. static_dir을 dist/renderer/ (부모 디렉토리)로 변경하고, "/" 요청을 obs/index.html로 매핑하도록 수정. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 16 +++---- src-tauri/src/services/obs_bridge.rs | 62 ++++++++++++++++++---------- src-tauri/src/state/app_state.rs | 5 ++- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index f655773a..91eebdb1 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -4,22 +4,22 @@ use tauri::{AppHandle, Manager, State}; use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; -/// OBS 빌드 정적 파일 경로 탐색 +/// OBS 빌드 정적 파일 루트 경로 탐색 (dist/renderer/) +/// obs/index.html이 ../assets/ 를 참조하므로, obs/ 상위인 renderer/ 루트를 반환 pub fn resolve_obs_static_dir(app: &AppHandle) -> Option { - // 1. Tauri resource_dir/obs/ (프로덕션 번들) + // 1. Tauri resource_dir (프로덕션 번들) if let Ok(res) = app.path().resource_dir() { - let obs = res.join("obs"); - if obs.join("index.html").exists() { - return Some(obs); + if res.join("obs/index.html").exists() { + return Some(res); } } // 2. 실행 파일 기준 탐색 (dev mode: src-tauri/target/debug/) if let Ok(exe) = std::env::current_exe() { if let Some(exe_dir) = exe.parent() { - let dev = exe_dir.join("../../../dist/renderer/obs"); - if dev.join("index.html").exists() { - return dev.canonicalize().ok(); + let renderer_root = exe_dir.join("../../../dist/renderer"); + if renderer_root.join("obs/index.html").exists() { + return renderer_root.canonicalize().ok(); } } } diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index bbddc88d..82460410 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -7,11 +7,11 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use parking_lot::RwLock; use serde_json::Value; -use uuid::Uuid; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, oneshot}; use tokio_tungstenite::tungstenite::Message; +use uuid::Uuid; use crate::models::obs::{ make_envelope, HelloAckPayload, KeyEventPayload, KeyState, ObsBroadcast, ObsEnvelope, ObsStatus, @@ -73,7 +73,11 @@ impl ObsBridgeService { pub fn status(&self) -> ObsStatus { let token = { let t = self.session_token.read(); - if t.is_empty() { None } else { Some(t.clone()) } + if t.is_empty() { + None + } else { + Some(t.clone()) + } }; ObsStatus { running: self.is_running(), @@ -145,10 +149,7 @@ impl ObsBridgeService { }; // 실제 바인딩된 포트 저장 (port=0인 경우 OS 할당 포트 반영) - let actual_port = listener - .local_addr() - .map(|a| a.port()) - .unwrap_or(port); + let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); *self.shutdown_tx.write() = Some(shutdown_tx); @@ -301,9 +302,10 @@ impl ObsBridgeService { return; } - // 경로 정규화: "/" → "/index.html", 디렉토리 탐색 방지 + // 경로 정규화: "/" → "obs/index.html", 디렉토리 탐색 방지 + // static_dir은 dist/renderer/ (obs/index.html이 ../assets/ 참조하므로) let normalized = if path == "/" || path.is_empty() { - "index.html" + "obs/index.html" } else { path.trim_start_matches('/') }; @@ -357,8 +359,8 @@ impl ObsBridgeService { return; } - // SPA fallback: 확장자 없는 경로 → index.html - let index_path = static_dir.join("index.html"); + // SPA fallback: 확장자 없는 경로 → obs/index.html + let index_path = static_dir.join("obs/index.html"); match tokio::fs::read(&index_path).await { Ok(content) => { let response = format!( @@ -429,13 +431,18 @@ impl ObsBridgeService { // 보안 토큰 검증 let expected_token = self.session_token.read().clone(); if !expected_token.is_empty() { - let client_token = hello.payload.get("token") + let client_token = hello + .payload + .get("token") .and_then(|v| v.as_str()) .unwrap_or(""); if client_token != expected_token { log::warn!("[ObsBridge] {addr}: 토큰 불일치, 연결 거부"); - let err_msg = make_envelope("error", 0, - serde_json::json!({"code": "AUTH_FAILED", "message": "Invalid token"})); + let err_msg = make_envelope( + "error", + 0, + serde_json::json!({"code": "AUTH_FAILED", "message": "Invalid token"}), + ); let _ = ws_tx.send(Message::Text(err_msg.to_string())).await; self.client_count.fetch_sub(1, Ordering::Relaxed); return; @@ -602,9 +609,19 @@ impl ObsBridgeService { .to_ascii_lowercase(); if !matches!( ext.as_str(), - "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" - | "mp4" | "webm" | "ogg" - | "woff" | "woff2" | "ttf" | "otf" + "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "svg" + | "mp4" + | "webm" + | "ogg" + | "woff" + | "woff2" + | "ttf" + | "otf" ) { let _ = stream .write_all( @@ -638,7 +655,13 @@ impl ObsBridgeService { /// 파일 확장자로 MIME 타입 추정 fn guess_mime(path: &str) -> &'static str { - match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_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", @@ -667,10 +690,7 @@ fn percent_decode(input: &str) -> String { 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, - ) { + if let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16) { result.push(byte); i += 3; continue; diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 2adc17e7..17a84c13 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -315,7 +315,10 @@ impl AppState { log::info!("[ObsBridge] auto-start 성공 (port={})", port); } Err(e) => { - log::error!("[ObsBridge] auto-start 실패: {} — obs_mode_enabled를 false로 복구", e); + log::error!( + "[ObsBridge] auto-start 실패: {} — obs_mode_enabled를 false로 복구", + e + ); let _ = store.update(|state| { state.obs_mode_enabled = false; }); From 76ab7923819a55a094fbd56c1bd32ca632f5c828 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 21:08:57 +0900 Subject: [PATCH 27/56] =?UTF-8?q?docs:=20OverlayHost=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=EC=84=B1=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94=20(=C2=A712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A/B/C 접근 방식 비교 및 B 방식(OverlayHost adapter) 확정 - OverlayHost 인터페이스 정의, 구현 계획, 범위 제한 명시 - #11 상태를 "설계 확정 / 구현 대기"로 업데이트 Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 125 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index 30b5eb24..8284d27a 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -474,7 +474,7 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 | 8 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 반영 | ✅ | | 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ✅ | | 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ✅ | -| 11 | **DataSource 호환성 레이어** | Tauri API / WebSocket 통합 인터페이스 (DataSource adapter) 도입, overlay/obs 공용 레이아웃 훅 추출로 중복 제거 | ⚠️ 레이아웃 추출만 완료, adapter 미구현 | +| 11 | **DataSource 호환성 레이어** | OverlayHost adapter로 Tauri API / WebSocket 통합 인터페이스 도입 (§12 참조) | ⚠️ computeLayout 추출 완료, adapter 설계 확정 / 구현 대기 | #### v3+ 이후 (P3) @@ -492,3 +492,126 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 | layout_diff | 서버 API만 구현 | 모드/키/위치/탭 변경 시 자동 broadcast | | cached_snapshot | 초기 + 프리셋 로드 시만 갱신 | 모든 diff 시 증분 갱신 | | 메인 UI URL 복사 | `ws://localhost:PORT` | `http://localhost:PORT` | + +--- + +## 12. OverlayHost 호환성 레이어 설계 + +> 상태: **설계 확정** / 구현 대기 +> P2 #11 상세 설계 + +### 12.1 배경 및 목표 + +현재 overlay/App.tsx와 obs/App.tsx는 동일한 UI를 렌더링하면서 데이터 수신 방식만 다름: +- overlay: Tauri API (`invoke`, `listen`, window API) — 19+ 이벤트 구독, 12+ invoke, 8+ window API +- obs: WebSocket (`useObsWebSocket`) — snapshot/diff/key_event 수신 + +이 구조의 문제: +1. **렌더링 로직 중복** — 키 상태 관리, KPS 계산, 딜레이 처리 등이 양쪽에 복제됨 +2. **유지보수 부담** — 기능 추가 시 overlay/obs 양쪽 모두 수정 필요 +3. **불일치 위험** — 한쪽만 수정하고 다른 쪽을 빠뜨릴 가능성 + +### 12.2 검토한 접근 방식 + +#### A. 현재 구조 유지 (분리형) + +``` +overlay/App.tsx ──→ Tauri API ──→ OverlayScene +obs/App.tsx ──→ WebSocket ──→ OverlayScene + (computeLayout 공유) +``` + +- 장점: 단순, 각 환경에 최적화 가능 +- 단점: 렌더링 로직 중복, 기능 추가마다 양쪽 수정 +- 적합: 기능이 안정화되어 변경이 적을 때 + +#### B. OverlayRuntime 공유 훅 + +``` +overlay/App.tsx ──→ TauriDataSource ──→ useOverlayRuntime() ──→ OverlayScene +obs/App.tsx ──→ WsDataSource ──→ useOverlayRuntime() ──→ OverlayScene +``` + +- 장점: 렌더링 로직 단일화, 데이터 소스만 교체 +- 단점: 추상화 인터페이스 설계/유지 비용 + +#### C. Tauri API shim (폴리필) + +``` +overlay/App.tsx ──→ window.__TAURI__ (real) ──→ OverlayScene +obs/App.tsx ──→ window.__TAURI__ (shim) ──→ OverlayScene + (WS가 Tauri API를 흉내) +``` + +- 장점: overlay 코드 변경 최소화 +- 단점: Tauri API의 모든 시그니처를 정확히 흉내내야 함, edge case 많음 + +### 12.3 최종 결정: B 방식 (OverlayHost adapter) + +**"B with C's philosophy"** — 새 인터페이스를 정의하되, overlay가 실제 사용하는 기능만 포함. + +B와 C는 개념적으로 수렴하지만, B 형태(새 인터페이스)가 구현이 깔끔함: +- Tauri API 시그니처를 완벽히 흉내내는 것보다, 필요한 기능만 정의하는 것이 안전 +- 마이그레이션은 한 번이지만 이후 유지보수가 단순화됨 + +### 12.4 OverlayHost 인터페이스 + +```typescript +// src/renderer/hosts/types.ts + +interface OverlayHostCallbacks { + onSnapshot: (payload: BootstrapPayload) => void; + onKeyEvent: (payload: KeyEventPayload) => void; + onSettingsDiff: (diff: Record) => void; + onCounterUpdate: (data: Record) => void; +} + +interface OverlayHost { + /** 호스트 타입 식별 */ + type: 'tauri' | 'websocket'; + + /** 데이터 구독 시작 (콜백 등록) */ + subscribe(callbacks: OverlayHostCallbacks): () => void; // unsubscribe 반환 + + /** 설정 변경 요청 (overlay에서 설정 UI 조작 시) */ + invoke?(command: string, args?: unknown): Promise; + + /** 창 제어 (overlay 전용, OBS에서는 no-op) */ + window?: { + startDragging(): void; + setIgnoreCursorEvents(ignore: boolean): void; + // ... overlay에서 사용하는 window API만 포함 + }; +} +``` + +### 12.5 구현 계획 + +``` +src/renderer/ +├── hosts/ +│ ├── types.ts ← OverlayHost 인터페이스 정의 +│ ├── TauriHost.ts ← Tauri API 기반 구현 +│ └── WebSocketHost.ts ← WebSocket 기반 구현 +├── hooks/shared/ +│ └── useOverlayRuntime.ts ← 공용 렌더링 로직 (키 상태, KPS, 딜레이 등) +└── windows/ + ├── overlay/App.tsx ← TauriHost + useOverlayRuntime + OverlayScene + └── obs/App.tsx ← WebSocketHost + useOverlayRuntime + OverlayScene +``` + +단계: +1. `OverlayHost` 인터페이스 + `WebSocketHost` 구현 (기존 useObsWebSocket 리팩토링) +2. `useOverlayRuntime` 훅 추출 (obs/App.tsx에서 렌더링 로직 분리) +3. `TauriHost` 구현 (overlay/App.tsx에서 Tauri API 호출 래핑) +4. overlay/App.tsx를 `TauriHost + useOverlayRuntime` 으로 마이그레이션 +5. 검증: 양쪽 동작 확인 후 기존 중복 코드 제거 + +### 12.6 범위 제한 + +현재 P2 범위에서는 다음만 포함: +- **포함**: 키 이벤트, 설정 동기화, 카운터, KPS, 레이아웃, CSS +- **제외**: 플러그인 엘리먼트 (P3), 커스텀 JS (P3), 창 드래그/리사이즈 (overlay 전용) + +overlay 전용 기능(창 제어, 컨텍스트 메뉴 등)은 `OverlayHost.window?`로 분리하여 +OBS 환경에서는 자연스럽게 비활성화됨. From 791bc3cfbbee05bf20bfcd8da1d99344612875e8 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 21:16:55 +0900 Subject: [PATCH 28/56] =?UTF-8?q?refactor:=20useOverlayRuntime=20=ED=9B=85?= =?UTF-8?q?=20=EC=B6=94=EC=B6=9C=20=EB=B0=8F=20obs/App.tsx=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit obs/App.tsx의 렌더링 로직(키 상태, KPS, 딜레이, CSS, 레이아웃)을 useOverlayRuntime 훅으로 추출. obs/App.tsx는 405줄에서 34줄로 축소. 향후 TauriHost 마이그레이션 시 overlay/App.tsx에서도 동일 훅 사용 가능. Co-Authored-By: Claude Opus 4.6 --- .../hooks/shared/useOverlayRuntime.ts | 416 ++++++++++++++++++ src/renderer/windows/obs/App.tsx | 381 +--------------- 2 files changed, 420 insertions(+), 377 deletions(-) create mode 100644 src/renderer/hooks/shared/useOverlayRuntime.ts diff --git a/src/renderer/hooks/shared/useOverlayRuntime.ts b/src/renderer/hooks/shared/useOverlayRuntime.ts new file mode 100644 index 00000000..6293009b --- /dev/null +++ b/src/renderer/hooks/shared/useOverlayRuntime.ts @@ -0,0 +1,416 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNoteSystem } from '@hooks/overlay/useNoteSystem'; +import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; +import { + mergeNoteSettings, + NOTE_SETTINGS_DEFAULTS, +} from '@src/types/settings/noteSettings'; +import { + setKeyActive as setKeyActiveSignal, + resetAllKeySignals, +} from '@stores/signals/keySignals'; +import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; +import { applyStatsSnapshot } from '@stores/signals/statsSignals'; +import { computeLayout } from '@hooks/shared/useLayoutComputation'; +import type { BootstrapPayload } from '@src/types/app'; +import type { KeyEventPayload } from '@src/types/obs'; +import type { + KeyMappings, + KeyPosition, + KeyPositions, +} from '@src/types/key/keys'; +import type { StatItemPositions } from '@src/types/key/statItems'; +import type { GraphItemPositions } from '@src/types/key/graphItems'; +import type { NoteSettings } from '@src/types/settings/noteSettings'; +import type { CustomCss } from '@src/types/plugin/css'; + +const OBS_CUSTOM_CSS_ID = 'dmn-obs-custom-css'; + +// ── 콜백 타입 (데이터 소스가 호출) ── + +export interface OverlayRuntimeHandlers { + onSnapshot: (payload: BootstrapPayload) => void; + onKeyEvent: (payload: KeyEventPayload) => void; + onSettingsDiff: (diff: Record) => void; + onCounterUpdate: (data: Record) => void; +} + +// ── 훅 ── + +export function useOverlayRuntime() { + // 상태 + const [keyMappings, setKeyMappings] = useState({}); + const [positions, setPositions] = useState({}); + const [statPositions, setStatPositions] = useState({}); + const [graphPositions, setGraphPositions] = useState({}); + const [selectedKeyType, setSelectedKeyType] = useState('4key'); + const [noteEffect, setNoteEffect] = useState(true); + const [noteSettings, setNoteSettings] = useState( + NOTE_SETTINGS_DEFAULTS, + ); + const [backgroundColor, setBackgroundColor] = useState('transparent'); + const [keyCounterEnabled, setKeyCounterEnabled] = useState(false); + const [initialized, setInitialized] = useState(false); + + // 커스텀 CSS DOM 주입 + const cssStateRef = useRef({ enabled: false, content: '' }); + const cssStyleElRef = useRef(null); + + const applyCssToDOM = useCallback(() => { + let el = cssStyleElRef.current; + if (!el) { + el = document.getElementById( + OBS_CUSTOM_CSS_ID, + ) as HTMLStyleElement | null; + if (!el) { + el = document.createElement('style'); + el.id = OBS_CUSTOM_CSS_ID; + document.head.appendChild(el); + } + cssStyleElRef.current = el; + } + const { enabled, content } = cssStateRef.current; + if (enabled && content) { + el.textContent = content; + el.disabled = false; + } else { + el.textContent = ''; + el.disabled = true; + } + }, []); + + // 노트 시스템 + const { + notesRef, + subscribe, + handleKeyDown, + handleKeyUp, + noteBuffer, + updateTrackLayouts, + } = useNoteSystem({ noteEffect, noteSettings }); + + // ref로 최신 값 유지 (useCallback 안에서 참조) + const handleKeyDownRef = useRef(handleKeyDown); + const handleKeyUpRef = useRef(handleKeyUp); + useEffect(() => { + handleKeyDownRef.current = handleKeyDown; + handleKeyUpRef.current = handleKeyUp; + }, [handleKeyDown, handleKeyUp]); + + const selectedKeyTypeRef = useRef(selectedKeyType); + useEffect(() => { + selectedKeyTypeRef.current = selectedKeyType; + }, [selectedKeyType]); + + const keyMappingsRef = useRef([]); + const positionsRef = useRef([]); + useEffect(() => { + keyMappingsRef.current = keyMappings[selectedKeyType] ?? []; + positionsRef.current = positions[selectedKeyType] ?? []; + }, [keyMappings, positions, selectedKeyType]); + + // 키 딜레이 + const keyDisplayDelayMsRef = useRef(0); + const keyDelayTimersRef = useRef< + Map> }> + >(new Map()); + + useEffect(() => { + keyDisplayDelayMsRef.current = Number(noteSettings?.keyDisplayDelayMs ?? 0); + }, [noteSettings?.keyDisplayDelayMs]); + + useEffect(() => { + const timers = keyDelayTimersRef.current; + return () => { + timers.forEach((entry) => { + entry.timers.forEach((timer) => clearTimeout(timer)); + }); + timers.clear(); + }; + }, []); + + // KPS 로컬 계산 (1초 슬라이딩 윈도우) + const kpsRef = useRef({ + timestamps: [] as number[], + total: 0, + kpsMax: 0, + kpsSumForAvg: 0, + kpsNonZeroCount: 0, + activeKeys: new Set(), + }); + + useEffect(() => { + const interval = setInterval(() => { + const tracker = kpsRef.current; + const now = performance.now(); + while ( + tracker.timestamps.length > 0 && + now - tracker.timestamps[0] > 1000 + ) { + tracker.timestamps.shift(); + } + const kps = tracker.timestamps.length; + if (kps > tracker.kpsMax) tracker.kpsMax = kps; + if (kps > 0) { + tracker.kpsSumForAvg += kps; + tracker.kpsNonZeroCount++; + } + const kpsAvg = + tracker.kpsNonZeroCount > 0 + ? Math.round(tracker.kpsSumForAvg / tracker.kpsNonZeroCount) + : 0; + applyStatsSnapshot({ + kps, + kpsAvg, + kpsMax: tracker.kpsMax, + total: tracker.total, + }); + }, 50); + return () => clearInterval(interval); + }, []); + + // 키 딜레이 적용 신호 업데이트 + const updateKeySignalWithDelay = useCallback( + (key: string, isDown: boolean) => { + const delayMs = keyDisplayDelayMsRef.current; + + let timerEntry = keyDelayTimersRef.current.get(key); + if (!timerEntry) { + timerEntry = { timers: new Set() }; + keyDelayTimersRef.current.set(key, timerEntry); + } + + if (delayMs <= 0) { + timerEntry.timers.forEach((timer) => clearTimeout(timer)); + timerEntry.timers.clear(); + setKeyActiveSignal(key, isDown); + return; + } + + const timer = setTimeout(() => { + setKeyActiveSignal(key, isDown); + timerEntry?.timers.delete(timer); + }, delayMs); + timerEntry.timers.add(timer); + }, + [], + ); + + // ── 데이터 소스 콜백 ── + + const onSnapshot = useCallback( + (payload: BootstrapPayload) => { + setKeyMappings(payload.keys ?? {}); + setPositions(payload.positions ?? {}); + setStatPositions(payload.statPositions ?? {}); + setGraphPositions(payload.graphPositions ?? {}); + setSelectedKeyType(payload.selectedKeyType ?? '4key'); + + const settings = payload.settings; + if (settings) { + setNoteEffect(settings.noteEffect ?? true); + setNoteSettings( + mergeNoteSettings( + settings.noteSettings ?? NOTE_SETTINGS_DEFAULTS, + null, + ), + ); + setBackgroundColor(settings.backgroundColor ?? 'transparent'); + setKeyCounterEnabled(settings.keyCounterEnabled ?? false); + + // 커스텀 CSS + cssStateRef.current = { + enabled: settings.useCustomCSS ?? false, + content: (settings.customCSS as CustomCss | undefined)?.content ?? '', + }; + applyCssToDOM(); + } + + // 카운터 초기화 + if (payload.keyCounters) { + applyCounterSnapshot(payload.keyCounters); + } + + // KPS 트래커 리셋 + const tracker = kpsRef.current; + tracker.timestamps = []; + tracker.kpsMax = 0; + tracker.kpsSumForAvg = 0; + tracker.kpsNonZeroCount = 0; + tracker.activeKeys.clear(); + let totalFromCounters = 0; + if (payload.keyCounters) { + const modeCounters = + payload.keyCounters[payload.selectedKeyType ?? '4key']; + if (modeCounters) { + totalFromCounters = Object.values(modeCounters).reduce( + (sum, v) => sum + v, + 0, + ); + } + } + tracker.total = totalFromCounters; + applyStatsSnapshot({ + kps: 0, + kpsAvg: 0, + kpsMax: 0, + total: totalFromCounters, + }); + + // 딜레이 타이머 정리 + keyDelayTimersRef.current.forEach((entry) => { + entry.timers.forEach((timer) => clearTimeout(timer)); + entry.timers.clear(); + }); + + // ref 즉시 동기화 + const mode = payload.selectedKeyType ?? '4key'; + selectedKeyTypeRef.current = mode; + keyMappingsRef.current = (payload.keys ?? {})[mode] ?? []; + positionsRef.current = (payload.positions ?? {})[mode] ?? []; + if (payload.settings?.noteSettings) { + keyDisplayDelayMsRef.current = Number( + payload.settings.noteSettings.keyDisplayDelayMs ?? 0, + ); + } + + resetAllKeySignals(); + setInitialized(true); + }, + [applyCssToDOM], + ); + + const onKeyEvent = useCallback( + (payload: KeyEventPayload) => { + const { key, state } = payload; + const isDown = state === 'DOWN'; + + updateKeySignalWithDelay(key, isDown); + + // KPS + const tracker = kpsRef.current; + if (isDown) { + if (!tracker.activeKeys.has(key)) { + tracker.activeKeys.add(key); + tracker.timestamps.push(performance.now()); + tracker.total++; + } + const keys = keyMappingsRef.current; + const pos = positionsRef.current; + const keyIndex = keys.indexOf(key); + const keyPosition = pos[keyIndex]; + if (keyPosition?.noteEffectEnabled !== false) { + requestAnimationFrame(() => handleKeyDownRef.current(key)); + } + } else { + tracker.activeKeys.delete(key); + requestAnimationFrame(() => handleKeyUpRef.current(key)); + } + }, + [updateKeySignalWithDelay], + ); + + const onSettingsDiff = useCallback( + (diff: Record) => { + if ('noteEffect' in diff) setNoteEffect(diff.noteEffect as boolean); + if ('noteSettings' in diff) + setNoteSettings((prev) => + mergeNoteSettings( + { ...prev, ...(diff.noteSettings as Partial) }, + null, + ), + ); + if ('backgroundColor' in diff) + setBackgroundColor(diff.backgroundColor as string); + if ('keyCounterEnabled' in diff) + setKeyCounterEnabled(diff.keyCounterEnabled as boolean); + + // 커스텀 CSS + let cssChanged = false; + if ('useCustomCSS' in diff) { + cssStateRef.current.enabled = diff.useCustomCSS as boolean; + cssChanged = true; + } + if ('customCSS' in diff) { + const css = diff.customCSS as Partial | undefined; + if (css?.content !== undefined) { + cssStateRef.current.content = css.content; + cssChanged = true; + } + } + if (cssChanged) applyCssToDOM(); + }, + [applyCssToDOM], + ); + + const onCounterUpdate = useCallback((data: Record) => { + const counters = data as Record>; + applyCounterSnapshot(counters); + const modeCounters = counters[selectedKeyTypeRef.current]; + if (modeCounters) { + kpsRef.current.total = Object.values(modeCounters).reduce( + (sum, v) => sum + v, + 0, + ); + } + }, []); + + // ── 레이아웃 계산 ── + + const currentKeys = keyMappings[selectedKeyType] ?? []; + const currentPositions = positions[selectedKeyType] ?? []; + const currentStatPositions = statPositions[selectedKeyType] ?? []; + const currentGraphPositions = graphPositions[selectedKeyType] ?? []; + + const trackHeight = + noteSettings?.trackHeight ?? DEFAULT_NOTE_SETTINGS.trackHeight; + + const { + displayPositions, + displayStatPositions, + displayGraphPositions, + webglTracks, + } = computeLayout({ + currentKeys, + currentPositions, + currentStatPositions, + currentGraphPositions, + trackHeight, + noteSettings, + }); + + useEffect(() => { + updateTrackLayouts(webglTracks); + }, [webglTracks, updateTrackLayouts]); + + return { + // 데이터 소스 콜백 + handlers: { + onSnapshot, + onKeyEvent, + onSettingsDiff, + onCounterUpdate, + } satisfies OverlayRuntimeHandlers, + + // OverlayScene props + sceneProps: { + currentKeys, + displayPositions, + currentPositions, + displayStatPositions, + displayGraphPositions, + selectedKeyType, + noteEffect, + noteSettings, + webglTracks, + notesRef, + subscribe, + noteBuffer, + backgroundColor, + keyCounterEnabled, + showPluginElements: false as const, + }, + + initialized, + }; +} diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx index ebc041bb..022648ec 100644 --- a/src/renderer/windows/obs/App.tsx +++ b/src/renderer/windows/obs/App.tsx @@ -1,377 +1,22 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useObsWebSocket } from '@hooks/obs/useObsWebSocket'; -import { useNoteSystem } from '@hooks/overlay/useNoteSystem'; -import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; -import { - mergeNoteSettings, - NOTE_SETTINGS_DEFAULTS, -} from '@src/types/settings/noteSettings'; -import { - setKeyActive as setKeyActiveSignal, - resetAllKeySignals, -} from '@stores/signals/keySignals'; -import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; -import { applyStatsSnapshot } from '@stores/signals/statsSignals'; +import { useOverlayRuntime } from '@hooks/shared/useOverlayRuntime'; import OverlayScene from '@components/shared/OverlayScene'; -import { computeLayout } from '@hooks/shared/useLayoutComputation'; -import type { BootstrapPayload } from '@src/types/app'; -import type { KeyEventPayload } from '@src/types/obs'; -import type { KeyMappings, KeyPosition, KeyPositions } from '@src/types/key/keys'; -import type { StatItemPositions } from '@src/types/key/statItems'; -import type { GraphItemPositions } from '@src/types/key/graphItems'; -import type { NoteSettings } from '@src/types/settings/noteSettings'; -import type { CustomCss } from '@src/types/plugin/css'; - -const OBS_CUSTOM_CSS_ID = 'dmn-obs-custom-css'; export default function App() { - // WS 연결 URL: HTTP로 서빙된 경우 같은 호스트:포트, 아닌 경우 query param fallback const params = new URLSearchParams(window.location.search); const host = params.get('host') || window.location.hostname || '127.0.0.1'; const port = params.get('port') || window.location.port || '34891'; const token = params.get('token') || ''; const wsUrl = `ws://${host}:${port}`; - // 상태 - const [keyMappings, setKeyMappings] = useState({}); - const [positions, setPositions] = useState({}); - const [statPositions, setStatPositions] = useState({}); - const [graphPositions, setGraphPositions] = useState({}); - const [selectedKeyType, setSelectedKeyType] = useState('4key'); - const [noteEffect, setNoteEffect] = useState(true); - const [noteSettings, setNoteSettings] = useState( - NOTE_SETTINGS_DEFAULTS, - ); - const [backgroundColor, setBackgroundColor] = useState('transparent'); - const [keyCounterEnabled, setKeyCounterEnabled] = useState(false); - const [initialized, setInitialized] = useState(false); - - // 커스텀 CSS 주입 - const cssStateRef = useRef({ enabled: false, content: '' }); - const cssStyleElRef = useRef(null); - - const applyCssToDOM = useCallback(() => { - let el = cssStyleElRef.current; - if (!el) { - el = document.getElementById(OBS_CUSTOM_CSS_ID) as HTMLStyleElement | null; - if (!el) { - el = document.createElement('style'); - el.id = OBS_CUSTOM_CSS_ID; - document.head.appendChild(el); - } - cssStyleElRef.current = el; - } - const { enabled, content } = cssStateRef.current; - if (enabled && content) { - el.textContent = content; - el.disabled = false; - } else { - el.textContent = ''; - el.disabled = true; - } - }, []); - - // 노트 시스템 - const { - notesRef, - subscribe, - handleKeyDown, - handleKeyUp, - noteBuffer, - updateTrackLayouts, - } = useNoteSystem({ noteEffect, noteSettings }); - - // handleKeyDown/handleKeyUp를 ref로 유지 - const handleKeyDownRef = useRef(handleKeyDown); - const handleKeyUpRef = useRef(handleKeyUp); - useEffect(() => { - handleKeyDownRef.current = handleKeyDown; - handleKeyUpRef.current = handleKeyUp; - }, [handleKeyDown, handleKeyUp]); - - // selectedKeyType를 ref로 유지 (콜백에서 최신 값 참조) - const selectedKeyTypeRef = useRef(selectedKeyType); - useEffect(() => { - selectedKeyTypeRef.current = selectedKeyType; - }, [selectedKeyType]); - - // 현재 모드의 키/포지션을 ref로 유지 (이벤트 콜백에서 최신 값 참조) - const keyMappingsRef = useRef([]); - const positionsRef = useRef([]); - useEffect(() => { - keyMappingsRef.current = keyMappings[selectedKeyType] ?? []; - positionsRef.current = positions[selectedKeyType] ?? []; - }, [keyMappings, positions, selectedKeyType]); - - // 키 딜레이 설정 - const keyDisplayDelayMsRef = useRef(0); - const keyDelayTimersRef = useRef< - Map> }> - >(new Map()); - - useEffect(() => { - keyDisplayDelayMsRef.current = Number( - noteSettings?.keyDisplayDelayMs ?? 0, - ); - }, [noteSettings?.keyDisplayDelayMs]); - - // 딜레이 타이머 클린업 - useEffect(() => { - const timers = keyDelayTimersRef.current; - return () => { - timers.forEach((entry) => { - entry.timers.forEach((timer) => clearTimeout(timer)); - }); - timers.clear(); - }; - }, []); - - // KPS 로컬 계산 (1초 슬라이딩 윈도우) - const kpsRef = useRef({ - timestamps: [] as number[], - total: 0, - kpsMax: 0, - kpsSumForAvg: 0, - kpsNonZeroCount: 0, - activeKeys: new Set(), - }); - - useEffect(() => { - const interval = setInterval(() => { - const tracker = kpsRef.current; - const now = performance.now(); - while (tracker.timestamps.length > 0 && now - tracker.timestamps[0] > 1000) { - tracker.timestamps.shift(); - } - const kps = tracker.timestamps.length; - if (kps > tracker.kpsMax) tracker.kpsMax = kps; - if (kps > 0) { - tracker.kpsSumForAvg += kps; - tracker.kpsNonZeroCount++; - } - const kpsAvg = tracker.kpsNonZeroCount > 0 - ? Math.round(tracker.kpsSumForAvg / tracker.kpsNonZeroCount) - : 0; - applyStatsSnapshot({ - kps, - kpsAvg, - kpsMax: tracker.kpsMax, - total: tracker.total, - }); - }, 50); - return () => clearInterval(interval); - }, []); - - // 스냅샷 수신 - const onSnapshot = useCallback((payload: BootstrapPayload) => { - setKeyMappings(payload.keys ?? {}); - setPositions(payload.positions ?? {}); - setStatPositions(payload.statPositions ?? {}); - setGraphPositions(payload.graphPositions ?? {}); - setSelectedKeyType(payload.selectedKeyType ?? '4key'); - - const settings = payload.settings; - if (settings) { - setNoteEffect(settings.noteEffect ?? true); - setNoteSettings( - mergeNoteSettings( - settings.noteSettings ?? NOTE_SETTINGS_DEFAULTS, - null, - ), - ); - setBackgroundColor(settings.backgroundColor ?? 'transparent'); - setKeyCounterEnabled(settings.keyCounterEnabled ?? false); - - // 커스텀 CSS 적용 - cssStateRef.current = { - enabled: settings.useCustomCSS ?? false, - content: (settings.customCSS as CustomCss | undefined)?.content ?? '', - }; - applyCssToDOM(); - } - - // 카운터 초기화 - if (payload.keyCounters) { - applyCounterSnapshot(payload.keyCounters); - } - - // KPS 트래커 리셋 - const tracker = kpsRef.current; - tracker.timestamps = []; - tracker.kpsMax = 0; - tracker.kpsSumForAvg = 0; - tracker.kpsNonZeroCount = 0; - tracker.activeKeys.clear(); - // total은 카운터 합산으로 초기화 - let totalFromCounters = 0; - if (payload.keyCounters) { - const modeCounters = payload.keyCounters[payload.selectedKeyType ?? '4key']; - if (modeCounters) { - totalFromCounters = Object.values(modeCounters).reduce((sum, v) => sum + v, 0); - } - } - tracker.total = totalFromCounters; - applyStatsSnapshot({ kps: 0, kpsAvg: 0, kpsMax: 0, total: totalFromCounters }); - - // 딜레이 타이머 정리 (resync 시 이전 타이머 잔류 방지) - keyDelayTimersRef.current.forEach((entry) => { - entry.timers.forEach((timer) => clearTimeout(timer)); - entry.timers.clear(); - }); - - // ref 즉시 동기화 (useEffect 지연 없이 첫 key_event에서 최신 값 사용) - const mode = payload.selectedKeyType ?? '4key'; - selectedKeyTypeRef.current = mode; - keyMappingsRef.current = (payload.keys ?? {})[mode] ?? []; - positionsRef.current = (payload.positions ?? {})[mode] ?? []; - if (payload.settings?.noteSettings) { - keyDisplayDelayMsRef.current = Number( - payload.settings.noteSettings.keyDisplayDelayMs ?? 0, - ); - } - - resetAllKeySignals(); - setInitialized(true); - }, [applyCssToDOM]); - - // 키 딜레이 적용 신호 업데이트 - const updateKeySignalWithDelay = useCallback( - (key: string, isDown: boolean) => { - const delayMs = keyDisplayDelayMsRef.current; - - let timerEntry = keyDelayTimersRef.current.get(key); - if (!timerEntry) { - timerEntry = { timers: new Set() }; - keyDelayTimersRef.current.set(key, timerEntry); - } - - if (delayMs <= 0) { - timerEntry.timers.forEach((timer) => clearTimeout(timer)); - timerEntry.timers.clear(); - setKeyActiveSignal(key, isDown); - return; - } - - const timer = setTimeout(() => { - setKeyActiveSignal(key, isDown); - timerEntry?.timers.delete(timer); - }, delayMs); - timerEntry.timers.add(timer); - }, - [], - ); - - // 키 이벤트 수신 - const onKeyEvent = useCallback((payload: KeyEventPayload) => { - const { key, state } = payload; - const isDown = state === 'DOWN'; - - // 키 UI 업데이트 (딜레이 적용) - updateKeySignalWithDelay(key, isDown); - - // KPS 로컬 계산 피드 - const tracker = kpsRef.current; - if (isDown) { - if (!tracker.activeKeys.has(key)) { - tracker.activeKeys.add(key); - tracker.timestamps.push(performance.now()); - tracker.total++; - } - // 개별 키의 noteEffectEnabled 확인 - const keys = keyMappingsRef.current; - const pos = positionsRef.current; - const keyIndex = keys.indexOf(key); - const keyPosition = pos[keyIndex]; - if (keyPosition?.noteEffectEnabled !== false) { - requestAnimationFrame(() => handleKeyDownRef.current(key)); - } - } else { - tracker.activeKeys.delete(key); - requestAnimationFrame(() => handleKeyUpRef.current(key)); - } - }, [updateKeySignalWithDelay]); - - // 설정 변경 - const onSettingsDiff = useCallback((diff: Record) => { - if ('noteEffect' in diff) setNoteEffect(diff.noteEffect as boolean); - if ('noteSettings' in diff) - setNoteSettings((prev) => - mergeNoteSettings( - { ...prev, ...(diff.noteSettings as Partial) }, - null, - ), - ); - if ('backgroundColor' in diff) - setBackgroundColor(diff.backgroundColor as string); - if ('keyCounterEnabled' in diff) - setKeyCounterEnabled(diff.keyCounterEnabled as boolean); - - // 커스텀 CSS 변경 - let cssChanged = false; - if ('useCustomCSS' in diff) { - cssStateRef.current.enabled = diff.useCustomCSS as boolean; - cssChanged = true; - } - if ('customCSS' in diff) { - const css = diff.customCSS as Partial | undefined; - if (css?.content !== undefined) { - cssStateRef.current.content = css.content; - cssChanged = true; - } - } - if (cssChanged) applyCssToDOM(); - }, [applyCssToDOM]); - - // 카운터 업데이트 - const onCounterUpdate = useCallback((data: Record) => { - const counters = data as Record>; - applyCounterSnapshot(counters); - // 카운터 리셋/수정 시 total 재계산 - const modeCounters = counters[selectedKeyTypeRef.current]; - if (modeCounters) { - kpsRef.current.total = Object.values(modeCounters).reduce( - (sum, v) => sum + v, - 0, - ); - } - }, []); + const { handlers, sceneProps, initialized } = useOverlayRuntime(); useObsWebSocket({ url: wsUrl, token, - onSnapshot, - onKeyEvent, - onSettingsDiff, - onCounterUpdate, + ...handlers, }); - // 현재 모드 데이터 - const currentKeys = keyMappings[selectedKeyType] ?? []; - const currentPositions = positions[selectedKeyType] ?? []; - const currentStatPositions = statPositions[selectedKeyType] ?? []; - const currentGraphPositions = graphPositions[selectedKeyType] ?? []; - - const trackHeight = - noteSettings?.trackHeight ?? DEFAULT_NOTE_SETTINGS.trackHeight; - - const { - displayPositions, - displayStatPositions, - displayGraphPositions, - webglTracks, - } = computeLayout({ - currentKeys, - currentPositions, - currentStatPositions, - currentGraphPositions, - trackHeight, - noteSettings, - }); - - useEffect(() => { - updateTrackLayouts(webglTracks); - }, [webglTracks, updateTrackLayouts]); - if (!initialized) { return (
- ); + return ; } From 8f456f9d463821206a91a46c4a7295efb6f543a6 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sat, 7 Mar 2026 22:39:53 +0900 Subject: [PATCH 29/56] =?UTF-8?q?fix:=20OBS=20bridge=EB=A5=BC=20Tauri=20?= =?UTF-8?q?=EC=9E=84=EB=B2=A0=EB=94=A9=20=EC=97=90=EC=85=8B=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98=EC=97=AC=20=ED=8F=AC?= =?UTF-8?q?=ED=84=B0=EB=B8=94=20exe=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - static_dir 디스크 파일 서빙을 AssetFetcher(asset_resolver) 기반으로 교체 - dev 모드 리다이렉트에 port/token query param 추가 (WS 포트 불일치 해결) - useObsWebSocket의 try/catch 범위를 JSON 파싱만으로 축소하여 콜백 에러 전파 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 46 ++--- src-tauri/src/services/obs_bridge.rs | 160 ++++++++---------- src-tauri/src/state/app_state.rs | 15 +- src/renderer/components/main/Settings.tsx | 10 +- src/renderer/hooks/obs/useObsWebSocket.ts | 75 ++++---- .../hooks/shared/useLayoutComputation.ts | 8 +- 6 files changed, 143 insertions(+), 171 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 91eebdb1..c302cd90 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -1,32 +1,9 @@ -use std::path::PathBuf; +use std::sync::Arc; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, State}; use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; -/// OBS 빌드 정적 파일 루트 경로 탐색 (dist/renderer/) -/// obs/index.html이 ../assets/ 를 참조하므로, obs/ 상위인 renderer/ 루트를 반환 -pub fn resolve_obs_static_dir(app: &AppHandle) -> Option { - // 1. Tauri resource_dir (프로덕션 번들) - if let Ok(res) = app.path().resource_dir() { - if res.join("obs/index.html").exists() { - return Some(res); - } - } - - // 2. 실행 파일 기준 탐색 (dev mode: src-tauri/target/debug/) - if let Ok(exe) = std::env::current_exe() { - if let Some(exe_dir) = exe.parent() { - let renderer_root = exe_dir.join("../../../dist/renderer"); - if renderer_root.join("obs/index.html").exists() { - return renderer_root.canonicalize().ok(); - } - } - } - - None -} - #[tauri::command] pub async fn obs_start( app: AppHandle, @@ -35,17 +12,24 @@ pub async fn obs_start( ) -> CmdResult { let port = port.unwrap_or(state.store.with_state(|s| s.obs_port)); - // OBS 정적 파일 경로 설정 - // dev 모드에서는 Vite dev server 우선 사용 (stale 빌드 디렉토리 회피) + // OBS 정적 파일 서빙 설정 if cfg!(debug_assertions) { + // dev 모드: Vite dev server로 리다이렉트 let dev_url = "http://localhost:3400".to_string(); log::info!("[ObsBridge] dev 모드: Vite dev server로 리다이렉트 ({dev_url})"); state.obs_bridge.set_dev_url(dev_url); - } else if let Some(dir) = resolve_obs_static_dir(&app) { - log::info!("[ObsBridge] static_dir: {}", dir.display()); - state.obs_bridge.set_static_dir(dir); } else { - log::warn!("[ObsBridge] OBS 정적 파일 디렉토리를 찾을 수 없음 (HTTP 서빙 비활성)"); + // 프로덕션: Tauri 임베딩 에셋으로 서빙 (포터블 단일 exe 지원) + let handle = app.clone(); + let fetcher = 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) + }) + }); + state.obs_bridge.set_asset_fetcher(fetcher); + log::info!("[ObsBridge] Tauri 임베딩 에셋으로 HTTP 서빙"); } state diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 82460410..a54e2f38 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -17,6 +17,9 @@ use crate::models::obs::{ make_envelope, HelloAckPayload, KeyEventPayload, KeyState, ObsBroadcast, ObsEnvelope, ObsStatus, }; +/// 임베딩 에셋 조회 함수 타입 (path → Option<(bytes, mime_type)>) +pub type AssetFetcher = Arc Option<(Vec, String)> + Send + Sync>; + /// OBS WebSocket 서버 pub struct ObsBridgeService { running: AtomicBool, @@ -27,8 +30,8 @@ pub struct ObsBridgeService { shutdown_tx: RwLock>>, /// 서버 루프 태스크 핸들 (stop→start 경쟁 조건 방지) server_handle: tokio::sync::Mutex>>, - /// 빌드된 OBS 정적 파일 경로 (dist/renderer/obs) - static_dir: RwLock>, + /// Tauri 임베딩 에셋 조회 (포터블 exe용) + asset_fetcher: RwLock>, /// dev 모드 Vite dev server URL (예: "http://localhost:3400") dev_url: RwLock>, server_version: String, @@ -47,15 +50,15 @@ impl ObsBridgeService { broadcast_tx, shutdown_tx: RwLock::new(None), server_handle: tokio::sync::Mutex::new(None), - static_dir: RwLock::new(None), + asset_fetcher: RwLock::new(None), dev_url: RwLock::new(None), server_version: version.to_string(), session_token: RwLock::new(String::new()), } } - pub fn set_static_dir(&self, dir: PathBuf) { - *self.static_dir.write() = Some(dir); + pub fn set_asset_fetcher(&self, fetcher: AssetFetcher) { + *self.asset_fetcher.write() = Some(fetcher); } pub fn set_dev_url(&self, url: String) { @@ -246,49 +249,6 @@ impl ObsBridgeService { let mut discard = vec![0u8; request.len()]; let _ = stream.read(&mut discard).await; - // RwLockReadGuard를 await 전에 해제하기 위해 즉시 clone - let static_dir = self.static_dir.read().clone(); - let dev_url = self.dev_url.read().clone(); - let static_dir = match static_dir.as_ref() { - Some(dir) => dir.clone(), - None => { - // dev 모드: Vite dev server로 리다이렉트 - if let Some(dev_base) = &dev_url { - let path = request - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or("/"); - // /media/ 경로는 이미 위에서 처리됨 → 정적 파일만 리다이렉트 - let obs_path = if path == "/" || path.is_empty() { - "/obs/index.html" - } else { - path - }; - // OBS 정적 파일 경로가 /obs/로 시작하지 않으면 추가 - let redirect_path = if obs_path.starts_with("/obs/") { - obs_path.to_string() - } else { - format!("/obs{obs_path}") - }; - let location = format!("{dev_base}{redirect_path}"); - let response = format!( - "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - ); - let _ = stream.write_all(response.as_bytes()).await; - return; - } - let body = "OBS bridge: static directory not configured"; - let response = format!( - "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(response.as_bytes()).await; - return; - } - }; - // GET 경로 파싱 let path = request .lines() @@ -296,14 +256,42 @@ impl ObsBridgeService { .and_then(|line| line.split_whitespace().nth(1)) .unwrap_or("/"); + // dev 모드: Vite dev server로 리다이렉트 + let dev_url = self.dev_url.read().clone(); + if let Some(dev_base) = &dev_url { + let obs_path = if path == "/" || path.is_empty() { + "/obs/index.html" + } else { + path + }; + let redirect_path = if obs_path.starts_with("/obs/") { + obs_path.to_string() + } else { + format!("/obs{obs_path}") + }; + // WS 연결에 필요한 port/token을 query param으로 전달 + // (Vite dev server 포트 ≠ OBS bridge 포트) + let port = *self.port.read(); + let token = self.session_token.read().clone(); + let location = if redirect_path.contains('?') { + format!("{dev_base}{redirect_path}&port={port}&token={token}") + } else { + format!("{dev_base}{redirect_path}?port={port}&token={token}") + }; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } + // /media/?token=xxx — 사용자 로컬 미디어 파일 서빙 if let Some(rest) = path.strip_prefix("/media/") { self.handle_media_request(stream, rest).await; return; } - // 경로 정규화: "/" → "obs/index.html", 디렉토리 탐색 방지 - // static_dir은 dist/renderer/ (obs/index.html이 ../assets/ 참조하므로) + // 경로 정규화: "/" → "obs/index.html" let normalized = if path == "/" || path.is_empty() { "obs/index.html" } else { @@ -324,58 +312,48 @@ impl ObsBridgeService { return; } - let file_path = static_dir.join(normalized); - - // canonicalize 후 static_dir 하위인지 재검증 - if let Ok(canonical) = file_path.canonicalize() { - if !canonical.starts_with(&static_dir) { - let _ = stream - .write_all( - b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - return; - } + // 에셋 조회: 1) Tauri 임베딩 에셋 2) 디스크 정적 파일 + if let Some((content, mime)) = self.resolve_asset(normalized).await { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", + content.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&content).await; + return; } - match tokio::fs::read(&file_path).await { - Ok(content) => { - let mime = guess_mime(normalized); + // SPA fallback: 확장자 없는 경로 → obs/index.html + let has_extension = normalized + .rsplit('/') + .next() + .map_or(false, |filename| filename.contains('.')); + if !has_extension { + if let Some((content, mime)) = self.resolve_asset("obs/index.html").await { let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n", content.len() ); let _ = stream.write_all(response.as_bytes()).await; let _ = stream.write_all(&content).await; + return; } - Err(_) => { - // 확장자가 있는 정적 리소스 요청은 SPA fallback 하지 않음 (404) - let has_extension = normalized - .rsplit('/') - .next() - .map_or(false, |filename| filename.contains('.')); - if has_extension { - let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await; - return; - } + } - // SPA fallback: 확장자 없는 경로 → obs/index.html - let index_path = static_dir.join("obs/index.html"); - match tokio::fs::read(&index_path).await { - Ok(content) => { - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\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; - } - } - } + let _ = stream + .write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + } + + /// Tauri 임베딩 에셋 조회 + async fn resolve_asset(&self, path: &str) -> Option<(Vec, String)> { + let fetcher = self.asset_fetcher.read().clone(); + if let Some(ref f) = fetcher { + return f(path); } + None } async fn handle_ws_client( diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 17a84c13..df904575 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -296,10 +296,17 @@ impl AppState { let port = store.with_state(|s| s.obs_port); let app_handle = app.clone(); - // 정적 파일 경로 설정 - if let Some(dir) = crate::commands::app::obs::resolve_obs_static_dir(&app_handle) { - log::info!("[ObsBridge] auto-start static_dir: {}", dir.display()); - bridge.set_static_dir(dir); + // 프로덕션: Tauri 임베딩 에셋으로 서빙 + if !cfg!(debug_assertions) { + 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); } // 오버레이 숨김 (부팅 시 flash 방지) diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index c1af1ec6..1debef31 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -585,7 +585,10 @@ const Settings = ({ const status = await obsApi.start(port); setObsStatus(status); // 시작 성공 후에만 영속화 (실패 시 obsModeEnabled=true 잔류 방지) - await window.api.settings.update({ obsModeEnabled: true, obsPort: port }); + await window.api.settings.update({ + obsModeEnabled: true, + obsPort: port, + }); } } catch (error) { console.error('Failed to toggle OBS mode', error); @@ -974,7 +977,10 @@ const Settings = ({
{obsStatus.running && ( -
+

{t('settings.obsGuide')}

diff --git a/src/renderer/hooks/obs/useObsWebSocket.ts b/src/renderer/hooks/obs/useObsWebSocket.ts index 7e0c468b..ba00c094 100644 --- a/src/renderer/hooks/obs/useObsWebSocket.ts +++ b/src/renderer/hooks/obs/useObsWebSocket.ts @@ -85,46 +85,47 @@ function useObsWebSocket({ }; ws.onmessage = (event) => { + let envelope: ObsEnvelope; try { - const envelope = JSON.parse(event.data as string) as ObsEnvelope; - switch (envelope.type) { - case 'hello_ack': - setConnectionState('connected'); - break; - case 'snapshot': - callbacksRef.current.onSnapshot( - envelope.payload as BootstrapPayload, - ); - break; - case 'key_event': - callbacksRef.current.onKeyEvent( - envelope.payload as KeyEventPayload, - ); - break; - case 'settings_diff': - callbacksRef.current.onSettingsDiff( - envelope.payload as Record, - ); - break; - case 'counter_update': - callbacksRef.current.onCounterUpdate( - envelope.payload as Record, - ); - break; - case 'ping': - sendMessage('pong'); - break; - case 'error': { - const payload = envelope.payload as Record; - if (payload?.code === 'AUTH_FAILED') { - console.warn('[ObsWS] 토큰 인증 실패, 재연결 중단'); - disposed = true; // 재연결 루프 방지 - } - break; + envelope = JSON.parse(event.data as string) as ObsEnvelope; + } catch { + return; // JSON 파싱 실패 무시 + } + switch (envelope.type) { + case 'hello_ack': + setConnectionState('connected'); + break; + case 'snapshot': + callbacksRef.current.onSnapshot( + envelope.payload as BootstrapPayload, + ); + break; + case 'key_event': + callbacksRef.current.onKeyEvent( + envelope.payload as KeyEventPayload, + ); + break; + case 'settings_diff': + callbacksRef.current.onSettingsDiff( + envelope.payload as Record, + ); + break; + case 'counter_update': + callbacksRef.current.onCounterUpdate( + envelope.payload as Record, + ); + break; + case 'ping': + sendMessage('pong'); + break; + case 'error': { + const payload = envelope.payload as Record; + if (payload?.code === 'AUTH_FAILED') { + console.warn('[ObsWS] 토큰 인증 실패, 재연결 중단'); + disposed = true; // 재연결 루프 방지 } + break; } - } catch { - // 파싱 실패 무시 } }; diff --git a/src/renderer/hooks/shared/useLayoutComputation.ts b/src/renderer/hooks/shared/useLayoutComputation.ts index 3f20d156..50ee53bf 100644 --- a/src/renderer/hooks/shared/useLayoutComputation.ts +++ b/src/renderer/hooks/shared/useLayoutComputation.ts @@ -114,9 +114,7 @@ export function computeLayout(input: LayoutInput) { } const width = - element.measuredSize?.width ?? - element.estimatedSize?.width ?? - 200; + element.measuredSize?.width ?? element.estimatedSize?.width ?? 200; const height = element.measuredSize?.height ?? element.estimatedSize?.height ?? @@ -159,9 +157,7 @@ export function computeLayout(input: LayoutInput) { const displayStatPositions = applyOffset(currentStatPositions); const displayGraphPositions = applyOffset(currentGraphPositions); - const positionOffset = bounds - ? { x: offsetX, y: offsetY } - : { x: 0, y: 0 }; + const positionOffset = bounds ? { x: offsetX, y: offsetY } : { x: 0, y: 0 }; const topMostY = bounds ? topOffset : 0; From 0ca539c57466c56c7985d8e17b8b68b707584a88 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 10:32:50 +0900 Subject: [PATCH 30/56] =?UTF-8?q?docs:=20=C2=A712=20Tauri=20IPC=20Shim=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=84=B1=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=A1=9C=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OverlayHost adapter(B) → Tauri IPC Shim(C) 방식으로 전환. invoke/listen 프리미티브를 WS로 교체하여 overlay 코드 변경 없이 OBS 호환성 달성. useOverlayRuntime 중복 로직 완전 해소 설계. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/codex-debug/SKILL.md | 4 +- .claude/skills/codex-review/SKILL.md | 4 +- .claude/skills/plan/SKILL.md | 4 +- docs/obs-mode-design.md | 299 +++++++++++++++++++-------- 4 files changed, 223 insertions(+), 88 deletions(-) diff --git a/.claude/skills/codex-debug/SKILL.md b/.claude/skills/codex-debug/SKILL.md index 61f9930e..ce20d777 100644 --- a/.claude/skills/codex-debug/SKILL.md +++ b/.claude/skills/codex-debug/SKILL.md @@ -107,8 +107,8 @@ codex exec -C "$(pwd)" -s danger-full-access --json "다음 증상의 원인 후 ### 호출 규칙 - `-C "$(pwd)"`, `-s danger-full-access`, `--json` 기본 적용. - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). -- Bash의 `run_in_background: true`로 실행합니다. `timeout: 600000`은 Bash 도구의 최대 대기 시간이며, 백그라운드 실행이므로 Codex 작업 자체는 완료까지 계속됩니다. -- 완료 알림을 받으면 TaskOutput으로 결과를 수집합니다. +- Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. +- 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. ### 진행 상황 확인 백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다. diff --git a/.claude/skills/codex-review/SKILL.md b/.claude/skills/codex-review/SKILL.md index 7d746515..c9834d4b 100644 --- a/.claude/skills/codex-review/SKILL.md +++ b/.claude/skills/codex-review/SKILL.md @@ -70,8 +70,8 @@ codex exec resume --last "해당 이슈에 대한 구체적인 수정 코드를 ### 호출 규칙 - `-C "$(pwd)"`, `-s danger-full-access`, `--json` 기본 적용. - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). -- Bash의 `run_in_background: true`로 실행합니다. `timeout: 600000`은 Bash 도구의 최대 대기 시간이며, 백그라운드 실행이므로 Codex 작업 자체는 완료까지 계속됩니다. -- 완료 알림을 받으면 TaskOutput으로 결과를 수집합니다. +- Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. +- 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. ## 실패 처리 diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md index 5fb76057..047dac8e 100644 --- a/.claude/skills/plan/SKILL.md +++ b/.claude/skills/plan/SKILL.md @@ -74,8 +74,8 @@ codex exec -C "$(pwd)" -s danger-full-access --json "다음 작업에 대한 구 ### 공통 규칙 - `-C "$(pwd)"`, `-s danger-full-access`, `--json` 기본 적용. - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). -- Bash의 `run_in_background: true`로 실행합니다. `timeout: 600000`은 Bash 도구의 최대 대기 시간이며, 백그라운드 실행이므로 Codex 작업 자체는 완료까지 계속됩니다. -- 완료 알림을 받으면 TaskOutput으로 결과를 수집합니다. +- Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. +- 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. ### 진행 상황 확인 백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다. diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index 8284d27a..79823cfd 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -199,11 +199,14 @@ pub struct ObsBridgeService { broadcast_tx: broadcast::Sender, shutdown_tx: RwLock>>, server_version: String, - static_dir: RwLock>, // v2: HTTP 정적 서빙용 + asset_fetcher: RwLock>, // v4: Tauri 임베딩 에셋 (포터블 exe) server_handle: tokio::sync::Mutex>>, // v3: stop→start 경쟁 방지 dev_url: RwLock>, // v3: dev 모드 Vite dev server URL session_token: RwLock, // v3: UUID v4 세션 토큰 } + +// 임베딩 에셋 조회 함수 타입 +pub type AssetFetcher = Arc Option<(Vec, String)> + Send + Sync>; ``` 주요 API: @@ -375,7 +378,7 @@ v3 추가: | 키 UI + 노트 효과 | ✅ 지원 | | 통계/그래프 표시 | ✅ 지원 (v3: KPS 로컬 1초 슬라이딩 윈도우 계산) | | 키 카운터 | ✅ 지원 | -| HTTP 정적 서빙 | ✅ 지원 | +| HTTP 정적 서빙 | ✅ 지원 (Tauri 임베딩 에셋, 포터블 exe 호환) | | 레이아웃 동기화 | ✅ 지원 (snapshot 재전송) | | 커스텀 CSS | ✅ 지원 (v3: settings_diff 경유 실시간 주입) | | 배경 미디어 서빙 | ✅ 지원 (v3: /media/ 엔드포인트 + 토큰 검증) | @@ -475,6 +478,7 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 | 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ✅ | | 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ✅ | | 11 | **DataSource 호환성 레이어** | OverlayHost adapter로 Tauri API / WebSocket 통합 인터페이스 도입 (§12 참조) | ⚠️ computeLayout 추출 완료, adapter 설계 확정 / 구현 대기 | +| 12 | **포터블 exe 에셋 서빙** | static_dir 디스크 파일 → Tauri asset_resolver() 기반 AssetFetcher로 전환, 단일 exe 배포 지원 | ✅ | #### v3+ 이후 (P3) @@ -495,123 +499,254 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 --- -## 12. OverlayHost 호환성 레이어 설계 +## 12. Tauri IPC Shim 호환성 레이어 설계 -> 상태: **설계 확정** / 구현 대기 +> 상태: **설계 확정 (C 방식)** / 구현 대기 > P2 #11 상세 설계 ### 12.1 배경 및 목표 현재 overlay/App.tsx와 obs/App.tsx는 동일한 UI를 렌더링하면서 데이터 수신 방식만 다름: -- overlay: Tauri API (`invoke`, `listen`, window API) — 19+ 이벤트 구독, 12+ invoke, 8+ window API -- obs: WebSocket (`useObsWebSocket`) — snapshot/diff/key_event 수신 +- overlay: Tauri IPC (`invoke`, `listen`) → `window.api.*` → Zustand 스토어 → OverlayScene +- obs: WebSocket → `useOverlayRuntime` (중복 로직) → OverlayScene 이 구조의 문제: -1. **렌더링 로직 중복** — 키 상태 관리, KPS 계산, 딜레이 처리 등이 양쪽에 복제됨 +1. **렌더링 로직 중복** — 키 딜레이, KPS 계산, 레이아웃 등이 useOverlayRuntime에 복제됨 2. **유지보수 부담** — 기능 추가 시 overlay/obs 양쪽 모두 수정 필요 -3. **불일치 위험** — 한쪽만 수정하고 다른 쪽을 빠뜨릴 가능성 +3. **플러그인 미지원** — OBS에서 커스텀 JS/플러그인이 동작하지 않음 ### 12.2 검토한 접근 방식 -#### A. 현재 구조 유지 (분리형) +| 방식 | 설명 | overlay 변경 | 중복 제거 | 비고 | +|------|------|:---:|:---:|------| +| A. 분리형 유지 | 현재 구조 유지, computeLayout만 공유 | 없음 | 일부 | 변경 적을 때만 적합 | +| B. OverlayRuntime 통합 | TauriHost/WebSocketHost → useOverlayRuntime | 대규모 | 완전 | 기존 훅 해체 필요 | +| **C. Tauri IPC Shim** | `invoke`/`listen` 프리미티브를 WS로 교체 | **없음** | **완전** | **채택** | -``` -overlay/App.tsx ──→ Tauri API ──→ OverlayScene -obs/App.tsx ──→ WebSocket ──→ OverlayScene - (computeLayout 공유) -``` +### 12.3 최종 결정: C 방식 (Tauri IPC Shim) -- 장점: 단순, 각 환경에 최적화 가능 -- 단점: 렌더링 로직 중복, 기능 추가마다 양쪽 수정 -- 적합: 기능이 안정화되어 변경이 적을 때 +Tauri 프론트엔드 API는 모두 두 가지 프리미티브에 의존: +- `invoke(command, args)` → 요청/응답 (81개 커맨드) +- `listen(event, callback)` → 이벤트 구독 (25개 이벤트) -#### B. OverlayRuntime 공유 훅 +이 두 함수는 내부적으로 `window.__TAURI_INTERNALS__`를 호출함. +**OBS 진입점에서 이 글로벌을 WS 기반 shim으로 교체**하면, +overlay/App.tsx 및 모든 의존 훅(useAppBootstrap, keyEventBus 등)이 코드 변경 없이 동작. ``` -overlay/App.tsx ──→ TauriDataSource ──→ useOverlayRuntime() ──→ OverlayScene -obs/App.tsx ──→ WsDataSource ──→ useOverlayRuntime() ──→ OverlayScene +overlay 환경: + overlay/App.tsx → window.api.* → invoke/listen → Tauri IPC → Rust 백엔드 + +OBS 환경: + obs/App.tsx → initIpcShim() → overlay/App.tsx (동일 코드) + ↓ + window.__TAURI_INTERNALS__ = { invoke: wsInvoke, ... } + ↓ + invoke/listen → WebSocket → Rust 백엔드 (동일 서버) ``` -- 장점: 렌더링 로직 단일화, 데이터 소스만 교체 -- 단점: 추상화 인터페이스 설계/유지 비용 +장점: +- **overlay/App.tsx 변경 0** — 기존 훅, 스토어, 이벤트 버스 모두 그대로 +- **useOverlayRuntime.ts 제거** — 중복 로직 완전 해소 +- **shim 표면적 최소** — invoke + listen 2개만 교체 +- **향후 확장 자동 지원** — 새 window.api 메서드 추가 시 shim 수정 불필요 +- **플러그인 자연 지원** — dmn.* API가 invoke를 사용하므로 자동 호환 -#### C. Tauri API shim (폴리필) +### 12.4 IPC Shim 구현 +```typescript +// src/renderer/api/ipcShim.ts + +/** + * OBS 환경에서 Tauri IPC를 WebSocket으로 교체하는 shim. + * obs/index.tsx에서 앱 마운트 전에 호출. + */ + +// invoke shim: WS RPC (requestId 기반) +async function wsInvoke(command: string, args?: unknown): Promise { + // 1. no-op 커맨드 → 즉시 반환 (overlay_resize, overlay_set_visible 등) + // 2. 캐시 커맨드 → snapshot 데이터에서 반환 (app_bootstrap, settings_get 등) + // 3. RPC 커맨드 → WS invoke_request 전송 → invoke_response 대기 (향후) +} + +// listen shim: WS 메시지를 이벤트로 디스패치 +function wsListen(event: string, callback: Function): Promise<() => void> { + // WS 메시지 타입 → Tauri 이벤트명 매핑: + // key_event → 'keys:state' + // settings_diff → 'settings:changed' + // counter_update → 'keys:counters' + // snapshot → 모든 *:changed 이벤트 일괄 디스패치 +} + +export function initIpcShim(wsUrl: string, token: string): Promise { + // 1. WS 연결 + hello 핸드셰이크 + // 2. snapshot 수신 → 캐시 저장 + // 3. window.__TAURI_INTERNALS__ 설치 + // 4. 창 관리 API no-op 스텁 설치 (@tauri-apps/api/window, menu 등) +} ``` -overlay/App.tsx ──→ window.__TAURI__ (real) ──→ OverlayScene -obs/App.tsx ──→ window.__TAURI__ (shim) ──→ OverlayScene - (WS가 Tauri API를 흉내) + +### 12.5 WS ↔ Tauri 이벤트 매핑 + +#### invoke 매핑 (요청/응답) + +| invoke 커맨드 | OBS shim 처리 | +|---------------|---------------| +| `app_bootstrap` | snapshot 캐시에서 BootstrapPayload 형태로 변환 반환 | +| `settings_get` | snapshot.settings에서 반환 | +| `keys_get` / `positions_get` | snapshot에서 반환 | +| `css_get` / `css_get_use` / `css_tab_get_all` | snapshot에서 반환 | +| `note_tab_get_all` | snapshot에서 반환 | +| `stat_positions_get` / `graph_positions_get` | snapshot에서 반환 | +| `layer_groups_get` | snapshot에서 반환 | +| `stats_get` | 로컬 KPS 초기값 반환 | +| `overlay_resize` / `overlay_set_visible` | no-op | +| `settings_update` / `overlay_set_lock` | no-op (또는 향후 WS RPC) | +| `window_show_main` / `app_quit` | no-op | +| `plugin_storage_*` | 향후 WS RPC로 확장 | + +#### listen 매핑 (이벤트 구독) + +| Tauri 이벤트 | WS 메시지 소스 | 비고 | +|-------------|---------------|------| +| `keys:state` | `key_event` | keyEventBus가 구독 | +| `settings:changed` | `settings_diff` | useAppBootstrap이 구독 | +| `keys:counter` / `keys:counters` | `counter_update` | | +| `keys:changed` | `snapshot` 재전송 | snapshot 수신 시 디스패치 | +| `positions:changed` | `snapshot` 재전송 | | +| `statPositions:changed` | `snapshot` 재전송 | | +| `graphPositions:changed` | `snapshot` 재전송 | | +| `keys:mode-changed` | `snapshot` 재전송 | | +| `preset:snapshot` | `snapshot` 재전송 | | +| `css:use` / `css:content` | `settings_diff` | CSS 관련 필드 감지 시 | +| `tabCss:changed` | `settings_diff` | | +| `js:use` / `js:content` | `settings_diff` | 플러그인 지원 시 | +| `tabNote:changed` / `tabNote:changed_all` | `snapshot` 재전송 | | +| `customTabs:changed` | `snapshot` 재전송 | | +| `overlay:lock` / `overlay:anchor` | 미사용 (OBS에서 의미 없음) | | +| `plugin-bridge:message` | 향후 WS 확장 | 플러그인 브릿지 지원 시 | + +#### stats 구독 특수 처리 + +`window.api.stats.subscribe()`는 `listen`이 아닌 별도 메커니즘. +OBS에서는 기존 `useOverlayRuntime`의 로컬 KPS 슬라이딩 윈도우를 `keyStatsService` shim으로 이전. + +### 12.6 창 관리 API No-op 스텁 + +overlay/App.tsx가 직접 사용하는 non-IPC Tauri API: + +| 모듈 | API | No-op 처리 | +|------|-----|-----------| +| `@tauri-apps/api/window` | `getCurrentWindow()` | `startDragging()` 등 전부 no-op Promise 반환 | +| `@tauri-apps/api/window` | `currentMonitor()` | `null` 반환 | +| `@tauri-apps/api/window` | `Window.getByLabel()` | `null` 반환 | +| `@tauri-apps/api/dpi` | `LogicalPosition`, `PhysicalPosition` | 빈 클래스 | +| `@tauri-apps/api/menu` | `Menu.new()`, `menu.popup()` | no-op Promise | +| `@tauri-apps/api/core` | `convertFileSrc()` | OBS HTTP `/media/` 경로로 변환 | + +구현 방식: Vite alias 또는 obs 진입점에서 모듈 모킹 + +### 12.7 obs/App.tsx 최종 형태 + +```tsx +// src/renderer/windows/obs/App.tsx +import { useEffect, useState } from 'react'; +import { initIpcShim, disposeIpcShim } from '@api/ipcShim'; + +// shim 설치 후 overlay App을 동적 임포트 +const App = () => { + const [OverlayApp, setOverlayApp] = useState(null); + + useEffect(() => { + const { host, port, token } = parseUrlParams(); + initIpcShim(`ws://${host}:${port}`, token).then(async () => { + const { default: Overlay } = await import('@windows/overlay/App'); + setOverlayApp(() => Overlay); + }); + return () => disposeIpcShim(); + }, []); + + if (!OverlayApp) return
Connecting...
; + return ; +}; ``` -- 장점: overlay 코드 변경 최소화 -- 단점: Tauri API의 모든 시그니처를 정확히 흉내내야 함, edge case 많음 +### 12.8 구현 단계 -### 12.3 최종 결정: B 방식 (OverlayHost adapter) +| 단계 | 작업 | 파일 | +|------|------|------| +| 1 | **IPC shim 인프라** — WS 연결, invoke/listen 기본 구조 | 신설: `api/ipcShim.ts` | +| 2 | **invoke shim** — snapshot 캐시 기반 반환 + no-op 매핑 | `api/ipcShim.ts` | +| 3 | **listen shim** — WS 메시지 → Tauri 이벤트 디스패치 | `api/ipcShim.ts` | +| 4 | **창 관리 no-op** — window/menu/dpi 모킹 | Vite alias 또는 `api/ipcShim.ts` | +| 5 | **obs/App.tsx 재작성** — shim 초기화 + overlay App 임포트 | `windows/obs/App.tsx` | +| 6 | **검증** — dev 모드에서 OBS 페이지 동작 확인 | | +| 7 | **정리** — useOverlayRuntime.ts 제거, useObsWebSocket.ts 제거/내부 흡수 | | -**"B with C's philosophy"** — 새 인터페이스를 정의하되, overlay가 실제 사용하는 기능만 포함. +### 12.9 서버 확장 (향후) -B와 C는 개념적으로 수렴하지만, B 형태(새 인터페이스)가 구현이 깔끔함: -- Tauri API 시그니처를 완벽히 흉내내는 것보다, 필요한 기능만 정의하는 것이 안전 -- 마이그레이션은 한 번이지만 이후 유지보수가 단순화됨 +현재 WS 프로토콜로 대부분의 overlay 기능이 동작하지만, +플러그인 `dmn.*` API의 쓰기 연산을 위해 WS RPC 프로토콜 추가 필요: -### 12.4 OverlayHost 인터페이스 +``` +C→S: { type: "invoke_request", requestId: "uuid", command: "plugin_storage_get", args: {...} } +S→C: { type: "invoke_response", requestId: "uuid", result: {...} } +``` -```typescript -// src/renderer/hosts/types.ts +- 화이트리스트 기반 커맨드 허용 (보안) +- requestId + pending map + timeout (30초) +- 단계 1~6 완료 후 별도 구현 -interface OverlayHostCallbacks { - onSnapshot: (payload: BootstrapPayload) => void; - onKeyEvent: (payload: KeyEventPayload) => void; - onSettingsDiff: (diff: Record) => void; - onCounterUpdate: (data: Record) => void; -} +### 12.10 리스크 및 제약 -interface OverlayHost { - /** 호스트 타입 식별 */ - type: 'tauri' | 'websocket'; +| 리스크 | 심각도 | 대응 | +|--------|--------|------| +| `window.__TAURI_INTERNALS__` 내부 API 변경 | 중 | Tauri 버전 고정 + 업그레이드 시 shim 검증 | +| snapshot → BootstrapPayload 변환 불일치 | 중 | 타입 검증 + 단계별 테스트 | +| overlay 전용 API 누락으로 런타임 에러 | 낮 | try/catch 가드 + no-op 폴백 | +| stats 구독 메커니즘 차이 | 낮 | 로컬 KPS 계산 유지 (기존 검증됨) | - /** 데이터 구독 시작 (콜백 등록) */ - subscribe(callbacks: OverlayHostCallbacks): () => void; // unsubscribe 반환 +--- - /** 설정 변경 요청 (overlay에서 설정 UI 조작 시) */ - invoke?(command: string, args?: unknown): Promise; +## 13. 남은 작업 우선순위 (2026-03-08 기준) - /** 창 제어 (overlay 전용, OBS에서는 no-op) */ - window?: { - startDragging(): void; - setIgnoreCursorEvents(ignore: boolean): void; - // ... overlay에서 사용하는 window API만 포함 - }; -} -``` +> v3 P1/P2 대부분 완료. 아래는 미완료 항목을 우선순위별로 정리. -### 12.5 구현 계획 +### Tier 1 — Tauri IPC Shim (§12, 다음 작업) -``` -src/renderer/ -├── hosts/ -│ ├── types.ts ← OverlayHost 인터페이스 정의 -│ ├── TauriHost.ts ← Tauri API 기반 구현 -│ └── WebSocketHost.ts ← WebSocket 기반 구현 -├── hooks/shared/ -│ └── useOverlayRuntime.ts ← 공용 렌더링 로직 (키 상태, KPS, 딜레이 등) -└── windows/ - ├── overlay/App.tsx ← TauriHost + useOverlayRuntime + OverlayScene - └── obs/App.tsx ← WebSocketHost + useOverlayRuntime + OverlayScene -``` +| # | 작업 | 예상 규모 | 비고 | +|---|------|-----------|------| +| 1 | **IPC shim 인프라 + invoke/listen 구현** | `api/ipcShim.ts` 1파일 | WS 연결 + snapshot 캐시 + invoke no-op/캐시 반환 + listen → WS 메시지 디스패치 | +| 2 | **창 관리 API no-op 스텁** | Vite alias 또는 shim 내부 | window/menu/dpi 모킹 | +| 3 | **obs/App.tsx 재작성** | 기존 33줄 → shim 초기화 + overlay App 임포트 | | +| 4 | **검증 + 정리** | useOverlayRuntime.ts, useObsWebSocket.ts 제거 | | -단계: -1. `OverlayHost` 인터페이스 + `WebSocketHost` 구현 (기존 useObsWebSocket 리팩토링) -2. `useOverlayRuntime` 훅 추출 (obs/App.tsx에서 렌더링 로직 분리) -3. `TauriHost` 구현 (overlay/App.tsx에서 Tauri API 호출 래핑) -4. overlay/App.tsx를 `TauriHost + useOverlayRuntime` 으로 마이그레이션 -5. 검증: 양쪽 동작 확인 후 기존 중복 코드 제거 +구현 결과: +- overlay/App.tsx **코드 변경 0** +- obs/App.tsx가 overlay/App.tsx와 **동일 코드** 실행 +- 중복 로직 **완전 해소** (useOverlayRuntime 417줄 제거) -### 12.6 범위 제한 +### Tier 2 — 플러그인 OBS 지원 (§12 완료 후) -현재 P2 범위에서는 다음만 포함: -- **포함**: 키 이벤트, 설정 동기화, 카운터, KPS, 레이아웃, CSS -- **제외**: 플러그인 엘리먼트 (P3), 커스텀 JS (P3), 창 드래그/리사이즈 (overlay 전용) +| # | 작업 | 설명 | 비고 | +|---|------|------|------| +| 5 | **WS invoke RPC 프로토콜** | invoke_request/response + 화이트리스트 | IPC shim이 RPC 커맨드를 실제 WS로 전송 | +| 6 | **플러그인 엘리먼트** | snapshot에 pluginElements 포함 | shim이 자동 처리 (코드 변경 없음) | +| 7 | **커스텀 JS** | js:use/js:content 이벤트 + plugin_storage_* RPC | #5 의존 | + +### Tier 3 — 알려진 이슈 (낮은 우선순위) + +| # | 이슈 | 증상 | 추정 원인 | +|---|------|------|-----------| +| 8 | **초기 접속 시 빈 화면** | 브라우저 최초 접속 시 키 UI가 보이지 않다가, 키 위치를 한 번 변경하면 전부 표시됨 | snapshot → bootstrap 변환 시 레이아웃 계산 타이밍. IPC shim 도입 시 자연 해소 가능성 있음 | -overlay 전용 기능(창 제어, 컨텍스트 메뉴 등)은 `OverlayHost.window?`로 분리하여 -OBS 환경에서는 자연스럽게 비활성화됨. +### 완료된 주요 마일스톤 + +``` +v1: WS 서버 + OBS 페이지 기본 동작 +v2: HTTP+WS 통합 서빙, layout_diff, cached_snapshot 증분 갱신 +v3 P1: 설정 영속화, 오버레이 연동, KPS 로컬 계산, UI 안내 +v3 P2: 커스텀 CSS, 배경 미디어, keyDisplayDelayMs, 키별 노트 효과, + 보안 토큰, dev 모드 서빙, 포터블 exe AssetFetcher +v4 (예정): Tauri IPC Shim → overlay 코드 재사용 + useOverlayRuntime 제거 +``` From dac007a2194051c1bed62b6f9a9cd2b0a0e12d99 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 11:54:02 +0900 Subject: [PATCH 31/56] =?UTF-8?q?feat:=20IPC=20Shim=20=EC=9E=AC=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=E2=80=94=20denyList=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0,=20tauri=5Fevent,=20convertFileSrc=20base64u?= =?UTF-8?q?rl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §12 설계 기반으로 ipcShim.ts를 재작성: - 하드코딩 NO_OP_COMMANDS → hello_ack에서 수신하는 denyList로 교체 - isDenied() 함수: | suffix → prefix 매칭, 아니면 exact 매칭 - tauri_event WS 메시지 수신 처리 추가 - convertFileSrc → OBS HTTP /media/ base64url(no-pad) 인코딩 - disposeIpcShim → pending RPC reject 처리 - WS 끊긴 상태에서 RPC 즉시 reject - 구 버전 백엔드 호환: denyList 미수신 시 DEFAULT_DENY_LIST 폴백 - obs.ts: HelloAckPayload에 denyList, ObsMessageType에 invoke_request/invoke_response/tauri_event 추가 - 설계 문서(§12) deny 리스트 일원화 반영 Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 532 +++++++++++++++++++------- src/renderer/api/ipcShim.ts | 581 +++++++++++++++++++++++++++++ src/renderer/windows/obs/index.tsx | 32 +- src/types/obs.ts | 6 +- 4 files changed, 1022 insertions(+), 129 deletions(-) create mode 100644 src/renderer/api/ipcShim.ts diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index 79823cfd..abcd3960 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -552,159 +552,437 @@ OBS 환경: - **향후 확장 자동 지원** — 새 window.api 메서드 추가 시 shim 수정 불필요 - **플러그인 자연 지원** — dmn.* API가 invoke를 사용하므로 자동 호환 +#### 재사용성 원칙 + +이 호환성 레이어는 **DmNote에 종속되지 않는 범용 구조**로 설계: + +| 계층 | 역할 | 프로젝트 종속 여부 | +|------|------|:---:| +| 프론트 IPC shim | `__TAURI_INTERNALS__` → WS RPC 교체 | **범용** | +| 프론트 이벤트 시스템 | 콜백 레지스트리 + `tauri_event` 디스패치 | **범용** | +| 프론트 deny 체크 | `hello_ack`에서 수신한 단일 배열로 동적 구성 | **백엔드에서 수신 (관리 불필요)** | +| 백엔드 WS RPC | `invoke_request` → `webview.on_message()` 자동 디스패치 | **범용** | +| 백엔드 deny 리스트 | OBS에서 의미 없는 커맨드 차단 | **프로젝트별 설정 (유일한 관리 포인트)** | +| 백엔드 이벤트 포워딩 | Tauri emit → `tauri_event` WS 브로드캐스트 | **범용** | + +프로젝트별로 관리하는 것은 **deny 리스트 하나뿐** — **백엔드 Rust 코드에서 1곳만 관리**. +프론트엔드는 WS handshake(`hello_ack`)에서 deny 리스트를 수신하여 No-op Set을 동적 구성. +나머지 인프라는 어떤 Tauri 앱이든 그대로 이식 가능. + ### 12.4 IPC Shim 구현 +#### 설계 원칙 + +shim은 **커맨드별 분기를 하지 않는다**. 모든 invoke 호출은 다음 3단계로만 처리: + +1. **이벤트 플러그인** (`plugin:event|*`) → 로컬 콜백 레지스트리에서 처리 +2. **No-op** (OBS에서 의미 없는 창 관리 커맨드) → 즉시 반환 +3. **WS RPC** → 백엔드에 전달, 실제 커맨드 핸들러가 처리 + ```typescript // src/renderer/api/ipcShim.ts -/** - * OBS 환경에서 Tauri IPC를 WebSocket으로 교체하는 shim. - * obs/index.tsx에서 앱 마운트 전에 호출. - */ +// deny 리스트는 하드코딩 아님 — WS handshake(hello_ack)에서 수신 +let denyList: string[] = []; -// invoke shim: WS RPC (requestId 기반) -async function wsInvoke(command: string, args?: unknown): Promise { - // 1. no-op 커맨드 → 즉시 반환 (overlay_resize, overlay_set_visible 등) - // 2. 캐시 커맨드 → snapshot 데이터에서 반환 (app_bootstrap, settings_get 등) - // 3. RPC 커맨드 → WS invoke_request 전송 → invoke_response 대기 (향후) +async function shimInvoke(cmd: string, args?: Record): Promise { + // 1. 이벤트 플러그인 (프론트엔드 로컬) + if (cmd === 'plugin:event|listen') return handleEventListen(args); + if (cmd === 'plugin:event|unlisten') { handleEventUnlisten(args); return; } + if (cmd === 'plugin:event|emit') { handleEventEmit(args); return; } + + // 2. deny 체크 (백엔드에서 수신한 단일 리스트) + if (isDenied(cmd)) return; + + // 3. 나머지 전부 → WS RPC (백엔드가 실제 처리) + return wsRpc(cmd, args); } -// listen shim: WS 메시지를 이벤트로 디스패치 -function wsListen(event: string, callback: Function): Promise<() => void> { - // WS 메시지 타입 → Tauri 이벤트명 매핑: - // key_event → 'keys:state' - // settings_diff → 'settings:changed' - // counter_update → 'keys:counters' - // snapshot → 모든 *:changed 이벤트 일괄 디스패치 +// "|"로 끝나면 prefix 매칭, 아니면 exact 매칭 +function isDenied(cmd: string): boolean { + return denyList.some(entry => + entry.endsWith('|') ? cmd.startsWith(entry) : cmd === entry + ); } +``` + +**커맨드 추가 시 shim 수정 불필요** — 백엔드에 커맨드가 있으면 자동으로 동작. -export function initIpcShim(wsUrl: string, token: string): Promise { - // 1. WS 연결 + hello 핸드셰이크 - // 2. snapshot 수신 → 캐시 저장 - // 3. window.__TAURI_INTERNALS__ 설치 - // 4. 창 관리 API no-op 스텁 설치 (@tauri-apps/api/window, menu 등) +#### deny 리스트 일원화 + +**백엔드가 유일한 source of truth**. 단일 배열 하나로 exact + prefix 매칭 통합. +`|`로 끝나는 항목은 prefix 매칭, 아니면 exact 매칭. +프론트엔드는 WS handshake에서 수신: + +```json +// hello_ack 응답에 deny 리스트 포함 +{ + "type": "hello_ack", + "payload": { + "serverVersion": "1.5.2", + "obsMode": true, + "denyList": [ + "overlay_resize", "overlay_set_visible", "overlay_set_lock", + "overlay_set_anchor", "overlay_get", + "window_minimize", "window_close", "window_show_main", + "window_open_devtools_all", + "app_quit", "app_restart", "app_open_external", "app_auto_update", + "plugin:window|", "plugin:menu|", "plugin:resources|" + ] + } } ``` +프론트 shim은 `hello_ack` 수신 시 `denyList`를 그대로 저장: + +```typescript +function onHelloAck(payload: HelloAckPayload) { + denyList = payload.denyList ?? []; +} +``` + +이 구조의 장점: +- **관리 포인트 1곳** — Rust 코드의 `DENIED_WS_COMMANDS` 배열 하나만 수정 +- **단일 배열** — exact/prefix 구분 없이 하나의 리스트로 통합 (`|` suffix 컨벤션) +- **빌드 의존성 없음** — codegen이나 공유 JSON 파일 불필요 +- **런타임 동기화** — 백엔드 버전이 올라가도 프론트 shim 재빌드 필요 없음 + +#### deny 커맨드 목록 (참고 — Rust에서만 관리) + +| 항목 | 매칭 | 이유 | +|------|------|------| +| `overlay_resize`, `overlay_set_visible` 등 | exact | Tauri 윈도우 조작 | +| `window_minimize`, `window_close` 등 | exact | 네이티브 윈도우 제어 | +| `app_quit`, `app_restart` 등 | exact | 앱 생명주기 | +| `plugin:window\|` | prefix | Tauri window 플러그인 전체 | +| `plugin:menu\|` | prefix | Tauri menu 플러그인 전체 | +| `plugin:resources\|` | prefix | Tauri resources 플러그인 전체 | + +`raw_input_subscribe` 등 **백엔드 기능이 필요한 커맨드**는 deny가 아닌 WS RPC로 처리. + ### 12.5 WS ↔ Tauri 이벤트 매핑 -#### invoke 매핑 (요청/응답) - -| invoke 커맨드 | OBS shim 처리 | -|---------------|---------------| -| `app_bootstrap` | snapshot 캐시에서 BootstrapPayload 형태로 변환 반환 | -| `settings_get` | snapshot.settings에서 반환 | -| `keys_get` / `positions_get` | snapshot에서 반환 | -| `css_get` / `css_get_use` / `css_tab_get_all` | snapshot에서 반환 | -| `note_tab_get_all` | snapshot에서 반환 | -| `stat_positions_get` / `graph_positions_get` | snapshot에서 반환 | -| `layer_groups_get` | snapshot에서 반환 | -| `stats_get` | 로컬 KPS 초기값 반환 | -| `overlay_resize` / `overlay_set_visible` | no-op | -| `settings_update` / `overlay_set_lock` | no-op (또는 향후 WS RPC) | -| `window_show_main` / `app_quit` | no-op | -| `plugin_storage_*` | 향후 WS RPC로 확장 | - -#### listen 매핑 (이벤트 구독) - -| Tauri 이벤트 | WS 메시지 소스 | 비고 | -|-------------|---------------|------| -| `keys:state` | `key_event` | keyEventBus가 구독 | -| `settings:changed` | `settings_diff` | useAppBootstrap이 구독 | -| `keys:counter` / `keys:counters` | `counter_update` | | -| `keys:changed` | `snapshot` 재전송 | snapshot 수신 시 디스패치 | -| `positions:changed` | `snapshot` 재전송 | | -| `statPositions:changed` | `snapshot` 재전송 | | -| `graphPositions:changed` | `snapshot` 재전송 | | -| `keys:mode-changed` | `snapshot` 재전송 | | -| `preset:snapshot` | `snapshot` 재전송 | | -| `css:use` / `css:content` | `settings_diff` | CSS 관련 필드 감지 시 | -| `tabCss:changed` | `settings_diff` | | -| `js:use` / `js:content` | `settings_diff` | 플러그인 지원 시 | -| `tabNote:changed` / `tabNote:changed_all` | `snapshot` 재전송 | | -| `customTabs:changed` | `snapshot` 재전송 | | -| `overlay:lock` / `overlay:anchor` | 미사용 (OBS에서 의미 없음) | | -| `plugin-bridge:message` | 향후 WS 확장 | 플러그인 브릿지 지원 시 | - -#### stats 구독 특수 처리 - -`window.api.stats.subscribe()`는 `listen`이 아닌 별도 메커니즘. -OBS에서는 기존 `useOverlayRuntime`의 로컬 KPS 슬라이딩 윈도우를 `keyStatsService` shim으로 이전. +#### invoke (요청/응답) — 백엔드 WS RPC + +shim에서는 커맨드를 구분하지 않고 전부 WS RPC로 전달. +백엔드 WS 서버가 실제 커맨드 핸들러를 호출하여 응답 (§12.11 참조). + +``` +프론트엔드 백엔드 +shimInvoke('settings_get') + → WS: { type: "invoke_request", requestId, command: "settings_get", args } + → settings_get() 핸들러 호출 + ← WS: { type: "invoke_response", requestId, result: {...} } + ← resolve(result) +``` + +#### listen (이벤트 구독) — WS 메시지 → Tauri 이벤트 디스패치 + +WS 브로드캐스트 메시지를 수신하면 Tauri 이벤트명으로 변환하여 등록된 리스너에 디스패치: + +| WS 메시지 타입 | → Tauri 이벤트 | 비고 | +|---------------|---------------|------| +| `key_event` | `keys:state` | keyEventBus 구독 | +| `settings_diff` | `settings:changed` | `{ changed: patch }` 래핑 | +| `counter_update` | `keys:counters` | 전체 카운터 | +| `snapshot` | `keys:changed`, `positions:changed`, `settings:changed` 등 | 다수 이벤트 일괄 디스패치 | +| `tauri_event` | 이벤트명 그대로 | 범용 이벤트 포워딩 (§12.12) | + +#### stats 구독 + +`keyStatsService`가 `listen('keys:state')` + `invoke('app_bootstrap')`을 사용. +shim이 설치되면 자동으로 WS 경유 동작 — 별도 처리 불필요. ### 12.6 창 관리 API No-op 스텁 -overlay/App.tsx가 직접 사용하는 non-IPC Tauri API: +overlay/App.tsx가 `@tauri-apps/api/window`, `@tauri-apps/api/menu` 등을 직접 import. +이 모듈들은 내부적으로 `invoke('plugin:window|...', ...)` 형태로 호출. + +백엔드 deny 리스트의 `denyPrefixes`에 `plugin:window|`, `plugin:menu|` 등이 포함되어 +shim이 handshake 시 수신한 prefix 매칭으로 자동 no-op 처리 — 별도 모듈 모킹 불필요. -| 모듈 | API | No-op 처리 | -|------|-----|-----------| -| `@tauri-apps/api/window` | `getCurrentWindow()` | `startDragging()` 등 전부 no-op Promise 반환 | -| `@tauri-apps/api/window` | `currentMonitor()` | `null` 반환 | -| `@tauri-apps/api/window` | `Window.getByLabel()` | `null` 반환 | -| `@tauri-apps/api/dpi` | `LogicalPosition`, `PhysicalPosition` | 빈 클래스 | -| `@tauri-apps/api/menu` | `Menu.new()`, `menu.popup()` | no-op Promise | -| `@tauri-apps/api/core` | `convertFileSrc()` | OBS HTTP `/media/` 경로로 변환 | +`convertFileSrc()`는 `__TAURI_INTERNALS__.convertFileSrc`에 설치되므로 shim에서 직접 제공. +OBS HTTP 서버의 `/media/?token=...` 경로로 변환: -구현 방식: Vite alias 또는 obs 진입점에서 모듈 모킹 +```typescript +function shimConvertFileSrc(filePath: string): string { + const encoded = btoa(filePath); + return `http://${host}:${port}/media/${encoded}?token=${sessionToken}`; +} +``` -### 12.7 obs/App.tsx 최종 형태 +### 12.7 obs/index.tsx 진입점 ```tsx -// src/renderer/windows/obs/App.tsx -import { useEffect, useState } from 'react'; +// src/renderer/windows/obs/index.tsx import { initIpcShim, disposeIpcShim } from '@api/ipcShim'; -// shim 설치 후 overlay App을 동적 임포트 -const App = () => { - const [OverlayApp, setOverlayApp] = useState(null); +async function bootstrap() { + const params = new URLSearchParams(window.location.search); + const host = params.get('host') || window.location.hostname || '127.0.0.1'; + const port = params.get('port') || window.location.port || '34891'; + const token = params.get('token') || ''; + const wsUrl = `ws://${host}:${port}`; - useEffect(() => { - const { host, port, token } = parseUrlParams(); - initIpcShim(`ws://${host}:${port}`, token).then(async () => { - const { default: Overlay } = await import('@windows/overlay/App'); - setOverlayApp(() => Overlay); - }); - return () => disposeIpcShim(); - }, []); + await initIpcShim(wsUrl, token); + await import('@api/dmnoteApi'); + window.__dmn_window_type = 'overlay'; - if (!OverlayApp) return
Connecting...
; - return ; -}; + const { I18nProvider } = await import('@contexts/I18nContext'); + const { default: App } = await import('@windows/overlay/App'); + // render +} ``` ### 12.8 구현 단계 -| 단계 | 작업 | 파일 | +| 단계 | 작업 | 영역 | |------|------|------| -| 1 | **IPC shim 인프라** — WS 연결, invoke/listen 기본 구조 | 신설: `api/ipcShim.ts` | -| 2 | **invoke shim** — snapshot 캐시 기반 반환 + no-op 매핑 | `api/ipcShim.ts` | -| 3 | **listen shim** — WS 메시지 → Tauri 이벤트 디스패치 | `api/ipcShim.ts` | -| 4 | **창 관리 no-op** — window/menu/dpi 모킹 | Vite alias 또는 `api/ipcShim.ts` | -| 5 | **obs/App.tsx 재작성** — shim 초기화 + overlay App 임포트 | `windows/obs/App.tsx` | -| 6 | **검증** — dev 모드에서 OBS 페이지 동작 확인 | | -| 7 | **정리** — useOverlayRuntime.ts 제거, useObsWebSocket.ts 제거/내부 흡수 | | +| 1 | **프론트 IPC shim** — WS 연결, `__TAURI_INTERNALS__` 설치, No-op, WS RPC | `api/ipcShim.ts` | +| 2 | **백엔드 WS RPC 핸들러** — `invoke_request` 수신 → 커맨드 라우팅 → `invoke_response` (§12.11) | `obs_bridge.rs` | +| 3 | **백엔드 이벤트 포워딩** — Tauri 이벤트를 `tauri_event` WS 메시지로 포워딩 (§12.12) | `obs_bridge.rs` | +| 4 | **snapshot 필드 보강** — `layerGroups`, `tabNoteOverrides`, `tabCssOverrides` 추가 | `app_state.rs`, `mod.rs` | +| 5 | **obs/index.tsx 재작성** — shim 초기화 + overlay/App 동적 임포트 | `windows/obs/index.tsx` | +| 6 | **convertFileSrc 수정** — OBS HTTP `/media/` 경로 매핑 | `api/ipcShim.ts` | +| 7 | **검증 + 정리** — useOverlayRuntime.ts, useObsWebSocket.ts 제거 | | -### 12.9 서버 확장 (향후) +### 12.9 WS 프로토콜 확장 -현재 WS 프로토콜로 대부분의 overlay 기능이 동작하지만, -플러그인 `dmn.*` API의 쓰기 연산을 위해 WS RPC 프로토콜 추가 필요: +#### 신규 메시지 타입 -``` -C→S: { type: "invoke_request", requestId: "uuid", command: "plugin_storage_get", args: {...} } -S→C: { type: "invoke_response", requestId: "uuid", result: {...} } +| 방향 | 타입 | 용도 | +|------|------|------| +| C→S | `invoke_request` | 커맨드 실행 요청 | +| S→C | `invoke_response` | 커맨드 실행 결과 | +| S→C | `tauri_event` | 범용 Tauri 이벤트 포워딩 | + +#### invoke_request / invoke_response + +```json +// C→S +{ "v": 1, "type": "invoke_request", "seq": 42, + "payload": { "requestId": "rpc_xxx", "command": "settings_get", "args": {} } } + +// S→C +{ "v": 1, "type": "invoke_response", "seq": 43, + "payload": { "requestId": "rpc_xxx", "result": { ... } } } +// 에러 시 +{ "v": 1, "type": "invoke_response", "seq": 43, + "payload": { "requestId": "rpc_xxx", "error": "Not found" } } ``` -- 화이트리스트 기반 커맨드 허용 (보안) -- requestId + pending map + timeout (30초) -- 단계 1~6 완료 후 별도 구현 +#### tauri_event (범용 이벤트 포워딩) + +```json +// S→C — 백엔드의 모든 Tauri emit을 WS로 전달 +{ "v": 1, "type": "tauri_event", "seq": 44, + "payload": { "event": "keys:counter", "data": { "mode": "4key", "key": "A", "count": 42 } } } +``` ### 12.10 리스크 및 제약 | 리스크 | 심각도 | 대응 | |--------|--------|------| | `window.__TAURI_INTERNALS__` 내부 API 변경 | 중 | Tauri 버전 고정 + 업그레이드 시 shim 검증 | -| snapshot → BootstrapPayload 변환 불일치 | 중 | 타입 검증 + 단계별 테스트 | -| overlay 전용 API 누락으로 런타임 에러 | 낮 | try/catch 가드 + no-op 폴백 | -| stats 구독 메커니즘 차이 | 낮 | 로컬 KPS 계산 유지 (기존 검증됨) | +| WS RPC 보안 (임의 커맨드 실행) | 중 | deny 리스트 + Tauri ACL 재사용 + 세션 토큰 검증 | +| `InvokeRequest` API 안정성 | 중 | Tauri 2.x 내 변경 가능성 낮음, 업그레이드 시 한 곳만 수정 | +| overlay 전용 API 누락으로 런타임 에러 | 낮 | No-op prefix 매칭 (`plugin:window|*`) + try/catch 가드 | +| WS RPC 지연 (localhost) | 낮 | <1ms, 체감 불가 | +| pendingRpc dispose 시 미해결 Promise | 낮 | dispose 시 모든 pending을 reject 처리 | + +### 12.11 백엔드 WS RPC 핸들러 + +OBS 브라우저에서 `invoke(cmd, args)`가 호출되면 shim이 WS `invoke_request`로 전달. +백엔드 WS 서버가 이를 **Tauri의 기존 커맨드 파이프라인에 주입**하여 자동 처리. + +#### 핵심: `Webview::on_message(InvokeRequest)` 활용 + +Tauri v2는 `WebviewWindow::on_message(request, responder)` API를 제공. +이를 통해 WS 요청을 "가짜 IPC"로 주입하면 **수동 커맨드 라우팅 없이** 기존 `#[tauri::command]` 파이프라인을 그대로 탈 수 있음. + +```rust +// obs_bridge.rs — WS invoke_request 핸들러 +async fn handle_invoke_request( + app: &AppHandle, + request_id: &str, + command: &str, + args: Value, + ws_tx: &WsSender, +) { + // 1. deny 리스트 체크 (= 프론트 No-op 리스트와 동일) + if DENIED_WS_COMMANDS.contains(&command) { + ws_tx.send(invoke_response_error(request_id, "Command not allowed")); + return; + } + + // 2. Tauri 파이프라인에 주입 — match문 없음 + let webview = app.get_webview_window("main").unwrap(); + let request = InvokeRequest { + cmd: command.to_string(), + body: InvokeBody::Json(args), + headers: Default::default(), + url: webview.url().unwrap(), // ACL 검증용 + invoke_key: app.invoke_key().to_string(), + }; + + let request_id = request_id.to_string(); + let tx = ws_tx.clone(); + webview.on_message(request, Box::new(move |_webview, _cmd, response, _cb, _err| { + // 3. Tauri 응답을 WS invoke_response로 변환 + tx.send(invoke_response(&request_id, response)); + })); +} +``` + +#### deny 리스트 (유일한 source of truth) + +**이 배열 하나가 프론트/백엔드 양쪽의 유일한 관리 포인트**. +`|`로 끝나는 항목은 prefix 매칭, 아니면 exact 매칭 — 프론트/백엔드 동일 규칙. +WS handshake 시 `hello_ack`에 포함하여 프론트엔드에 전달 (§12.4 참조). + +```rust +// obs_bridge.rs — 유일한 deny 리스트 정의 (단일 배열) +const DENIED_WS_COMMANDS: &[&str] = &[ + // exact 매칭 + "overlay_resize", "overlay_set_visible", "overlay_set_lock", + "overlay_set_anchor", "overlay_get", + "window_minimize", "window_close", "window_show_main", + "window_open_devtools_all", + "app_quit", "app_restart", "app_open_external", "app_auto_update", + // prefix 매칭 ("|"로 끝남) + "plugin:window|", "plugin:menu|", "plugin:resources|", +]; + +fn is_denied(cmd: &str) -> bool { + DENIED_WS_COMMANDS.iter().any(|entry| { + if entry.ends_with('|') { cmd.starts_with(entry) } + else { cmd == *entry } + }) +} +``` + +```rust +// hello_ack 전송 시 deny 리스트 포함 +fn build_hello_ack(&self) -> Value { + json!({ + "serverVersion": self.server_version, + "obsMode": true, + "denyList": DENIED_WS_COMMANDS, + }) +} +``` + +deny에 없는 커맨드는 **자동으로 Tauri가 처리** — 새 커맨드 추가 시 양쪽 모두 수정 불필요. +Tauri의 ACL 시스템이 보안 경계 역할을 하므로 별도 화이트리스트 불필요. + +#### 장점 + +- **match문 완전 제거** — 커맨드별 분기 없음 +- **인자 역직렬화 자동** — Tauri의 `#[tauri::command]` 매크로가 처리 +- **ACL 재사용** — Tauri permissions 시스템이 보안 검증 +- **새 커맨드 자동 지원** — `#[tauri::command]` 추가하면 WS에서도 즉시 동작 +- **관리 포인트 1개** — Rust deny 리스트만 수정하면 `hello_ack`로 프론트에 자동 전파 + +#### 제약 + +- `InvokeRequest`는 Tauri에서 stable API로 보장하지 않음 — Tauri 메이저 업그레이드 시 검증 필요 +- `webview.on_message()` 호출에 기존 Webview 인스턴스 필요 (main window 사용) +- `invoke_key`는 내부 보안 키이므로 외부 노출 금지 + +### 12.12 백엔드 이벤트 포워딩 + +현재 WS 서버는 `key_event`, `settings_diff`, `counter_update`, `snapshot`만 전송. +IPC shim이 모든 이벤트를 `listen()`할 수 있으려면 백엔드가 **Tauri 이벤트를 WS로 포워딩**해야 함. + +#### 접근 방식: `tauri_event` 범용 메시지 + +백엔드에서 Tauri 이벤트가 emit될 때 WS 클라이언트에도 전달: + +```rust +// app_state.rs — 이벤트 emit 시 WS도 함께 전송 +fn emit_and_forward(&self, event: &str, payload: &impl Serialize) { + // 1. 기존: Tauri 윈도우로 emit + self.app_handle.emit(event, payload).ok(); + + // 2. 신규: OBS WS 클라이언트로 포워딩 + if let Some(bridge) = &self.obs_bridge { + bridge.forward_tauri_event(event, payload); + } +} +``` + +```rust +// obs_bridge.rs +pub fn forward_tauri_event(&self, event: &str, payload: &impl Serialize) { + let envelope = ObsEnvelope::tauri_event(event, serde_json::to_value(payload).unwrap()); + self.broadcast(envelope); +} +``` + +#### 포워딩 대상 이벤트 + +| 이벤트 | 소비자 | 기존 WS 대체 | +|--------|--------|-------------| +| `keys:state` | keyEventBus, keyStatsService | 기존 `key_event` → `tauri_event`로 통합 가능 | +| `keys:counter` | keyStatsService (total 실시간 갱신) | **신규** — 현재 누락 | +| `keys:counters` | useAppBootstrap | 기존 `counter_update` | +| `settings:changed` | useAppBootstrap | 기존 `settings_diff` | +| `keys:changed` | useAppBootstrap | 기존 `snapshot` 내 | +| `positions:changed` | useAppBootstrap | 기존 `snapshot` 내 | +| `css:use`, `css:content` | useCustomCssInjection | **신규** — 현재 누락 | +| `tabCss:changed` | useCustomCssInjection | **신규** — 현재 누락 | +| `js:use`, `js:content` | customJsRuntime | **신규** — 현재 누락 | +| `input:raw` | rawKeyEventBus (플러그인) | **신규** — raw_input_subscribe 시 | +| `plugin-bridge:message` | PluginElementsRenderer | **신규** — 플러그인 지원 시 | + +#### 전환 전략 + +기존 전용 WS 메시지(`key_event`, `settings_diff`, `counter_update`)를 즉시 제거하면 하위 호환 깨짐. +단계적 전환: + +1. **1단계**: `tauri_event` 포워딩 추가 (신규 이벤트만: `keys:counter`, `css:*`, `js:*`, `input:raw`) +2. **2단계**: shim의 `onWsMessage`에서 기존 메시지 타입 처리 유지 + `tauri_event` 처리 추가 +3. **3단계** (선택): 기존 전용 메시지를 `tauri_event`로 통합, `onWsMessage` 매핑 로직 제거 + +### 12.13 프론트엔드 shim 최종 구조 + +위 백엔드 호환성 레이어가 완성되면, ipcShim.ts는 다음으로 축소: + +```typescript +// ── deny 리스트 (hello_ack에서 수신, 하드코딩 없음) ── +let denyList: string[] = []; + +// ── invoke 핸들러 ── +async function shimInvoke(cmd, args) { + // 1. 이벤트 플러그인 (프론트엔드 로컬 — 콜백 레지스트리) + if (cmd.startsWith('plugin:event|')) { /* listen/unlisten/emit */ } + + // 2. deny 체크 ("|"로 끝나면 prefix, 아니면 exact) + if (isDenied(cmd)) return; + + // 3. WS RPC (백엔드가 처리) + return wsRpc(cmd, args); +} + +// ── WS 메시지 수신 ── +function onWsMessage(envelope) { + switch (envelope.type) { + // 기존 호환 (1단계) + case 'key_event': dispatchEvent('keys:state', envelope.payload); break; + case 'settings_diff': dispatchEvent('settings:changed', { changed: envelope.payload }); break; + case 'counter_update': dispatchEvent('keys:counters', envelope.payload); break; + case 'snapshot': /* 다수 이벤트 일괄 디스패치 */ break; + + // 범용 포워딩 (2단계) + case 'tauri_event': dispatchEvent(envelope.payload.event, envelope.payload.data); break; + + // RPC 응답 + case 'invoke_response': /* pending RPC resolve/reject */ break; + } +} +``` + +3단계 전환 완료 후에는 기존 `case 'key_event'` 등이 제거되고 `tauri_event` 하나로 통합. --- @@ -712,33 +990,37 @@ S→C: { type: "invoke_response", requestId: "uuid", result: {...} } > v3 P1/P2 대부분 완료. 아래는 미완료 항목을 우선순위별로 정리. -### Tier 1 — Tauri IPC Shim (§12, 다음 작업) +### Tier 1 — IPC Shim + 백엔드 호환성 레이어 (§12) -| # | 작업 | 예상 규모 | 비고 | -|---|------|-----------|------| -| 1 | **IPC shim 인프라 + invoke/listen 구현** | `api/ipcShim.ts` 1파일 | WS 연결 + snapshot 캐시 + invoke no-op/캐시 반환 + listen → WS 메시지 디스패치 | -| 2 | **창 관리 API no-op 스텁** | Vite alias 또는 shim 내부 | window/menu/dpi 모킹 | -| 3 | **obs/App.tsx 재작성** | 기존 33줄 → shim 초기화 + overlay App 임포트 | | -| 4 | **검증 + 정리** | useOverlayRuntime.ts, useObsWebSocket.ts 제거 | | +| # | 작업 | 영역 | 비고 | +|---|------|------|------| +| 1 | **프론트 IPC shim** — WS 연결 + invoke/listen + No-op + WS RPC | `api/ipcShim.ts` | | +| 2 | **백엔드 WS RPC** — `invoke_request` → `webview.on_message()` 자동 디스패치 | `obs_bridge.rs` | §12.11 | +| 3 | **백엔드 이벤트 포워딩** — `tauri_event` WS 메시지 추가 | `obs_bridge.rs`, `app_state.rs` | §12.12 | +| 4 | **snapshot 필드 보강** — `layerGroups`, `tabNoteOverrides`, `tabCssOverrides` | `app_state.rs`, `mod.rs` | | +| 5 | **convertFileSrc 수정** — OBS HTTP `/media/` 경로 매핑 | `api/ipcShim.ts` | | +| 6 | **obs/index.tsx 재작성** — shim → dmnoteApi → overlay/App | `windows/obs/index.tsx` | | +| 7 | **검증 + 정리** — useOverlayRuntime.ts, useObsWebSocket.ts 제거 | | | 구현 결과: - overlay/App.tsx **코드 변경 0** - obs/App.tsx가 overlay/App.tsx와 **동일 코드** 실행 - 중복 로직 **완전 해소** (useOverlayRuntime 417줄 제거) +- **커맨드 추가 시 양쪽 모두 수정 불필요** — deny 리스트에 없으면 자동 동작 +- **deny 리스트 관리 포인트 1곳** — Rust `DENIED_WS_COMMANDS` 수정 시 WS handshake로 프론트에 자동 반영 -### Tier 2 — 플러그인 OBS 지원 (§12 완료 후) +### Tier 2 — 프로토콜 통합 (Tier 1 완료 후) -| # | 작업 | 설명 | 비고 | -|---|------|------|------| -| 5 | **WS invoke RPC 프로토콜** | invoke_request/response + 화이트리스트 | IPC shim이 RPC 커맨드를 실제 WS로 전송 | -| 6 | **플러그인 엘리먼트** | snapshot에 pluginElements 포함 | shim이 자동 처리 (코드 변경 없음) | -| 7 | **커스텀 JS** | js:use/js:content 이벤트 + plugin_storage_* RPC | #5 의존 | +| # | 작업 | 설명 | +|---|------|------| +| 8 | **기존 WS 메시지를 `tauri_event`로 통합** | `key_event` → `tauri_event { event: "keys:state" }` 등 | +| 9 | **shim `onWsMessage` 매핑 제거** | 통합 후 `tauri_event` + `invoke_response` 만 남김 | ### Tier 3 — 알려진 이슈 (낮은 우선순위) | # | 이슈 | 증상 | 추정 원인 | |---|------|------|-----------| -| 8 | **초기 접속 시 빈 화면** | 브라우저 최초 접속 시 키 UI가 보이지 않다가, 키 위치를 한 번 변경하면 전부 표시됨 | snapshot → bootstrap 변환 시 레이아웃 계산 타이밍. IPC shim 도입 시 자연 해소 가능성 있음 | +| 10 | **초기 접속 시 빈 화면** | 최초 접속 시 키 UI 미표시, 위치 변경 후 표시 | 레이아웃 계산 타이밍. IPC shim 도입 시 자연 해소 가능성 | ### 완료된 주요 마일스톤 @@ -748,5 +1030,5 @@ v2: HTTP+WS 통합 서빙, layout_diff, cached_snapshot 증분 갱신 v3 P1: 설정 영속화, 오버레이 연동, KPS 로컬 계산, UI 안내 v3 P2: 커스텀 CSS, 배경 미디어, keyDisplayDelayMs, 키별 노트 효과, 보안 토큰, dev 모드 서빙, 포터블 exe AssetFetcher -v4 (예정): Tauri IPC Shim → overlay 코드 재사용 + useOverlayRuntime 제거 +v4 (예정): Tauri IPC Shim + 백엔드 호환성 레이어 → 완전한 코드 재사용 ``` diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts new file mode 100644 index 00000000..667c7d95 --- /dev/null +++ b/src/renderer/api/ipcShim.ts @@ -0,0 +1,581 @@ +/** + * Tauri IPC Shim — OBS 환경에서 invoke/listen을 WebSocket으로 교체 + * + * obs/index.tsx에서 앱 마운트 전에 initIpcShim()을 호출하면, + * window.__TAURI_INTERNALS__ 및 __TAURI_EVENT_PLUGIN_INTERNALS__를 설치하여 + * overlay/App.tsx가 코드 변경 없이 동작. + * + * 설계 원칙 (§12.4): + * - 커맨드별 분기 없음. 3단계만: plugin:event → deny → WS RPC + * - deny 리스트는 hello_ack에서 수신 (백엔드가 유일한 source of truth) + * - 백엔드 WS RPC 미구현 시 캐시 폴백 (과도기) + */ + +import { OBS_PROTOCOL_VERSION } from '@src/types/obs'; +import type { ObsEnvelope, HelloAckPayload } from '@src/types/obs'; +import type { BootstrapPayload } from '@src/types/app'; + +// ── 내부 상태 ── + +let ws: WebSocket | null = null; +let disposed = false; +let reconnectTimer: ReturnType | null = null; + +// 연결 정보 (convertFileSrc에서 사용) +let connHost = '127.0.0.1'; +let connPort = '34891'; +let connToken = ''; + +// deny 리스트 — hello_ack에서 수신 (백엔드가 source of truth) +// 구 버전 백엔드가 denyList를 보내지 않을 때의 폴백 +const DEFAULT_DENY_LIST = [ + 'overlay_resize', + 'overlay_set_visible', + 'overlay_set_lock', + 'overlay_set_anchor', + 'overlay_get', + 'window_minimize', + 'window_close', + 'window_show_main', + 'window_open_devtools_all', + 'app_quit', + 'app_restart', + 'app_open_external', + 'app_auto_update', + 'plugin:window|', + 'plugin:menu|', + 'plugin:resources|', +]; +let denyList: string[] = DEFAULT_DENY_LIST; + +// 콜백 레지스트리 (transformCallback/runCallback) +const callbacks = new Map void>(); + +// 이벤트 리스너 레지스트리 (plugin:event|listen) +// eventId → { event, handlerId } +const eventListeners = new Map(); +// event → Set +const eventListenersByName = new Map>(); + +let nextEventId = 1; +let seqCounter = 0; + +// WS RPC 대기 중인 요청 +const pendingRpc = new Map< + string, + { resolve: (value: unknown) => void; reject: (reason: unknown) => void } +>(); + +// ── [과도기] 캐시 — 백엔드 WS RPC 구현 후 제거 예정 ── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let snapshotCache: (BootstrapPayload & Record) | null = null; + +/** + * [과도기] 백엔드 WS RPC가 없는 동안 snapshot 캐시에서 응답. + * §12.11 백엔드 구현 후 이 함수와 snapshotCache를 제거. + */ +function handleCacheCommand( + cmd: string, + _args: Record, +): unknown | undefined { + if (!snapshotCache) return undefined; + + switch (cmd) { + case 'app_bootstrap': + return snapshotCache; + case 'settings_get': + return snapshotCache.settings; + case 'keys_get': + return snapshotCache.keys; + case 'positions_get': + return snapshotCache.positions; + case 'stat_positions_get': + return snapshotCache.statPositions; + case 'graph_positions_get': + return snapshotCache.graphPositions; + case 'custom_tabs_list': + return snapshotCache.customTabs; + case 'layer_groups_get': + return snapshotCache.layerGroups ?? {}; + case 'note_tab_get_all': + return snapshotCache.tabNoteOverrides ?? {}; + case 'css_get': + return snapshotCache.settings?.customCSS ?? { content: '', path: null }; + case 'css_get_use': + return snapshotCache.settings?.useCustomCSS ?? false; + case 'css_tab_get_all': + return snapshotCache.tabCssOverrides ?? {}; + case 'js_get': + return ( + snapshotCache.settings?.customJS ?? { + content: '', + path: null, + plugins: [], + } + ); + case 'js_get_use': + return snapshotCache.settings?.useCustomJS ?? false; + default: + return undefined; + } +} + +// ── deny 체크 ── + +/** "|"로 끝나면 prefix 매칭, 아니면 exact 매칭 */ +function isDenied(cmd: string): boolean { + return denyList.some((entry) => + entry.endsWith('|') ? cmd.startsWith(entry) : cmd === entry, + ); +} + +// ── 콜백 관리 (transformCallback / runCallback) ── + +function registerCallback( + callback?: (data: unknown) => void, + once = false, +): number { + const id = crypto.getRandomValues(new Uint32Array(1))[0]; + callbacks.set(id, (data: unknown) => { + if (once) { + callbacks.delete(id); + } + callback?.(data); + }); + return id; +} + +function unregisterCallback(id: number) { + callbacks.delete(id); +} + +function runCallback(id: number, data: unknown) { + const callback = callbacks.get(id); + if (callback) { + callback(data); + } +} + +// ── 이벤트 시스템 (plugin:event|listen/unlisten) ── + +function handleEventListen(args: Record): number { + const event = args.event as string; + const handlerId = args.handler as number; + const eventId = nextEventId++; + + eventListeners.set(eventId, { event, handlerId }); + + if (!eventListenersByName.has(event)) { + eventListenersByName.set(event, new Set()); + } + eventListenersByName.get(event)!.add(eventId); + + return eventId; +} + +function handleEventUnlisten(args: Record) { + const event = args.event as string; + const eventId = args.eventId as number; + + const entry = eventListeners.get(eventId); + if (entry) { + unregisterCallback(entry.handlerId); + eventListeners.delete(eventId); + } + + const nameSet = eventListenersByName.get(event); + if (nameSet) { + nameSet.delete(eventId); + if (nameSet.size === 0) { + eventListenersByName.delete(event); + } + } +} + +function handleEventEmit(args: Record) { + const event = args.event as string; + const payload = args.payload; + dispatchEvent(event, payload); +} + +/** 내부: 등록된 모든 리스너에게 이벤트 디스패치 */ +function dispatchEvent(event: string, payload: unknown) { + const listenerIds = eventListenersByName.get(event); + if (!listenerIds) return; + + for (const eventId of listenerIds) { + const entry = eventListeners.get(eventId); + if (entry) { + runCallback(entry.handlerId, { + event, + id: eventId, + payload, + }); + } + } +} + +// ── WS 메시지 수신 → Tauri 이벤트 디스패치 ── + +function onWsMessage(envelope: ObsEnvelope) { + switch (envelope.type) { + // 범용 이벤트 포워딩 (§12.12) + case 'tauri_event': { + const { event, data } = envelope.payload as { + event: string; + data: unknown; + }; + dispatchEvent(event, data); + break; + } + + // WS RPC 응답 + case 'invoke_response': { + const resp = envelope.payload as { + requestId: string; + result?: unknown; + error?: string; + }; + const pending = pendingRpc.get(resp.requestId); + if (pending) { + pendingRpc.delete(resp.requestId); + if (resp.error) { + pending.reject(new Error(resp.error)); + } else { + pending.resolve(resp.result); + } + } + break; + } + + // [과도기] 기존 WS 메시지 → Tauri 이벤트 매핑 + // 백엔드가 tauri_event로 통합하면 아래 case들 제거 + case 'key_event': + dispatchEvent('keys:state', envelope.payload); + break; + + case 'settings_diff': { + const patch = envelope.payload as Record; + dispatchEvent('settings:changed', { changed: patch }); + if (snapshotCache) { + Object.assign(snapshotCache.settings, patch); + } + break; + } + + case 'counter_update': { + const counters = envelope.payload as Record< + string, + Record + >; + dispatchEvent('keys:counters', counters); + if (snapshotCache) { + snapshotCache.keyCounters = counters; + } + break; + } + + case 'snapshot': { + const snapshot = envelope.payload as BootstrapPayload; + const prev = snapshotCache; + snapshotCache = snapshot; + + // 개별 이벤트 디스패치 (useAppBootstrap이 구독) + dispatchEvent('keys:changed', snapshot.keys); + dispatchEvent('positions:changed', snapshot.positions); + dispatchEvent('statPositions:changed', snapshot.statPositions); + dispatchEvent('graphPositions:changed', snapshot.graphPositions); + dispatchEvent('keys:mode-changed', { + mode: snapshot.selectedKeyType, + }); + dispatchEvent('keys:counters', snapshot.keyCounters); + + dispatchEvent('customTabs:changed', { + customTabs: snapshot.customTabs, + selectedKeyType: snapshot.selectedKeyType, + }); + + const tabNoteOverrides = + (snapshot as BootstrapPayload & Record) + .tabNoteOverrides ?? {}; + dispatchEvent('tabNote:changed_all', tabNoteOverrides); + + const layerGroups = + (snapshot as BootstrapPayload & Record).layerGroups ?? + {}; + dispatchEvent('layerGroups:changed', layerGroups); + + // preset:snapshot (프리셋 로드 시) + if (prev) { + dispatchEvent('preset:snapshot', { + keys: snapshot.keys, + positions: snapshot.positions, + statPositions: snapshot.statPositions, + graphPositions: snapshot.graphPositions, + customTabs: snapshot.customTabs, + selectedKeyType: snapshot.selectedKeyType, + tabNoteOverrides, + }); + } + + if (snapshot.settings) { + dispatchEvent('settings:changed', { + changed: snapshot.settings, + }); + } + break; + } + } +} + +// ── WS 전송 ── + +function sendWsMessage(type: string, payload: unknown = null) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const envelope: ObsEnvelope = { + v: OBS_PROTOCOL_VERSION, + type, + seq: seqCounter++, + ts: Date.now(), + payload, + }; + ws.send(JSON.stringify(envelope)); +} + +// ── invoke 핸들러 ── + +async function shimInvoke( + cmd: string, + args: Record = {}, + _options?: unknown, +): Promise { + // 1. 이벤트 플러그인 커맨드 (프론트엔드 로컬) + if (cmd === 'plugin:event|listen') { + return handleEventListen(args); + } + if (cmd === 'plugin:event|unlisten') { + handleEventUnlisten(args); + return; + } + if (cmd === 'plugin:event|emit' || cmd === 'plugin:event|emit_to') { + handleEventEmit(args); + return; + } + + // 2. deny 체크 (hello_ack에서 수신한 리스트) + if (isDenied(cmd)) { + return; + } + + // 3-a. [과도기] 캐시 폴백 — 백엔드 WS RPC 구현 후 제거 + const cached = handleCacheCommand(cmd, args); + if (cached !== undefined) { + return cached; + } + + // 3-b. WS RPC (백엔드가 처리) + if (!ws || ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error(`[IPC Shim] WS not connected: ${cmd}`)); + } + + const requestId = `rpc_${Date.now()}_${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + pendingRpc.set(requestId, { resolve, reject }); + sendWsMessage('invoke_request', { requestId, command: cmd, args }); + + // 타임아웃 10초 + setTimeout(() => { + if (pendingRpc.has(requestId)) { + pendingRpc.delete(requestId); + reject(new Error(`[IPC Shim] RPC timeout: ${cmd}`)); + } + }, 10000); + }); +} + +// ── convertFileSrc shim ── + +function shimConvertFileSrc(filePath: string, _protocol = 'asset'): string { + // OBS HTTP 서버의 /media/ 엔드포인트로 변환 + // 백엔드가 base64url(no-pad)을 기대하므로 표준 base64 → base64url 변환 + const bytes = new TextEncoder().encode(filePath); + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join(''); + const encoded = btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + const tokenParam = connToken ? `?token=${connToken}` : ''; + return `http://${connHost}:${connPort}/media/${encoded}${tokenParam}`; +} + +// ── 공개 API ── + +/** + * IPC shim 초기화. WS 연결 → hello_ack(denyList 수신) → snapshot 수신 → 글로벌 설치. + * 반드시 dmnoteApi import 전에 호출. + */ +export function initIpcShim(wsUrl: string, token: string): Promise { + disposed = false; + + // 연결 정보 파싱 (convertFileSrc에서 사용) + try { + const url = new URL(wsUrl); + connHost = url.hostname || '127.0.0.1'; + connPort = url.port || '34891'; + } catch { + connHost = '127.0.0.1'; + connPort = '34891'; + } + connToken = token; + + return new Promise((resolve, reject) => { + let resolved = false; + + const connect = () => { + if (disposed) return; + + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + sendWsMessage('hello', { + client: 'obs-browser', + protocol: OBS_PROTOCOL_VERSION, + appVersion: '', + resumeFromSeq: 0, + token: token || undefined, + }); + }; + + ws.onmessage = (event) => { + let envelope: ObsEnvelope; + try { + envelope = JSON.parse(event.data as string) as ObsEnvelope; + } catch { + return; + } + + if (envelope.type === 'hello_ack') { + // deny 리스트 수신 (없으면 기본값 유지) + const payload = envelope.payload as HelloAckPayload; + if (payload.denyList) { + denyList = payload.denyList; + } + return; + } + + if (envelope.type === 'ping') { + sendWsMessage('pong'); + return; + } + + if (envelope.type === 'error') { + const payload = envelope.payload as Record; + if (payload?.code === 'AUTH_FAILED') { + disposed = true; + if (!resolved) { + resolved = true; + reject(new Error('OBS auth failed')); + } + } + return; + } + + // snapshot 수신 시 글로벌 설치 후 resolve + if (envelope.type === 'snapshot' && !resolved) { + snapshotCache = envelope.payload as BootstrapPayload; + installGlobals(); + resolved = true; + resolve(); + return; + } + + // 이후 메시지는 이벤트로 디스패치 + onWsMessage(envelope); + }; + + ws.onclose = () => { + ws = null; + if (!disposed) { + reconnectTimer = setTimeout(connect, 3000); + } + }; + + ws.onerror = () => { + // onclose에서 처리 + }; + }; + + // 초기 연결 타임아웃 15초 + const initTimeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error('[IPC Shim] Connection timeout')); + } + }, 15000); + + const originalResolve = resolve; + resolve = (value) => { + clearTimeout(initTimeout); + originalResolve(value); + }; + + connect(); + }); +} + +/** 글로벌 객체에 shim 설치 */ +function installGlobals() { + // __TAURI_INTERNALS__ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__TAURI_INTERNALS__ = { + invoke: shimInvoke, + transformCallback: registerCallback, + unregisterCallback, + runCallback, + callbacks, + convertFileSrc: shimConvertFileSrc, + metadata: { + currentWindow: { label: 'obs-overlay' }, + currentWebview: { windowLabel: 'obs-overlay', label: 'obs-overlay' }, + }, + }; + + // __TAURI_EVENT_PLUGIN_INTERNALS__ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__TAURI_EVENT_PLUGIN_INTERNALS__ = { + unregisterListener: (event: string, eventId: number) => { + handleEventUnlisten({ event, eventId }); + }, + }; + + // isTauri 플래그 (isTauri() 함수가 참조) + (globalThis as Record).isTauri = true; +} + +/** shim 해제 */ +export function disposeIpcShim() { + disposed = true; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (ws) { + ws.close(); + ws = null; + } + + // 대기 중인 RPC 전부 reject + for (const [id, pending] of pendingRpc) { + pending.reject(new Error('[IPC Shim] Disposed')); + pendingRpc.delete(id); + } + + callbacks.clear(); + eventListeners.clear(); + eventListenersByName.clear(); + denyList = DEFAULT_DENY_LIST; + snapshotCache = null; +} diff --git a/src/renderer/windows/obs/index.tsx b/src/renderer/windows/obs/index.tsx index 50e2ba4a..b33510ac 100644 --- a/src/renderer/windows/obs/index.tsx +++ b/src/renderer/windows/obs/index.tsx @@ -1,16 +1,42 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import '@styles/global.css'; +import { initIpcShim, disposeIpcShim } from '@api/ipcShim'; async function bootstrap() { + // URL 파라미터에서 WS 접속 정보 추출 + const params = new URLSearchParams(window.location.search); + const host = params.get('host') || window.location.hostname || '127.0.0.1'; + const port = params.get('port') || window.location.port || '34891'; + const token = params.get('token') || ''; + const wsUrl = `ws://${host}:${port}`; + try { - const { default: App } = await import('./App'); + // 1. IPC shim 설치 (WS 연결 + snapshot 수신 대기) + await initIpcShim(wsUrl, token); + + // 2. window.api 설치 (shim 위에서 동작) + await import('@api/dmnoteApi'); + + // 3. OBS 윈도우 타입 표시 + window.__dmn_window_type = 'overlay'; + + // 4. overlay/App.tsx를 I18nProvider로 래핑하여 렌더 + const { I18nProvider } = await import('@contexts/I18nContext'); + const { default: App } = await import('@src/renderer/windows/overlay/App'); + const container = document.getElementById('root')!; const root = createRoot(container); - root.render(); + root.render( + + + , + ); } catch (error) { const err = error as Error; - console.error('[OBS] Failed to mount React app:', err); + console.error('[OBS] Failed to bootstrap:', err); + disposeIpcShim(); + const pre = document.createElement('pre'); pre.style.cssText = 'color: red; padding: 20px;'; pre.textContent = `OBS Error: ${err.message}\n${err.stack}`; diff --git a/src/types/obs.ts b/src/types/obs.ts index f76589f2..717497b5 100644 --- a/src/types/obs.ts +++ b/src/types/obs.ts @@ -26,6 +26,7 @@ export interface HelloPayload { export interface HelloAckPayload { serverVersion: string; obsMode: boolean; + denyList?: string[]; } export interface KeyEventPayload { @@ -54,4 +55,7 @@ export type ObsMessageType = | 'ping' | 'pong' | 'resync_request' - | 'error'; + | 'error' + | 'invoke_request' + | 'invoke_response' + | 'tauri_event'; From 38936664031eb6d12d4f35563ce5d36942ed78a1 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 12:16:29 +0900 Subject: [PATCH 32/56] =?UTF-8?q?feat:=20=EB=B0=B1=EC=97=94=EB=93=9C=20WS?= =?UTF-8?q?=20RPC=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=E2=80=94=20invoke=5Freq?= =?UTF-8?q?uest=20=E2=86=92=20webview.on=5Fmessage()=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=94=94=EC=8A=A4=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models/obs.rs: HelloAckPayload에 deny_list 필드, InvokeRequestPayload 구조체 추가 - obs_bridge.rs: DENIED_WS_COMMANDS 상수 + is_denied() + build_deny_list() - obs_bridge.rs: AppHandle 저장, handle_invoke_request() → webview.on_message() 디스패치 - obs_bridge.rs: unbounded mpsc 채널로 RPC 응답 → invoke_response WS 메시지 전송 - obs_bridge.rs: hello_ack에 deny_list 포함 - obs_bridge.rs: 크로스 플랫폼 로컬 URL (Windows: http, macOS/Linux: tauri://) - obs_bridge.rs: 파싱 실패 시 에러 응답 (클라이언트 대기 방지) - deny list 보강: obs_start/stop, 파일 대화상자, preset_load 등 추가 - ipcShim.ts: DEFAULT_DENY_LIST 동기화 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 3 + src-tauri/src/models/obs.rs | 12 ++ src-tauri/src/services/obs_bridge.rs | 209 ++++++++++++++++++++++++++- src/renderer/api/ipcShim.ts | 18 +++ 4 files changed, 236 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index c302cd90..e080feac 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -32,6 +32,9 @@ pub async fn obs_start( log::info!("[ObsBridge] Tauri 임베딩 에셋으로 HTTP 서빙"); } + // AppHandle 전달 (invoke_request 디스패치용) + state.obs_bridge.set_app_handle(app.clone()); + state .obs_bridge .start(port) diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index 4a588db7..f7d39831 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -27,6 +27,18 @@ pub struct ObsEnvelope { pub struct HelloAckPayload { pub server_version: String, pub obs_mode: bool, + /// OBS 클라이언트에 전달할 deny list (|로 끝나면 prefix 매칭) + pub deny_list: Vec, +} + +/// invoke_request 페이로드 (클라이언트 → 서버) +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InvokeRequestPayload { + pub req_id: u32, + pub cmd: String, + #[serde(default)] + pub args: Value, } /// 키 상태 (DOWN/UP) diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index a54e2f38..96db5c9e 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -13,10 +13,72 @@ use tokio::sync::{broadcast, oneshot}; use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; +use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; +use tauri::webview::InvokeRequest; +use tauri::{AppHandle, Manager, Wry}; + use crate::models::obs::{ - make_envelope, HelloAckPayload, KeyEventPayload, KeyState, ObsBroadcast, ObsEnvelope, ObsStatus, + make_envelope, HelloAckPayload, InvokeRequestPayload, KeyEventPayload, KeyState, ObsBroadcast, + ObsEnvelope, ObsStatus, }; +/// OBS 클라이언트에서 실행 불가능한 커맨드 목록 +/// `|`로 끝나는 항목은 prefix 매칭 (예: "plugin:window|" → "plugin:window|*" 전부 차단) +const DENIED_WS_COMMANDS: &[&str] = &[ + // 오버레이 제어 (OBS에서 조작 불가) + "overlay_resize", + "overlay_set_visible", + "overlay_set_lock", + "overlay_set_anchor", + "overlay_get", + // 윈도우/앱 제어 + "window_minimize", + "window_close", + "window_show_main", + "window_open_devtools_all", + "app_quit", + "app_restart", + "app_open_external", + "app_auto_update", + // OBS 서버 제어 (자기 자신 종료/재시작 방지) + "obs_start", + "obs_stop", + // 파일 대화상자 / 파일 쓰기 (로컬 파일 시스템 접근) + "image_load", + "font_load", + "sound_load", + "sound_save_processed_wav", + "css_load", + "css_reset", + "js_load", + "js_reset", + "js_reload", + "preset_load", + "preset_load_tab", + // Tauri 플러그인 (네이티브 윈도우/메뉴/리소스) + "plugin:window|", + "plugin:menu|", + "plugin:resources|", +]; + +/// 커맨드가 deny list에 해당하는지 확인 +fn is_denied(cmd: &str) -> bool { + DENIED_WS_COMMANDS.iter().any(|entry| { + if let Some(prefix) = entry.strip_suffix('|') { + cmd.starts_with(prefix) + && cmd.len() > prefix.len() + && cmd.as_bytes()[prefix.len()] == b'|' + } else { + cmd == *entry + } + }) +} + +/// deny list를 Vec으로 변환 (hello_ack 전송용) +fn build_deny_list() -> Vec { + DENIED_WS_COMMANDS.iter().map(|s| s.to_string()).collect() +} + /// 임베딩 에셋 조회 함수 타입 (path → Option<(bytes, mime_type)>) pub type AssetFetcher = Arc Option<(Vec, String)> + Send + Sync>; @@ -37,6 +99,8 @@ pub struct ObsBridgeService { server_version: String, /// 세션 보안 토큰 (서버 시작 시 랜덤 생성) session_token: RwLock, + /// Tauri AppHandle (invoke_request 디스패치용) + app_handle: RwLock>>, } impl ObsBridgeService { @@ -54,6 +118,7 @@ impl ObsBridgeService { dev_url: RwLock::new(None), server_version: version.to_string(), session_token: RwLock::new(String::new()), + app_handle: RwLock::new(None), } } @@ -65,6 +130,10 @@ impl ObsBridgeService { *self.dev_url.write() = Some(url); } + pub fn set_app_handle(&self, handle: AppHandle) { + *self.app_handle.write() = Some(handle); + } + pub fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } @@ -341,9 +410,7 @@ impl ObsBridgeService { } let _ = stream - .write_all( - b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) + .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .await; } @@ -427,10 +494,11 @@ impl ObsBridgeService { } } - // hello_ack 전송 + // hello_ack 전송 (deny list 포함) let ack_payload = serde_json::to_value(HelloAckPayload { server_version: self.server_version.clone(), obs_mode: true, + deny_list: build_deny_list(), }) .unwrap_or_default(); let ack_msg = make_envelope("hello_ack", next_seq(), ack_payload); @@ -455,7 +523,11 @@ impl ObsBridgeService { return; } - // 메인 루프: broadcast 수신 + 클라이언트 메시지 수신 + // RPC 응답 채널 (invoke_request → invoke_response) + let (rpc_tx, mut rpc_rx) = + tokio::sync::mpsc::unbounded_channel::<(u32, Result)>(); + + // 메인 루프: broadcast 수신 + 클라이언트 메시지 수신 + RPC 응답 let mut ping_interval = tokio::time::interval(Duration::from_secs(30)); loop { @@ -501,6 +573,13 @@ impl ObsBridgeService { break; } } + "invoke_request" => { + self.handle_invoke_request( + &envelope.payload, + &addr, + rpc_tx.clone(), + ); + } _ => {} } } @@ -512,6 +591,17 @@ impl ObsBridgeService { _ => {} } } + // RPC 응답 전송 (invoke_request → invoke_response) + Some((req_id, result)) = rpc_rx.recv() => { + let payload = match result { + Ok(data) => serde_json::json!({ "reqId": req_id, "ok": data }), + Err(err) => serde_json::json!({ "reqId": req_id, "err": err }), + }; + let msg = make_envelope("invoke_response", next_seq(), payload); + if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { + break; + } + } // 서버 주도 ping (연결 유지) _ = ping_interval.tick() => { let ping_msg = make_envelope("ping", next_seq(), Value::Null); @@ -529,6 +619,113 @@ impl ObsBridgeService { ); } + /// invoke_request 처리: webview.on_message()로 Tauri 커맨드 파이프라인에 주입 + fn handle_invoke_request( + &self, + payload: &Value, + addr: &SocketAddr, + rpc_tx: tokio::sync::mpsc::UnboundedSender<(u32, Result)>, + ) { + let req: InvokeRequestPayload = match serde_json::from_value(payload.clone()) { + Ok(r) => r, + Err(e) => { + log::warn!("[ObsBridge] {addr}: invoke_request 파싱 실패: {e}"); + // reqId를 추출 시도하여 에러 응답 전송 (파싱 실패여도 클라이언트 대기 방지) + if let Some(req_id) = payload.get("reqId").and_then(|v| v.as_u64()) { + let _ = rpc_tx.send(( + req_id as u32, + Err(serde_json::json!(format!("Invalid invoke_request: {e}"))), + )); + } + return; + } + }; + + // deny 체크 (이중 안전망 — 프론트엔드에서도 차단하지만 백엔드에서 한번 더) + if is_denied(&req.cmd) { + log::debug!("[ObsBridge] {addr}: denied cmd={}", req.cmd); + let _ = rpc_tx.send(( + req.req_id, + Err(serde_json::json!(format!("Command denied: {}", req.cmd))), + )); + return; + } + + // AppHandle에서 overlay webview 가져오기 + let app_handle = match self.app_handle.read().clone() { + Some(h) => h, + None => { + log::warn!("[ObsBridge] {addr}: AppHandle 미설정"); + let _ = rpc_tx.send(( + req.req_id, + Err(serde_json::json!("AppHandle not available")), + )); + return; + } + }; + + let webview_window = match app_handle.get_webview_window("overlay") { + Some(w) => w, + None => { + log::warn!("[ObsBridge] {addr}: overlay webview 없음"); + let _ = rpc_tx.send(( + req.req_id, + Err(serde_json::json!("Overlay webview not found")), + )); + 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 invoke_key = app_handle.invoke_key().to_string(); + let request = InvokeRequest { + cmd: req.cmd.clone(), + callback: CallbackFn(0), + error: CallbackFn(1), + url: local_url, + body: InvokeBody::Json(req.args), + headers: Default::default(), + invoke_key, + }; + + let req_id = req.req_id; + let cmd = req.cmd.clone(); + let addr_clone = *addr; + + // 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((req_id, result)); + }); + + log::debug!("[ObsBridge] {addr_clone}: invoke cmd={cmd} reqId={req_id}"); + webview_window.on_message(request, responder); + } + /// /media/?token=xxx — 사용자 로컬 미디어 파일 서빙 async fn handle_media_request(&self, stream: &mut TcpStream, rest: &str) { use base64::Engine; diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts index 667c7d95..987e8ed5 100644 --- a/src/renderer/api/ipcShim.ts +++ b/src/renderer/api/ipcShim.ts @@ -29,11 +29,13 @@ let connToken = ''; // deny 리스트 — hello_ack에서 수신 (백엔드가 source of truth) // 구 버전 백엔드가 denyList를 보내지 않을 때의 폴백 const DEFAULT_DENY_LIST = [ + // 오버레이 제어 (OBS에서 조작 불가) 'overlay_resize', 'overlay_set_visible', 'overlay_set_lock', 'overlay_set_anchor', 'overlay_get', + // 윈도우/앱 제어 'window_minimize', 'window_close', 'window_show_main', @@ -42,6 +44,22 @@ const DEFAULT_DENY_LIST = [ 'app_restart', 'app_open_external', 'app_auto_update', + // OBS 서버 제어 (자기 자신 종료/재시작 방지) + 'obs_start', + 'obs_stop', + // 파일 대화상자 / 파일 쓰기 (로컬 파일 시스템 접근) + 'image_load', + 'font_load', + 'sound_load', + 'sound_save_processed_wav', + 'css_load', + 'css_reset', + 'js_load', + 'js_reset', + 'js_reload', + 'preset_load', + 'preset_load_tab', + // Tauri 플러그인 (네이티브 윈도우/메뉴/리소스) 'plugin:window|', 'plugin:menu|', 'plugin:resources|', From 28adb94552a987a078a61a530c65e7be2bd6e2b5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 12:37:58 +0900 Subject: [PATCH 33/56] =?UTF-8?q?feat:=20Tauri=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=E2=86=92=20OBS=20WS=20=ED=8F=AC=EC=9B=8C=EB=94=A9?= =?UTF-8?q?=20=EB=B8=8C=EB=A6=BF=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - register_event_forwarding()으로 22개 Tauri 이벤트를 app.listen() 구독 - ObsBroadcast::TauriEvent → tauri_event WS 메시지로 변환 - 리스너 lifecycle 관리 (stop 시 unlisten, 중복 호출 시 기존 해제) - auto_start_obs 경로에 set_app_handle + register_event_forwarding 추가 - keys:counter 이벤트명 수정 (keys:counter-changed → keys:counter) Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 2 + src-tauri/src/models/obs.rs | 5 ++ src-tauri/src/services/obs_bridge.rs | 78 ++++++++++++++++++++++++++++ src-tauri/src/state/app_state.rs | 14 ++++- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index e080feac..ae33a697 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -34,6 +34,8 @@ pub async fn obs_start( // AppHandle 전달 (invoke_request 디스패치용) state.obs_bridge.set_app_handle(app.clone()); + // Tauri 이벤트 → OBS WS 포워딩 리스너 등록 + state.obs_bridge.register_event_forwarding(&app); state .obs_bridge diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index f7d39831..f3dc1ca9 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -70,6 +70,11 @@ pub enum ObsBroadcast { LayoutDiff(Value), CounterUpdate(Value), Snapshot(Value), + /// 범용 Tauri 이벤트 포워딩 (event 이름 + JSON 데이터) + TauriEvent { + event: String, + data: Value, + }, /// 서버 종료 신호 — 클라이언트 세션 종료용 Shutdown, } diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 96db5c9e..0b4e8202 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -101,6 +101,8 @@ pub struct ObsBridgeService { session_token: RwLock, /// Tauri AppHandle (invoke_request 디스패치용) app_handle: RwLock>>, + /// 이벤트 포워딩용 리스너 ID (stop 시 해제) + event_listener_ids: RwLock>, } impl ObsBridgeService { @@ -119,6 +121,7 @@ impl ObsBridgeService { server_version: version.to_string(), session_token: RwLock::new(String::new()), app_handle: RwLock::new(None), + event_listener_ids: RwLock::new(Vec::new()), } } @@ -134,6 +137,61 @@ impl ObsBridgeService { *self.app_handle.write() = Some(handle); } + /// Tauri 이벤트를 OBS WS 클라이언트에 포워딩하는 리스너 등록 + pub fn register_event_forwarding(&self, app: &AppHandle) { + use tauri::Listener; + + // 기존 리스너 해제 (중복 호출 시 누적 방지) + for id in self.event_listener_ids.write().drain(..) { + app.unlisten(id); + } + + // OBS 오버레이가 수신하는 이벤트 목록 + let forwarded_events = [ + "settings:changed", + "keys:state", + "keys:changed", + "keys:counters", + "keys:counter", + "keys:mode-changed", + "positions:changed", + "statPositions:changed", + "graphPositions:changed", + "layerGroups:changed", + "overlay:visibility", + "overlay:lock", + "overlay:anchor", + "input:raw", + "css:use", + "css:content", + "js:use", + "js:content", + "tabNote:changed", + "tabNote:changed_all", + "tabCss:changed", + "plugin-bridge:message", + ]; + + let mut ids = Vec::with_capacity(forwarded_events.len()); + for event_name in &forwarded_events { + let tx = self.broadcast_tx.clone(); + let name = event_name.to_string(); + let id = app.listen(*event_name, move |evt| { + let data: Value = serde_json::from_str(evt.payload()).unwrap_or(Value::Null); + let _ = tx.send(ObsBroadcast::TauriEvent { + event: name.clone(), + data, + }); + }); + ids.push(id); + } + *self.event_listener_ids.write() = ids; + log::info!( + "[ObsBridge] 이벤트 포워딩 등록: {}개 이벤트", + forwarded_events.len() + ); + } + pub fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) } @@ -182,6 +240,14 @@ impl ObsBridgeService { let _ = self.broadcast_tx.send(ObsBroadcast::CounterUpdate(data)); } + /// 범용 Tauri 이벤트 포워딩 (OBS 클라이언트에 tauri_event로 전달) + #[allow(dead_code)] + pub fn broadcast_tauri_event(&self, event: String, data: Value) { + let _ = self + .broadcast_tx + .send(ObsBroadcast::TauriEvent { event, data }); + } + /// 전체 스냅샷 재전송 (프리셋 로드 등 대규모 변경 시) pub fn broadcast_snapshot(&self) { let snapshot = self.cached_snapshot.read().clone(); @@ -242,6 +308,13 @@ impl ObsBridgeService { if !self.running.load(Ordering::Relaxed) { return; } + // 이벤트 포워딩 리스너 해제 + if let Some(app) = self.app_handle.read().as_ref() { + use tauri::Listener; + for id in self.event_listener_ids.write().drain(..) { + app.unlisten(id); + } + } // 기존 클라이언트 세션에 종료 신호 전송 let _ = self.broadcast_tx.send(ObsBroadcast::Shutdown); if let Some(tx) = self.shutdown_tx.write().take() { @@ -893,6 +966,11 @@ fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { ObsBroadcast::LayoutDiff(diff) => make_envelope("layout_diff", seq, diff.clone()), ObsBroadcast::CounterUpdate(data) => make_envelope("counter_update", seq, data.clone()), 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은 직접 처리됨"), } } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index df904575..a71623d2 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -296,8 +296,13 @@ impl AppState { let port = store.with_state(|s| s.obs_port); let app_handle = app.clone(); - // 프로덕션: Tauri 임베딩 에셋으로 서빙 - if !cfg!(debug_assertions) { + // 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(); @@ -309,6 +314,11 @@ impl AppState { bridge.set_asset_fetcher(fetcher); } + // AppHandle 전달 (invoke_request 디스패치용) + bridge.set_app_handle(app.clone()); + // Tauri 이벤트 → OBS WS 포워딩 리스너 등록 + bridge.register_event_forwarding(app); + // 오버레이 숨김 (부팅 시 flash 방지) self.obs_hide_overlay(app); From f32faf485801ca2f19ebedd0aee41c7f54d2c0d6 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 12:46:08 +0900 Subject: [PATCH 34/56] =?UTF-8?q?feat:=20BootstrapPayload=EC=97=90=20layer?= =?UTF-8?q?Groups,=20tabNoteOverrides,=20tabCssOverrides=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust/TS BootstrapPayload 타입에 3개 필드 추가 - bootstrap_payload()에서 스토어 데이터 populate - ipcShim.ts에서 unsafe cast 제거, 정식 타입 접근으로 전환 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/models/mod.rs | 3 +++ src-tauri/src/state/app_state.rs | 3 +++ src/renderer/api/ipcShim.ts | 12 +++--------- src/types/app.ts | 6 ++++++ 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 3103aeaa..6293a664 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1565,6 +1565,9 @@ pub struct BootstrapPayload { pub current_mode: String, pub overlay: BootstrapOverlayState, pub key_counters: KeyCounters, + pub layer_groups: LayerGroups, + pub tab_note_overrides: TabNoteOverrides, + pub tab_css_overrides: TabCssOverrides, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index a71623d2..39047e14 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -250,6 +250,9 @@ impl AppState { anchor: state.overlay_resize_anchor.as_str().to_string(), }, key_counters: self.key_counters.read().clone(), + layer_groups: state.layer_groups.clone(), + tab_note_overrides: state.tab_note_overrides.clone(), + tab_css_overrides: state.tab_css_overrides.clone(), } } diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts index 987e8ed5..02e80805 100644 --- a/src/renderer/api/ipcShim.ts +++ b/src/renderer/api/ipcShim.ts @@ -314,15 +314,9 @@ function onWsMessage(envelope: ObsEnvelope) { selectedKeyType: snapshot.selectedKeyType, }); - const tabNoteOverrides = - (snapshot as BootstrapPayload & Record) - .tabNoteOverrides ?? {}; - dispatchEvent('tabNote:changed_all', tabNoteOverrides); + dispatchEvent('tabNote:changed_all', snapshot.tabNoteOverrides ?? {}); - const layerGroups = - (snapshot as BootstrapPayload & Record).layerGroups ?? - {}; - dispatchEvent('layerGroups:changed', layerGroups); + dispatchEvent('layerGroups:changed', snapshot.layerGroups ?? {}); // preset:snapshot (프리셋 로드 시) if (prev) { @@ -333,7 +327,7 @@ function onWsMessage(envelope: ObsEnvelope) { graphPositions: snapshot.graphPositions, customTabs: snapshot.customTabs, selectedKeyType: snapshot.selectedKeyType, - tabNoteOverrides, + tabNoteOverrides: snapshot.tabNoteOverrides ?? {}, }); } diff --git a/src/types/app.ts b/src/types/app.ts index 0e79a7a8..6a70775b 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -8,6 +8,9 @@ import { import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { DefaultsPayload } from '@src/renderer/defaults'; +import type { LayerGroups } from '@src/types/layerGroups'; +import type { TabNoteOverrides } from '@src/types/settings/noteSettings'; +import type { TabCssOverrides } from '@src/types/plugin/css'; export interface BootstrapPayload { settings: SettingsState; @@ -25,4 +28,7 @@ export interface BootstrapPayload { anchor: string; }; keyCounters: KeyCounters; + layerGroups: LayerGroups; + tabNoteOverrides: TabNoteOverrides; + tabCssOverrides: TabCssOverrides; } From 9cc15e01a1941a0e8ad75bbeb9bfb7fc17be26b9 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 12:47:38 +0900 Subject: [PATCH 35/56] =?UTF-8?q?refactor:=20=EB=A0=88=EA=B1=B0=EC=8B=9C?= =?UTF-8?q?=20OBS=20=ED=9B=85=20=EB=B0=8F=20obs/App.tsx=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IPC Shim이 overlay/App.tsx를 직접 사용하므로 중간 레이어 불필요: - obs/App.tsx (useObsWebSocket + useOverlayRuntime 래퍼) - hooks/obs/useObsWebSocket.ts (레거시 WS 클라이언트) - hooks/shared/useOverlayRuntime.ts (스냅샷 → signal 브릿지) Co-Authored-By: Claude Opus 4.6 --- src/renderer/hooks/obs/useObsWebSocket.ts | 176 -------- .../hooks/shared/useOverlayRuntime.ts | 416 ------------------ src/renderer/windows/obs/App.tsx | 32 -- 3 files changed, 624 deletions(-) delete mode 100644 src/renderer/hooks/obs/useObsWebSocket.ts delete mode 100644 src/renderer/hooks/shared/useOverlayRuntime.ts delete mode 100644 src/renderer/windows/obs/App.tsx diff --git a/src/renderer/hooks/obs/useObsWebSocket.ts b/src/renderer/hooks/obs/useObsWebSocket.ts deleted file mode 100644 index ba00c094..00000000 --- a/src/renderer/hooks/obs/useObsWebSocket.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import type { ObsEnvelope, KeyEventPayload } from '@src/types/obs'; -import { OBS_PROTOCOL_VERSION } from '@src/types/obs'; -import type { BootstrapPayload } from '@src/types/app'; - -type ConnectionState = 'connecting' | 'connected' | 'disconnected'; - -interface UseObsWebSocketOptions { - url: string; - token?: string; - onSnapshot: (payload: BootstrapPayload) => void; - onKeyEvent: (payload: KeyEventPayload) => void; - onSettingsDiff: (diff: Record) => void; - onCounterUpdate: (data: Record) => void; -} - -function useObsWebSocket({ - url, - token, - onSnapshot, - onKeyEvent, - onSettingsDiff, - onCounterUpdate, -}: UseObsWebSocketOptions) { - const [connectionState, setConnectionState] = - useState('disconnected'); - - // 모든 상태를 ref로 관리하여 React Compiler 호환성 확보 - const wsRef = useRef(null); - const reconnectTimerRef = useRef | null>(null); - const seqRef = useRef(0); - const callbacksRef = useRef({ - onSnapshot, - onKeyEvent, - onSettingsDiff, - onCounterUpdate, - }); - - // 콜백 ref 동기화 - useEffect(() => { - callbacksRef.current = { - onSnapshot, - onKeyEvent, - onSettingsDiff, - onCounterUpdate, - }; - }, [onSnapshot, onKeyEvent, onSettingsDiff, onCounterUpdate]); - - useEffect(() => { - let disposed = false; - - const sendMessage = (type: string, payload: unknown = null) => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const envelope: ObsEnvelope = { - v: OBS_PROTOCOL_VERSION, - type, - seq: seqRef.current++, - ts: Date.now(), - payload, - }; - ws.send(JSON.stringify(envelope)); - }; - - const connect = () => { - if (disposed) return; - - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - - setConnectionState('connecting'); - const ws = new WebSocket(url); - wsRef.current = ws; - - ws.onopen = () => { - sendMessage('hello', { - client: 'obs-browser', - protocol: OBS_PROTOCOL_VERSION, - appVersion: '', - resumeFromSeq: 0, - token: token || undefined, - }); - }; - - ws.onmessage = (event) => { - let envelope: ObsEnvelope; - try { - envelope = JSON.parse(event.data as string) as ObsEnvelope; - } catch { - return; // JSON 파싱 실패 무시 - } - switch (envelope.type) { - case 'hello_ack': - setConnectionState('connected'); - break; - case 'snapshot': - callbacksRef.current.onSnapshot( - envelope.payload as BootstrapPayload, - ); - break; - case 'key_event': - callbacksRef.current.onKeyEvent( - envelope.payload as KeyEventPayload, - ); - break; - case 'settings_diff': - callbacksRef.current.onSettingsDiff( - envelope.payload as Record, - ); - break; - case 'counter_update': - callbacksRef.current.onCounterUpdate( - envelope.payload as Record, - ); - break; - case 'ping': - sendMessage('pong'); - break; - case 'error': { - const payload = envelope.payload as Record; - if (payload?.code === 'AUTH_FAILED') { - console.warn('[ObsWS] 토큰 인증 실패, 재연결 중단'); - disposed = true; // 재연결 루프 방지 - } - break; - } - } - }; - - ws.onclose = () => { - setConnectionState('disconnected'); - wsRef.current = null; - if (!disposed) { - reconnectTimerRef.current = setTimeout(connect, 3000); - } - }; - - ws.onerror = () => { - // onclose에서 재연결 처리 - }; - }; - - connect(); - - return () => { - disposed = true; - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [url, token]); - - const requestResync = () => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const envelope: ObsEnvelope = { - v: OBS_PROTOCOL_VERSION, - type: 'resync_request', - seq: seqRef.current++, - ts: Date.now(), - payload: null, - }; - ws.send(JSON.stringify(envelope)); - }; - - return { connectionState, requestResync }; -} - -export { useObsWebSocket }; -export type { ConnectionState }; diff --git a/src/renderer/hooks/shared/useOverlayRuntime.ts b/src/renderer/hooks/shared/useOverlayRuntime.ts deleted file mode 100644 index 6293009b..00000000 --- a/src/renderer/hooks/shared/useOverlayRuntime.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useNoteSystem } from '@hooks/overlay/useNoteSystem'; -import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; -import { - mergeNoteSettings, - NOTE_SETTINGS_DEFAULTS, -} from '@src/types/settings/noteSettings'; -import { - setKeyActive as setKeyActiveSignal, - resetAllKeySignals, -} from '@stores/signals/keySignals'; -import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; -import { applyStatsSnapshot } from '@stores/signals/statsSignals'; -import { computeLayout } from '@hooks/shared/useLayoutComputation'; -import type { BootstrapPayload } from '@src/types/app'; -import type { KeyEventPayload } from '@src/types/obs'; -import type { - KeyMappings, - KeyPosition, - KeyPositions, -} from '@src/types/key/keys'; -import type { StatItemPositions } from '@src/types/key/statItems'; -import type { GraphItemPositions } from '@src/types/key/graphItems'; -import type { NoteSettings } from '@src/types/settings/noteSettings'; -import type { CustomCss } from '@src/types/plugin/css'; - -const OBS_CUSTOM_CSS_ID = 'dmn-obs-custom-css'; - -// ── 콜백 타입 (데이터 소스가 호출) ── - -export interface OverlayRuntimeHandlers { - onSnapshot: (payload: BootstrapPayload) => void; - onKeyEvent: (payload: KeyEventPayload) => void; - onSettingsDiff: (diff: Record) => void; - onCounterUpdate: (data: Record) => void; -} - -// ── 훅 ── - -export function useOverlayRuntime() { - // 상태 - const [keyMappings, setKeyMappings] = useState({}); - const [positions, setPositions] = useState({}); - const [statPositions, setStatPositions] = useState({}); - const [graphPositions, setGraphPositions] = useState({}); - const [selectedKeyType, setSelectedKeyType] = useState('4key'); - const [noteEffect, setNoteEffect] = useState(true); - const [noteSettings, setNoteSettings] = useState( - NOTE_SETTINGS_DEFAULTS, - ); - const [backgroundColor, setBackgroundColor] = useState('transparent'); - const [keyCounterEnabled, setKeyCounterEnabled] = useState(false); - const [initialized, setInitialized] = useState(false); - - // 커스텀 CSS DOM 주입 - const cssStateRef = useRef({ enabled: false, content: '' }); - const cssStyleElRef = useRef(null); - - const applyCssToDOM = useCallback(() => { - let el = cssStyleElRef.current; - if (!el) { - el = document.getElementById( - OBS_CUSTOM_CSS_ID, - ) as HTMLStyleElement | null; - if (!el) { - el = document.createElement('style'); - el.id = OBS_CUSTOM_CSS_ID; - document.head.appendChild(el); - } - cssStyleElRef.current = el; - } - const { enabled, content } = cssStateRef.current; - if (enabled && content) { - el.textContent = content; - el.disabled = false; - } else { - el.textContent = ''; - el.disabled = true; - } - }, []); - - // 노트 시스템 - const { - notesRef, - subscribe, - handleKeyDown, - handleKeyUp, - noteBuffer, - updateTrackLayouts, - } = useNoteSystem({ noteEffect, noteSettings }); - - // ref로 최신 값 유지 (useCallback 안에서 참조) - const handleKeyDownRef = useRef(handleKeyDown); - const handleKeyUpRef = useRef(handleKeyUp); - useEffect(() => { - handleKeyDownRef.current = handleKeyDown; - handleKeyUpRef.current = handleKeyUp; - }, [handleKeyDown, handleKeyUp]); - - const selectedKeyTypeRef = useRef(selectedKeyType); - useEffect(() => { - selectedKeyTypeRef.current = selectedKeyType; - }, [selectedKeyType]); - - const keyMappingsRef = useRef([]); - const positionsRef = useRef([]); - useEffect(() => { - keyMappingsRef.current = keyMappings[selectedKeyType] ?? []; - positionsRef.current = positions[selectedKeyType] ?? []; - }, [keyMappings, positions, selectedKeyType]); - - // 키 딜레이 - const keyDisplayDelayMsRef = useRef(0); - const keyDelayTimersRef = useRef< - Map> }> - >(new Map()); - - useEffect(() => { - keyDisplayDelayMsRef.current = Number(noteSettings?.keyDisplayDelayMs ?? 0); - }, [noteSettings?.keyDisplayDelayMs]); - - useEffect(() => { - const timers = keyDelayTimersRef.current; - return () => { - timers.forEach((entry) => { - entry.timers.forEach((timer) => clearTimeout(timer)); - }); - timers.clear(); - }; - }, []); - - // KPS 로컬 계산 (1초 슬라이딩 윈도우) - const kpsRef = useRef({ - timestamps: [] as number[], - total: 0, - kpsMax: 0, - kpsSumForAvg: 0, - kpsNonZeroCount: 0, - activeKeys: new Set(), - }); - - useEffect(() => { - const interval = setInterval(() => { - const tracker = kpsRef.current; - const now = performance.now(); - while ( - tracker.timestamps.length > 0 && - now - tracker.timestamps[0] > 1000 - ) { - tracker.timestamps.shift(); - } - const kps = tracker.timestamps.length; - if (kps > tracker.kpsMax) tracker.kpsMax = kps; - if (kps > 0) { - tracker.kpsSumForAvg += kps; - tracker.kpsNonZeroCount++; - } - const kpsAvg = - tracker.kpsNonZeroCount > 0 - ? Math.round(tracker.kpsSumForAvg / tracker.kpsNonZeroCount) - : 0; - applyStatsSnapshot({ - kps, - kpsAvg, - kpsMax: tracker.kpsMax, - total: tracker.total, - }); - }, 50); - return () => clearInterval(interval); - }, []); - - // 키 딜레이 적용 신호 업데이트 - const updateKeySignalWithDelay = useCallback( - (key: string, isDown: boolean) => { - const delayMs = keyDisplayDelayMsRef.current; - - let timerEntry = keyDelayTimersRef.current.get(key); - if (!timerEntry) { - timerEntry = { timers: new Set() }; - keyDelayTimersRef.current.set(key, timerEntry); - } - - if (delayMs <= 0) { - timerEntry.timers.forEach((timer) => clearTimeout(timer)); - timerEntry.timers.clear(); - setKeyActiveSignal(key, isDown); - return; - } - - const timer = setTimeout(() => { - setKeyActiveSignal(key, isDown); - timerEntry?.timers.delete(timer); - }, delayMs); - timerEntry.timers.add(timer); - }, - [], - ); - - // ── 데이터 소스 콜백 ── - - const onSnapshot = useCallback( - (payload: BootstrapPayload) => { - setKeyMappings(payload.keys ?? {}); - setPositions(payload.positions ?? {}); - setStatPositions(payload.statPositions ?? {}); - setGraphPositions(payload.graphPositions ?? {}); - setSelectedKeyType(payload.selectedKeyType ?? '4key'); - - const settings = payload.settings; - if (settings) { - setNoteEffect(settings.noteEffect ?? true); - setNoteSettings( - mergeNoteSettings( - settings.noteSettings ?? NOTE_SETTINGS_DEFAULTS, - null, - ), - ); - setBackgroundColor(settings.backgroundColor ?? 'transparent'); - setKeyCounterEnabled(settings.keyCounterEnabled ?? false); - - // 커스텀 CSS - cssStateRef.current = { - enabled: settings.useCustomCSS ?? false, - content: (settings.customCSS as CustomCss | undefined)?.content ?? '', - }; - applyCssToDOM(); - } - - // 카운터 초기화 - if (payload.keyCounters) { - applyCounterSnapshot(payload.keyCounters); - } - - // KPS 트래커 리셋 - const tracker = kpsRef.current; - tracker.timestamps = []; - tracker.kpsMax = 0; - tracker.kpsSumForAvg = 0; - tracker.kpsNonZeroCount = 0; - tracker.activeKeys.clear(); - let totalFromCounters = 0; - if (payload.keyCounters) { - const modeCounters = - payload.keyCounters[payload.selectedKeyType ?? '4key']; - if (modeCounters) { - totalFromCounters = Object.values(modeCounters).reduce( - (sum, v) => sum + v, - 0, - ); - } - } - tracker.total = totalFromCounters; - applyStatsSnapshot({ - kps: 0, - kpsAvg: 0, - kpsMax: 0, - total: totalFromCounters, - }); - - // 딜레이 타이머 정리 - keyDelayTimersRef.current.forEach((entry) => { - entry.timers.forEach((timer) => clearTimeout(timer)); - entry.timers.clear(); - }); - - // ref 즉시 동기화 - const mode = payload.selectedKeyType ?? '4key'; - selectedKeyTypeRef.current = mode; - keyMappingsRef.current = (payload.keys ?? {})[mode] ?? []; - positionsRef.current = (payload.positions ?? {})[mode] ?? []; - if (payload.settings?.noteSettings) { - keyDisplayDelayMsRef.current = Number( - payload.settings.noteSettings.keyDisplayDelayMs ?? 0, - ); - } - - resetAllKeySignals(); - setInitialized(true); - }, - [applyCssToDOM], - ); - - const onKeyEvent = useCallback( - (payload: KeyEventPayload) => { - const { key, state } = payload; - const isDown = state === 'DOWN'; - - updateKeySignalWithDelay(key, isDown); - - // KPS - const tracker = kpsRef.current; - if (isDown) { - if (!tracker.activeKeys.has(key)) { - tracker.activeKeys.add(key); - tracker.timestamps.push(performance.now()); - tracker.total++; - } - const keys = keyMappingsRef.current; - const pos = positionsRef.current; - const keyIndex = keys.indexOf(key); - const keyPosition = pos[keyIndex]; - if (keyPosition?.noteEffectEnabled !== false) { - requestAnimationFrame(() => handleKeyDownRef.current(key)); - } - } else { - tracker.activeKeys.delete(key); - requestAnimationFrame(() => handleKeyUpRef.current(key)); - } - }, - [updateKeySignalWithDelay], - ); - - const onSettingsDiff = useCallback( - (diff: Record) => { - if ('noteEffect' in diff) setNoteEffect(diff.noteEffect as boolean); - if ('noteSettings' in diff) - setNoteSettings((prev) => - mergeNoteSettings( - { ...prev, ...(diff.noteSettings as Partial) }, - null, - ), - ); - if ('backgroundColor' in diff) - setBackgroundColor(diff.backgroundColor as string); - if ('keyCounterEnabled' in diff) - setKeyCounterEnabled(diff.keyCounterEnabled as boolean); - - // 커스텀 CSS - let cssChanged = false; - if ('useCustomCSS' in diff) { - cssStateRef.current.enabled = diff.useCustomCSS as boolean; - cssChanged = true; - } - if ('customCSS' in diff) { - const css = diff.customCSS as Partial | undefined; - if (css?.content !== undefined) { - cssStateRef.current.content = css.content; - cssChanged = true; - } - } - if (cssChanged) applyCssToDOM(); - }, - [applyCssToDOM], - ); - - const onCounterUpdate = useCallback((data: Record) => { - const counters = data as Record>; - applyCounterSnapshot(counters); - const modeCounters = counters[selectedKeyTypeRef.current]; - if (modeCounters) { - kpsRef.current.total = Object.values(modeCounters).reduce( - (sum, v) => sum + v, - 0, - ); - } - }, []); - - // ── 레이아웃 계산 ── - - const currentKeys = keyMappings[selectedKeyType] ?? []; - const currentPositions = positions[selectedKeyType] ?? []; - const currentStatPositions = statPositions[selectedKeyType] ?? []; - const currentGraphPositions = graphPositions[selectedKeyType] ?? []; - - const trackHeight = - noteSettings?.trackHeight ?? DEFAULT_NOTE_SETTINGS.trackHeight; - - const { - displayPositions, - displayStatPositions, - displayGraphPositions, - webglTracks, - } = computeLayout({ - currentKeys, - currentPositions, - currentStatPositions, - currentGraphPositions, - trackHeight, - noteSettings, - }); - - useEffect(() => { - updateTrackLayouts(webglTracks); - }, [webglTracks, updateTrackLayouts]); - - return { - // 데이터 소스 콜백 - handlers: { - onSnapshot, - onKeyEvent, - onSettingsDiff, - onCounterUpdate, - } satisfies OverlayRuntimeHandlers, - - // OverlayScene props - sceneProps: { - currentKeys, - displayPositions, - currentPositions, - displayStatPositions, - displayGraphPositions, - selectedKeyType, - noteEffect, - noteSettings, - webglTracks, - notesRef, - subscribe, - noteBuffer, - backgroundColor, - keyCounterEnabled, - showPluginElements: false as const, - }, - - initialized, - }; -} diff --git a/src/renderer/windows/obs/App.tsx b/src/renderer/windows/obs/App.tsx deleted file mode 100644 index 022648ec..00000000 --- a/src/renderer/windows/obs/App.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useObsWebSocket } from '@hooks/obs/useObsWebSocket'; -import { useOverlayRuntime } from '@hooks/shared/useOverlayRuntime'; -import OverlayScene from '@components/shared/OverlayScene'; - -export default function App() { - const params = new URLSearchParams(window.location.search); - const host = params.get('host') || window.location.hostname || '127.0.0.1'; - const port = params.get('port') || window.location.port || '34891'; - const token = params.get('token') || ''; - const wsUrl = `ws://${host}:${port}`; - - const { handlers, sceneProps, initialized } = useOverlayRuntime(); - - useObsWebSocket({ - url: wsUrl, - token, - ...handlers, - }); - - if (!initialized) { - return ( -
-
Connecting...
-
- ); - } - - return ; -} From c0123336640571911a723c0ca1b0113b3871686d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 13:35:11 +0900 Subject: [PATCH 36/56] =?UTF-8?q?docs:=20OBS=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=97=90=20v4=20IPC=20Shim=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §13 Tier 1 전체 ✅ 완료 표시 + 커밋 해시 기록 - Tier 3 알려진 이슈 추가 (input:raw 이중 전달, tabCss resync) - §5.1 레거시 파일 삭제 반영 - §8.2 커스텀 JS/플러그인 지원 상태 업데이트 - v4 마일스톤 완료 기록 Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 88 ++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index abcd3960..b1116da2 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -2,7 +2,7 @@ > 작성일: 2026-03-07 > 목표: OBS 브라우저 소스로 키뷰어를 표시하여 게임 FPS 영향 완전 제거 -> 상태: **v2 구현 완료** (`feat/obs-mode` 브랜치) +> 상태: **v4 IPC Shim 구현 완료** (`feat/obs-mode` 브랜치) --- @@ -40,7 +40,7 @@ 1. **AppState가 단일 상태 소스** — 키보드 데몬이 직접 WS로 보내지 않음 ✅ 2. **렌더링 코드 재사용** — useNoteSystem, noteBuffer, WebGLTracksOGL 공유 ✅ -3. **OBS 페이지는 Tauri API 무의존** — window.api.* 참조 없음 ✅ +3. **OBS 페이지는 overlay/App.tsx 재사용** — IPC Shim으로 Tauri API 호환 ✅ ### 데이터 흐름 @@ -248,26 +248,25 @@ futures-util = "0.3" ``` src/renderer/ ├── windows/ -│ ├── overlay/App.tsx ✅ 기존 (OverlayScene 사용으로 리팩터링) +│ ├── overlay/App.tsx ✅ 기존 (OBS에서도 동일 코드 재사용) │ └── obs/ -│ ├── App.tsx ✅ 신설 (WebSocket + OverlayScene) -│ ├── index.tsx ✅ 신설 (bootstrap) -│ └── index.html ✅ 신설 +│ ├── index.tsx ✅ IPC Shim 초기화 → overlay/App 동적 import +│ └── index.html ✅ 엔트리 +├── api/ +│ └── ipcShim.ts ✅ 신설 (v4: WS→Tauri IPC 호환 레이어) ├── components/shared/ -│ └── OverlayScene.tsx ✅ 신설 (공용 렌더링 컴포넌트) -├── hooks/obs/ -│ └── useObsWebSocket.ts ✅ 신설 (WS 연결 + auto-reconnect) +│ └── OverlayScene.tsx ✅ 공용 렌더링 컴포넌트 ├── hooks/overlay/ │ └── useNoteSystem.ts ✅ 그대로 재사용 ├── stores/signals/ │ └── noteBuffer.ts ✅ 그대로 재사용 ├── api/modules/ -│ └── obsApi.ts ✅ 신설 (Tauri 커맨드 래퍼) +│ └── obsApi.ts ✅ Tauri 커맨드 래퍼 └── components/overlay/ └── WebGLTracksOGL.tsx ✅ 그대로 재사용 ``` -> 설계 문서의 adapter 패턴 대신, OBS App.tsx에서 직접 상태 관리하는 단순한 구조로 구현 +> v4: IPC Shim으로 overlay/App.tsx를 코드 변경 없이 재사용. obs/App.tsx, useObsWebSocket.ts, useOverlayRuntime.ts **삭제됨**. ### 5.2 OverlayScene 추출 ✅ @@ -382,8 +381,8 @@ v3 추가: | 레이아웃 동기화 | ✅ 지원 (snapshot 재전송) | | 커스텀 CSS | ✅ 지원 (v3: settings_diff 경유 실시간 주입) | | 배경 미디어 서빙 | ✅ 지원 (v3: /media/ 엔드포인트 + 토큰 검증) | -| 커스텀 JS (플러그인) | × 미지원 (Tauri API 의존) | -| 플러그인 엘리먼트 | × 미지원 (bridge API 의존) | +| 커스텀 JS (플러그인) | ✅ 지원 (v4: IPC Shim으로 invoke/listen 호환) | +| 플러그인 엘리먼트 | ✅ 지원 (v4: bridge API → WS RPC 자동 라우팅) | ### 8.3 성능 참고 @@ -477,16 +476,16 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 | 8 | **개별 키 noteEffectEnabled** | 키별 노트 효과 on/off 반영 | ✅ | | 9 | **보안 토큰** | 랜덤 세션 토큰 생성 + WS hello 검증 | ✅ | | 10 | **Dev 모드 서빙** | dev 모드 시 Vite dev server로 프록시하여 빌드 없이 OBS 페이지 테스트 가능하도록 지원 | ✅ | -| 11 | **DataSource 호환성 레이어** | OverlayHost adapter로 Tauri API / WebSocket 통합 인터페이스 도입 (§12 참조) | ⚠️ computeLayout 추출 완료, adapter 설계 확정 / 구현 대기 | +| 11 | **Tauri IPC Shim 호환성 레이어** | IPC Shim으로 invoke/listen 프리미티브 교체, overlay/App.tsx 코드 변경 없이 재사용 (§12 참조) | ✅ Tier 1 구현 완료 | | 12 | **포터블 exe 에셋 서빙** | static_dir 디스크 파일 → Tauri asset_resolver() 기반 AssetFetcher로 전환, 단일 exe 배포 지원 | ✅ | #### v3+ 이후 (P3) | # | 작업 | 설명 | 상태 | |---|------|------|------| -| 10 | **플러그인 엘리먼트** | bridge API 없는 환경에서 플러그인 UI 렌더링 (서버 HTML 스냅샷 등) | ❌ | -| 11 | **커스텀 JS (플러그인)** | bridge API WebSocket 프록시 레이어로 Tauri API 의존 해소 | ❌ | -| 12 | **OBS CEF 호환 테스트** | OBS 28+ 브라우저 소스에서 WebGL/CSS 실제 검증 | ❌ | +| 10 | **플러그인 엘리먼트** | IPC Shim으로 bridge API가 WS RPC를 통해 자동 동작 | ✅ (v4 IPC Shim으로 해소) | +| 11 | **커스텀 JS (플러그인)** | IPC Shim으로 invoke/listen 호환, dmn.* API 자동 지원 | ✅ (v4 IPC Shim으로 해소) | +| 12 | **OBS CEF 호환 테스트** | OBS 28+ 브라우저 소스에서 WebGL/CSS 실제 검증 | ❌ 미검증 | ### 11.3 v1 → v2 변경 요약 @@ -501,7 +500,7 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 ## 12. Tauri IPC Shim 호환성 레이어 설계 -> 상태: **설계 확정 (C 방식)** / 구현 대기 +> 상태: **Tier 1 구현 완료** / Tier 2 (프로토콜 통합) 대기 > P2 #11 상세 설계 ### 12.1 배경 및 목표 @@ -986,41 +985,50 @@ function onWsMessage(envelope) { --- -## 13. 남은 작업 우선순위 (2026-03-08 기준) +## 13. 작업 진행 현황 (2026-03-08 기준) -> v3 P1/P2 대부분 완료. 아래는 미완료 항목을 우선순위별로 정리. +> v4 Tier 1 구현 완료. Tier 2 (프로토콜 통합)는 후속 작업. -### Tier 1 — IPC Shim + 백엔드 호환성 레이어 (§12) +### Tier 1 — IPC Shim + 백엔드 호환성 레이어 (§12) ✅ 완료 -| # | 작업 | 영역 | 비고 | +| # | 작업 | 영역 | 상태 | |---|------|------|------| -| 1 | **프론트 IPC shim** — WS 연결 + invoke/listen + No-op + WS RPC | `api/ipcShim.ts` | | -| 2 | **백엔드 WS RPC** — `invoke_request` → `webview.on_message()` 자동 디스패치 | `obs_bridge.rs` | §12.11 | -| 3 | **백엔드 이벤트 포워딩** — `tauri_event` WS 메시지 추가 | `obs_bridge.rs`, `app_state.rs` | §12.12 | -| 4 | **snapshot 필드 보강** — `layerGroups`, `tabNoteOverrides`, `tabCssOverrides` | `app_state.rs`, `mod.rs` | | -| 5 | **convertFileSrc 수정** — OBS HTTP `/media/` 경로 매핑 | `api/ipcShim.ts` | | -| 6 | **obs/index.tsx 재작성** — shim → dmnoteApi → overlay/App | `windows/obs/index.tsx` | | -| 7 | **검증 + 정리** — useOverlayRuntime.ts, useObsWebSocket.ts 제거 | | | +| 1 | **프론트 IPC shim** — WS 연결 + invoke/listen + No-op + WS RPC | `api/ipcShim.ts` | ✅ `dac007a` | +| 2 | **백엔드 WS RPC** — `invoke_request` → `webview.on_message()` 자동 디스패치 | `obs_bridge.rs` | ✅ `3893666` | +| 3 | **백엔드 이벤트 포워딩** — 22개 Tauri 이벤트 → `tauri_event` WS 포워딩 | `obs_bridge.rs`, `app_state.rs` | ✅ `28adb94` | +| 4 | **snapshot 필드 보강** — `layerGroups`, `tabNoteOverrides`, `tabCssOverrides` | `app_state.rs`, `mod.rs`, `app.ts` | ✅ `f32faf4` | +| 5 | **convertFileSrc 수정** — OBS HTTP `/media/` base64url 매핑 | `api/ipcShim.ts` | ✅ (Step 1에 포함) | +| 6 | **obs/index.tsx 재작성** — shim → dmnoteApi → overlay/App | `windows/obs/index.tsx` | ✅ (기존 구현 검증) | +| 7 | **레거시 정리** — obs/App.tsx, useOverlayRuntime.ts, useObsWebSocket.ts 삭제 | | ✅ `9cc15e0` (624줄 삭제) | 구현 결과: - overlay/App.tsx **코드 변경 0** -- obs/App.tsx가 overlay/App.tsx와 **동일 코드** 실행 -- 중복 로직 **완전 해소** (useOverlayRuntime 417줄 제거) +- obs/index.tsx → IPC Shim 설치 → overlay/App.tsx **동일 코드** 실행 +- 중복 로직 **완전 해소** (레거시 624줄 삭제) - **커맨드 추가 시 양쪽 모두 수정 불필요** — deny 리스트에 없으면 자동 동작 - **deny 리스트 관리 포인트 1곳** — Rust `DENIED_WS_COMMANDS` 수정 시 WS handshake로 프론트에 자동 반영 +- **auto_start_obs 경로**에도 IPC Shim 지원 추가 (set_app_handle + register_event_forwarding) -### Tier 2 — 프로토콜 통합 (Tier 1 완료 후) +Codex(GPT 5.4) 리뷰에서 발견/수정한 이슈: +- `keys:counter-changed` → `keys:counter` 이벤트명 오류 수정 +- cross-platform URL (`tauri://localhost` vs `http://tauri.localhost`) 대응 +- deny list 확장 (obs_start/stop, 파일 커맨드, 프리셋 등 11항목 추가) +- 리스너 lifecycle (중복 등록 방지 + stop 시 해제) -| # | 작업 | 설명 | -|---|------|------| -| 8 | **기존 WS 메시지를 `tauri_event`로 통합** | `key_event` → `tauri_event { event: "keys:state" }` 등 | -| 9 | **shim `onWsMessage` 매핑 제거** | 통합 후 `tauri_event` + `invoke_response` 만 남김 | +### Tier 2 — 프로토콜 통합 (후속 작업) + +| # | 작업 | 설명 | 상태 | +|---|------|------|------| +| 8 | **기존 WS 메시지를 `tauri_event`로 통합** | `key_event` → `tauri_event { event: "keys:state" }` 등 | ❌ | +| 9 | **shim `onWsMessage` 매핑 제거** | 통합 후 `tauri_event` + `invoke_response` 만 남김 | ❌ | ### Tier 3 — 알려진 이슈 (낮은 우선순위) -| # | 이슈 | 증상 | 추정 원인 | -|---|------|------|-----------| -| 10 | **초기 접속 시 빈 화면** | 최초 접속 시 키 UI 미표시, 위치 변경 후 표시 | 레이아웃 계산 타이밍. IPC shim 도입 시 자연 해소 가능성 | +| # | 이슈 | 증상 | 비고 | +|---|------|------|------| +| 10 | **초기 접속 시 빈 화면** | 최초 접속 시 키 UI 미표시, 위치 변경 후 표시 | IPC shim 도입으로 자연 해소 가능성 → 실제 테스트 필요 | +| 11 | **`input:raw` 이중 전달** | main+overlay 양쪽 `window.emit()` → 리스너 2회 트리거 | OBS 클라이언트에서 dedup 필요할 수 있음 | +| 12 | **`tabCssOverrides` resync** | reconnect 시 snapshot만으로는 탭 CSS 미갱신 | `tabCss:changed` 이벤트 포워딩으로 커버, 초기 snapshot은 포함됨 | ### 완료된 주요 마일스톤 @@ -1030,5 +1038,5 @@ v2: HTTP+WS 통합 서빙, layout_diff, cached_snapshot 증분 갱신 v3 P1: 설정 영속화, 오버레이 연동, KPS 로컬 계산, UI 안내 v3 P2: 커스텀 CSS, 배경 미디어, keyDisplayDelayMs, 키별 노트 효과, 보안 토큰, dev 모드 서빙, 포터블 exe AssetFetcher -v4 (예정): Tauri IPC Shim + 백엔드 호환성 레이어 → 완전한 코드 재사용 +v4: Tauri IPC Shim + 백엔드 호환성 레이어 → 완전한 코드 재사용 ✅ ``` From 4a358838a52ed828c458dd87aa715dac997f7183 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 13:51:12 +0900 Subject: [PATCH 37/56] =?UTF-8?q?fix:=20invoke=5Frequest=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EC=88=98=EC=A0=95=20+=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20WS=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20tauri?= =?UTF-8?q?=5Fevent=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 3: 초기 빈화면 버그 수정 - invoke_request 필드명 일치: reqId→requestId(String), cmd→command - invoke_response 필드명 일치: ok→result, err→error - 에러 폴백에서 requestId를 string으로 추출 Tier 2: WS 프로토콜 통합 - ObsBroadcast enum에서 KeyEvent, SettingsDiff, LayoutDiff, CounterUpdate 제거 - KeyState, KeyEventPayload 타입 삭제 - broadcast_key_event(), broadcast_settings_diff(), broadcast_counter_update() 삭제 - app_state.rs에서 직접 broadcast 호출 제거 (캐시 갱신만 유지) - ipcShim.ts에서 key_event, settings_diff, counter_update 전용 핸들러 제거 - 모든 이벤트는 register_event_forwarding()이 tauri_event로 자동 포워딩 Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-design.md | 175 ++++++++++++--------------- src-tauri/src/models/obs.rs | 28 +---- src-tauri/src/services/obs_bridge.rs | 72 +++-------- src-tauri/src/state/app_state.rs | 36 ++---- src/renderer/api/ipcShim.ts | 27 ----- 5 files changed, 106 insertions(+), 232 deletions(-) diff --git a/docs/obs-mode-design.md b/docs/obs-mode-design.md index b1116da2..ddd82f51 100644 --- a/docs/obs-mode-design.md +++ b/docs/obs-mode-design.md @@ -2,7 +2,7 @@ > 작성일: 2026-03-07 > 목표: OBS 브라우저 소스로 키뷰어를 표시하여 게임 FPS 영향 완전 제거 -> 상태: **v4 IPC Shim 구현 완료** (`feat/obs-mode` 브랜치) +> 상태: **v4 IPC Shim + 프로토콜 통합 완료** (`feat/obs-mode` 브랜치) --- @@ -71,15 +71,14 @@ ### 3.2 메시지 타입 -| 방향 | 타입 | 용도 | 빈도 | v1 상태 | -|------|------|------|------|---------| +| 방향 | 타입 | 용도 | 빈도 | 상태 | +|------|------|------|------|------| | C→S | `hello` | 최초 접속 핸드셰이크 | 1회 | ✅ | -| S→C | `hello_ack` | 프로토콜 승인 | 1회 | ✅ | +| S→C | `hello_ack` | 프로토콜 승인 + deny list | 1회 | ✅ | | S→C | `snapshot` | 전체 상태 동기화 | 접속 시 + resync | ✅ | -| S→C | `key_event` | 키 입력 이벤트 | 매우 빈번 | ✅ | -| S→C | `settings_diff` | 설정 변경분 | 가끔 | ✅ | -| S→C | `layout_diff` | 레이아웃/모드/탭 변경 | 가끔 | ✅ snapshot 재전송으로 대체 | -| S→C | `counter_update` | 키 카운터 갱신 | 주기적 | ✅ | +| S→C | `tauri_event` | 범용 Tauri 이벤트 포워딩 | 빈번 | ✅ (v4: 기존 key_event/settings_diff/counter_update 통합) | +| C→S | `invoke_request` | 커맨드 실행 요청 (WS RPC) | 초기 + 간헐 | ✅ | +| S→C | `invoke_response` | 커맨드 실행 결과 | invoke 당 1회 | ✅ | | 양방향 | `ping` / `pong` | 연결 상태 확인 | 주기적 | ✅ | | C→S | `resync_request` | 상태 재동기화 요청 | 드묾 | ✅ | @@ -87,10 +86,10 @@ ``` 1. OBS 페이지 접속 (WS 직접 연결) ← v1: HTTP upgrade 없이 직접 WS -2. 클라이언트 → hello { client, protocol, appVersion } -3. 서버 → hello_ack { serverVersion, obsMode } +2. 클라이언트 → hello { client, protocol, appVersion, token } +3. 서버 → hello_ack { serverVersion, obsMode, denyList } 4. 서버 → snapshot { 전체 상태 } -5. 이후 key_event, settings_diff, counter_update 스트리밍 +5. 이후 tauri_event (keys:state, settings:changed 등) + invoke_request/invoke_response 6. seq gap 감지 시 → resync_request → snapshot 재전송 ``` @@ -131,37 +130,20 @@ } ``` -#### key_event (S→C) ✅ +#### tauri_event (S→C) ✅ ```json { "v": 1, - "type": "key_event", + "type": "tauri_event", "seq": 11, "payload": { - "key": "A", - "state": "DOWN", - "mode": "4key" - } -} -``` - -#### layout_diff (S→C) ✅ snapshot 재전송으로 대체 -```json -{ - "v": 1, - "type": "layout_diff", - "seq": 15, - "payload": { - "reason": "preset_loaded", - "selectedKeyType": "6key", - "keys": {}, - "positions": {}, - "statPositions": {}, - "graphPositions": {}, - "tabNoteOverrides": {} + "event": "keys:state", + "data": { "key": "A", "state": "DOWN", "mode": "4key" } } } ``` +> v4에서 기존 `key_event`, `settings_diff`, `counter_update` 전용 메시지를 `tauri_event`로 통합. +> 백엔드 `register_event_forwarding()`이 22개 Tauri 이벤트를 자동 포워딩. ### 3.5 상태 일관성 ✅ @@ -212,14 +194,16 @@ pub type AssetFetcher = Arc Option<(Vec, String)> + Send + S 주요 API: - `start(port: u16)` — WS 서버 bind ✅ - `stop()` — Shutdown broadcast → 서버 shutdown ✅ -- `broadcast_key_event(key, state, mode)` — 키 이벤트 전송 ✅ -- `broadcast_settings_diff(diff)` — 설정 변경 전송 ✅ -- `broadcast_layout_diff(diff)` — 레이아웃 변경 전송 ✅ (서버측, 호출 지점 미연동) -- `broadcast_counter_update(data)` — 카운터 갱신 전송 ✅ -- `broadcast_snapshot(snapshot)` — 스냅샷 전송 ✅ +- `broadcast_snapshot()` — 스냅샷 전송 ✅ +- `broadcast_tauri_event(event, data)` — 범용 Tauri 이벤트 포워딩 ✅ - `update_snapshot(snapshot)` — 캐시 갱신 ✅ +- `register_event_forwarding(app)` — 22개 Tauri 이벤트 → WS 자동 포워딩 ✅ +- `set_app_handle(handle)` — invoke_request WS RPC용 AppHandle 설정 ✅ - `status()` — 실행 상태 + 포트 + 클라이언트 수 조회 ✅ +> v4 Tier 2에서 `broadcast_key_event()`, `broadcast_settings_diff()`, `broadcast_counter_update()` 삭제. +> 모든 이벤트는 `register_event_forwarding()`이 `tauri_event`로 자동 포워딩. + ### 4.3 크레이트 의존성 ✅ ```toml @@ -230,12 +214,12 @@ futures-util = "0.3" ### 4.4 기존 코드 연동 지점 -| 기존 코드 위치 | 추가할 호출 | v1 상태 | -|----------------|-------------|---------| -| `app_state.rs` 키 입력 처리 루프 (~L813) | `obs_bridge.broadcast_key_event()` | ✅ | -| `app_state.rs` emit_settings_changed (~L252) | `obs_bridge.broadcast_settings_diff()` | ✅ | +| 기존 코드 위치 | 호출 | 상태 | +|----------------|------|------| +| `app_state.rs` 키 입력 처리 루프 | ~~`broadcast_key_event()`~~ → `register_event_forwarding`이 `keys:state` 자동 포워딩 | ✅ (Tier 2 통합) | +| `app_state.rs` emit_settings_changed | ~~`broadcast_settings_diff()`~~ → `register_event_forwarding`이 `settings:changed` 자동 포워딩 | ✅ (Tier 2 통합) | | `commands/preset/load.rs` 프리셋 로드 후 | `refresh_obs_snapshot()` + `broadcast_snapshot()` | ✅ | -| `commands/keys/keys.rs` 카운터 emit 지점 (9개) | `obs_broadcast_counters()` | ✅ | +| `commands/keys/keys.rs` 카운터 emit 지점 | ~~`obs_broadcast_counters()`~~ → `register_event_forwarding`이 `keys:counters` 자동 포워딩 (캐시 갱신만 유지) | ✅ (Tier 2 통합) | | `commands/keys/keys.rs` 모드 변경 | `refresh_obs_snapshot()` | ✅ | | `commands/layout/*` 레이아웃 변경 | `refresh_obs_snapshot()` | ✅ | @@ -308,11 +292,11 @@ TCP 스트림을 peek하여 `Upgrade: websocket` 헤더 유무로 분기. | 계층 | 데이터 | 변경 빈도 | v1 상태 | |------|--------|-----------|---------| -| 글로벌 설정 | noteEffect, noteSettings, backgroundColor | 드묾 | ✅ settings_diff | -| 레이아웃 | selectedKeyType, keys, positions, statPositions, graphPositions | 가끔 | ✅ 변경 시 snapshot 재전송 | -| 탭/프리셋 | customTabs, tabNoteOverrides | 가끔 | ✅ 변경 시 snapshot 재전송 | -| 런타임 | keyCounters, active mode | 실시간 | ✅ counter_update | -| 키 입력 | key, state | 매우 빈번 | ✅ key_event | +| 글로벌 설정 | noteEffect, noteSettings, backgroundColor | 드묾 | ✅ `tauri_event(settings:changed)` | +| 레이아웃 | selectedKeyType, keys, positions, statPositions, graphPositions | 가끔 | ✅ `tauri_event` + snapshot 재전송 | +| 탭/프리셋 | customTabs, tabNoteOverrides | 가끔 | ✅ `tauri_event` + snapshot 재전송 | +| 런타임 | keyCounters, active mode | 실시간 | ✅ `tauri_event(keys:counters)` | +| 키 입력 | key, state | 매우 빈번 | ✅ `tauri_event(keys:state)` | ### 6.2 동기화 전략 @@ -500,7 +484,7 @@ v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하 ## 12. Tauri IPC Shim 호환성 레이어 설계 -> 상태: **Tier 1 구현 완료** / Tier 2 (프로토콜 통합) 대기 +> 상태: **Tier 1~3 구현 완료** > P2 #11 상세 설계 ### 12.1 배경 및 목표 @@ -681,11 +665,11 @@ WS 브로드캐스트 메시지를 수신하면 Tauri 이벤트명으로 변환 | WS 메시지 타입 | → Tauri 이벤트 | 비고 | |---------------|---------------|------| -| `key_event` | `keys:state` | keyEventBus 구독 | -| `settings_diff` | `settings:changed` | `{ changed: patch }` 래핑 | -| `counter_update` | `keys:counters` | 전체 카운터 | +| `tauri_event` | 이벤트명 그대로 | 범용 이벤트 포워딩 — 22개 이벤트 자동 디스패치 | | `snapshot` | `keys:changed`, `positions:changed`, `settings:changed` 등 | 다수 이벤트 일괄 디스패치 | -| `tauri_event` | 이벤트명 그대로 | 범용 이벤트 포워딩 (§12.12) | +| `invoke_response` | — | WS RPC 응답 (pendingRpc resolve/reject) | + +> v4 Tier 2에서 기존 전용 메시지(`key_event`, `settings_diff`, `counter_update`)를 `tauri_event`로 완전 통합. #### stats 구독 @@ -934,60 +918,44 @@ pub fn forward_tauri_event(&self, event: &str, payload: &impl Serialize) { | `input:raw` | rawKeyEventBus (플러그인) | **신규** — raw_input_subscribe 시 | | `plugin-bridge:message` | PluginElementsRenderer | **신규** — 플러그인 지원 시 | -#### 전환 전략 +#### 전환 전략 ✅ 완료 -기존 전용 WS 메시지(`key_event`, `settings_diff`, `counter_update`)를 즉시 제거하면 하위 호환 깨짐. -단계적 전환: +기존 전용 WS 메시지(`key_event`, `settings_diff`, `counter_update`)를 `tauri_event`로 통합 완료. -1. **1단계**: `tauri_event` 포워딩 추가 (신규 이벤트만: `keys:counter`, `css:*`, `js:*`, `input:raw`) -2. **2단계**: shim의 `onWsMessage`에서 기존 메시지 타입 처리 유지 + `tauri_event` 처리 추가 -3. **3단계** (선택): 기존 전용 메시지를 `tauri_event`로 통합, `onWsMessage` 매핑 로직 제거 +1. ~~**1단계**: `tauri_event` 포워딩 추가~~ ✅ Tier 1에서 완료 +2. ~~**2단계**: shim의 `onWsMessage`에서 기존 메시지 타입 처리 유지 + `tauri_event` 처리 추가~~ ✅ Tier 1에서 완료 +3. ~~**3단계**: 기존 전용 메시지를 `tauri_event`로 통합, `onWsMessage` 매핑 로직 제거~~ ✅ Tier 2에서 완료 -### 12.13 프론트엔드 shim 최종 구조 +변경 내역: +- 백엔드: `ObsBroadcast` enum에서 `KeyEvent`, `SettingsDiff`, `CounterUpdate` 제거 +- 백엔드: `broadcast_key_event()`, `broadcast_settings_diff()`, `broadcast_counter_update()` 삭제 +- 백엔드: `app_state.rs`에서 직접 broadcast 호출 제거 (캐시 갱신만 유지) +- 프론트: `ipcShim.ts`에서 `key_event`, `settings_diff`, `counter_update` 전용 핸들러 제거 +- 모든 이벤트는 `register_event_forwarding()`이 `tauri_event`로 자동 포워딩 -위 백엔드 호환성 레이어가 완성되면, ipcShim.ts는 다음으로 축소: +### 12.13 프론트엔드 shim 최종 구조 ✅ -```typescript -// ── deny 리스트 (hello_ack에서 수신, 하드코딩 없음) ── -let denyList: string[] = []; - -// ── invoke 핸들러 ── -async function shimInvoke(cmd, args) { - // 1. 이벤트 플러그인 (프론트엔드 로컬 — 콜백 레지스트리) - if (cmd.startsWith('plugin:event|')) { /* listen/unlisten/emit */ } - - // 2. deny 체크 ("|"로 끝나면 prefix, 아니면 exact) - if (isDenied(cmd)) return; +Tier 2 통합 완료 후 ipcShim.ts의 WS 메시지 핸들러는 3가지만 처리: - // 3. WS RPC (백엔드가 처리) - return wsRpc(cmd, args); -} - -// ── WS 메시지 수신 ── +```typescript +// ── WS 메시지 수신 (최종) ── function onWsMessage(envelope) { switch (envelope.type) { - // 기존 호환 (1단계) - case 'key_event': dispatchEvent('keys:state', envelope.payload); break; - case 'settings_diff': dispatchEvent('settings:changed', { changed: envelope.payload }); break; - case 'counter_update': dispatchEvent('keys:counters', envelope.payload); break; - case 'snapshot': /* 다수 이벤트 일괄 디스패치 */ break; - - // 범용 포워딩 (2단계) - case 'tauri_event': dispatchEvent(envelope.payload.event, envelope.payload.data); break; - - // RPC 응답 + case 'tauri_event': dispatchEvent(envelope.payload.event, envelope.payload.data); break; case 'invoke_response': /* pending RPC resolve/reject */ break; + case 'snapshot': /* 다수 이벤트 일괄 디스패치 */ break; } } ``` -3단계 전환 완료 후에는 기존 `case 'key_event'` 등이 제거되고 `tauri_event` 하나로 통합. +기존 `key_event`, `settings_diff`, `counter_update` 전용 핸들러 제거 완료. +모든 이벤트는 백엔드 `register_event_forwarding()`이 `tauri_event`로 통합 포워딩. --- ## 13. 작업 진행 현황 (2026-03-08 기준) -> v4 Tier 1 구현 완료. Tier 2 (프로토콜 통합)는 후속 작업. +> v4 Tier 1~3 구현 완료. ### Tier 1 — IPC Shim + 백엔드 호환성 레이어 (§12) ✅ 완료 @@ -1015,20 +983,27 @@ Codex(GPT 5.4) 리뷰에서 발견/수정한 이슈: - deny list 확장 (obs_start/stop, 파일 커맨드, 프리셋 등 11항목 추가) - 리스너 lifecycle (중복 등록 방지 + stop 시 해제) -### Tier 2 — 프로토콜 통합 (후속 작업) +### Tier 2 — 프로토콜 통합 ✅ 완료 | # | 작업 | 설명 | 상태 | |---|------|------|------| -| 8 | **기존 WS 메시지를 `tauri_event`로 통합** | `key_event` → `tauri_event { event: "keys:state" }` 등 | ❌ | -| 9 | **shim `onWsMessage` 매핑 제거** | 통합 후 `tauri_event` + `invoke_response` 만 남김 | ❌ | +| 8 | **기존 WS 메시지를 `tauri_event`로 통합** | `key_event`, `settings_diff`, `counter_update` 제거, `tauri_event`로 일원화 | ✅ | +| 9 | **shim `onWsMessage` 매핑 제거** | `tauri_event` + `invoke_response` + `snapshot` 만 남김 | ✅ | + +변경 내역: +- `ObsBroadcast` enum에서 `KeyEvent`, `SettingsDiff`, `LayoutDiff`, `CounterUpdate` 제거 +- `KeyState`, `KeyEventPayload` 타입 삭제 +- `broadcast_key_event()`, `broadcast_settings_diff()`, `broadcast_counter_update()` 삭제 +- `app_state.rs`에서 직접 broadcast 호출 제거 (캐시 갱신만 유지) +- `ipcShim.ts`에서 전용 핸들러 3개 제거 -### Tier 3 — 알려진 이슈 (낮은 우선순위) +### Tier 3 — 알려진 이슈 ✅ 해결 / 확인 완료 -| # | 이슈 | 증상 | 비고 | +| # | 이슈 | 증상 | 상태 | |---|------|------|------| -| 10 | **초기 접속 시 빈 화면** | 최초 접속 시 키 UI 미표시, 위치 변경 후 표시 | IPC shim 도입으로 자연 해소 가능성 → 실제 테스트 필요 | -| 11 | **`input:raw` 이중 전달** | main+overlay 양쪽 `window.emit()` → 리스너 2회 트리거 | OBS 클라이언트에서 dedup 필요할 수 있음 | -| 12 | **`tabCssOverrides` resync** | reconnect 시 snapshot만으로는 탭 CSS 미갱신 | `tabCss:changed` 이벤트 포워딩으로 커버, 초기 snapshot은 포함됨 | +| 10 | **초기 접속 시 빈 화면** | `invoke_request` 필드명 불일치 (`reqId`/`cmd` vs `requestId`/`command`) | ✅ 수정 완료 | +| 11 | **`input:raw` 이중 전달** | main+overlay 양쪽 `window.emit()` → 리스너 2회 트리거 | ⚠️ 유지 (실사용 시 문제 발생하면 대응) | +| 12 | **OBS CEF 호환** | OBS 28+ 브라우저 소스에서 WebGL/CSS 동작 검증 | ✅ 사용자 테스트 확인 완료 | ### 완료된 주요 마일스톤 @@ -1038,5 +1013,7 @@ v2: HTTP+WS 통합 서빙, layout_diff, cached_snapshot 증분 갱신 v3 P1: 설정 영속화, 오버레이 연동, KPS 로컬 계산, UI 안내 v3 P2: 커스텀 CSS, 배경 미디어, keyDisplayDelayMs, 키별 노트 효과, 보안 토큰, dev 모드 서빙, 포터블 exe AssetFetcher -v4: Tauri IPC Shim + 백엔드 호환성 레이어 → 완전한 코드 재사용 ✅ +v4 Tier 1: Tauri IPC Shim + 백엔드 호환성 레이어 → 완전한 코드 재사용 ✅ +v4 Tier 2: 프로토콜 통합 — 전용 WS 메시지를 tauri_event로 일원화 ✅ +v4 Tier 3: invoke_request 필드명 수정 + OBS CEF 호환 확인 ✅ ``` diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index f3dc1ca9..b2966e42 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -35,40 +35,16 @@ pub struct HelloAckPayload { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InvokeRequestPayload { - pub req_id: u32, - pub cmd: String, + pub request_id: String, + pub command: String, #[serde(default)] pub args: Value, } -/// 키 상태 (DOWN/UP) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "UPPERCASE")] -pub enum KeyState { - Down, - Up, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct KeyEventPayload { - pub key: String, - pub state: KeyState, - pub mode: String, -} - // ── 브로드캐스트 내부 메시지 (tokio::sync::broadcast용) ── #[derive(Debug, Clone)] pub enum ObsBroadcast { - KeyEvent { - key: String, - state: KeyState, - mode: String, - }, - SettingsDiff(Value), - LayoutDiff(Value), - CounterUpdate(Value), Snapshot(Value), /// 범용 Tauri 이벤트 포워딩 (event 이름 + JSON 데이터) TauriEvent { diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 0b4e8202..cf23ec85 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -18,8 +18,7 @@ use tauri::webview::InvokeRequest; use tauri::{AppHandle, Manager, Wry}; use crate::models::obs::{ - make_envelope, HelloAckPayload, InvokeRequestPayload, KeyEventPayload, KeyState, ObsBroadcast, - ObsEnvelope, ObsStatus, + make_envelope, HelloAckPayload, InvokeRequestPayload, ObsBroadcast, ObsEnvelope, ObsStatus, }; /// OBS 클라이언트에서 실행 불가능한 커맨드 목록 @@ -221,25 +220,6 @@ impl ObsBridgeService { *self.cached_snapshot.write() = snapshot; } - pub fn broadcast_key_event(&self, key: String, state: KeyState, mode: String) { - let _ = self - .broadcast_tx - .send(ObsBroadcast::KeyEvent { key, state, mode }); - } - - pub fn broadcast_settings_diff(&self, diff: Value) { - let _ = self.broadcast_tx.send(ObsBroadcast::SettingsDiff(diff)); - } - - #[allow(dead_code)] // v2: layout 변경 broadcast - pub fn broadcast_layout_diff(&self, diff: Value) { - let _ = self.broadcast_tx.send(ObsBroadcast::LayoutDiff(diff)); - } - - pub fn broadcast_counter_update(&self, data: Value) { - let _ = self.broadcast_tx.send(ObsBroadcast::CounterUpdate(data)); - } - /// 범용 Tauri 이벤트 포워딩 (OBS 클라이언트에 tauri_event로 전달) #[allow(dead_code)] pub fn broadcast_tauri_event(&self, event: String, data: Value) { @@ -598,7 +578,7 @@ impl ObsBridgeService { // RPC 응답 채널 (invoke_request → invoke_response) let (rpc_tx, mut rpc_rx) = - tokio::sync::mpsc::unbounded_channel::<(u32, Result)>(); + tokio::sync::mpsc::unbounded_channel::<(String, Result)>(); // 메인 루프: broadcast 수신 + 클라이언트 메시지 수신 + RPC 응답 let mut ping_interval = tokio::time::interval(Duration::from_secs(30)); @@ -665,10 +645,10 @@ impl ObsBridgeService { } } // RPC 응답 전송 (invoke_request → invoke_response) - Some((req_id, result)) = rpc_rx.recv() => { + Some((request_id, result)) = rpc_rx.recv() => { let payload = match result { - Ok(data) => serde_json::json!({ "reqId": req_id, "ok": data }), - Err(err) => serde_json::json!({ "reqId": req_id, "err": err }), + 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); if ws_tx.send(Message::Text(msg.to_string())).await.is_err() { @@ -697,16 +677,16 @@ impl ObsBridgeService { &self, payload: &Value, addr: &SocketAddr, - rpc_tx: tokio::sync::mpsc::UnboundedSender<(u32, Result)>, + rpc_tx: tokio::sync::mpsc::UnboundedSender<(String, Result)>, ) { let req: InvokeRequestPayload = match serde_json::from_value(payload.clone()) { Ok(r) => r, Err(e) => { log::warn!("[ObsBridge] {addr}: invoke_request 파싱 실패: {e}"); - // reqId를 추출 시도하여 에러 응답 전송 (파싱 실패여도 클라이언트 대기 방지) - if let Some(req_id) = payload.get("reqId").and_then(|v| v.as_u64()) { + // requestId를 추출 시도하여 에러 응답 전송 (파싱 실패여도 클라이언트 대기 방지) + if let Some(request_id) = payload.get("requestId").and_then(|v| v.as_str()) { let _ = rpc_tx.send(( - req_id as u32, + request_id.to_string(), Err(serde_json::json!(format!("Invalid invoke_request: {e}"))), )); } @@ -715,11 +695,11 @@ impl ObsBridgeService { }; // deny 체크 (이중 안전망 — 프론트엔드에서도 차단하지만 백엔드에서 한번 더) - if is_denied(&req.cmd) { - log::debug!("[ObsBridge] {addr}: denied cmd={}", req.cmd); + if is_denied(&req.command) { + log::debug!("[ObsBridge] {addr}: denied cmd={}", req.command); let _ = rpc_tx.send(( - req.req_id, - Err(serde_json::json!(format!("Command denied: {}", req.cmd))), + req.request_id, + Err(serde_json::json!(format!("Command denied: {}", req.command))), )); return; } @@ -730,7 +710,7 @@ impl ObsBridgeService { None => { log::warn!("[ObsBridge] {addr}: AppHandle 미설정"); let _ = rpc_tx.send(( - req.req_id, + req.request_id, Err(serde_json::json!("AppHandle not available")), )); return; @@ -742,7 +722,7 @@ impl ObsBridgeService { None => { log::warn!("[ObsBridge] {addr}: overlay webview 없음"); let _ = rpc_tx.send(( - req.req_id, + req.request_id, Err(serde_json::json!("Overlay webview not found")), )); return; @@ -758,7 +738,7 @@ impl ObsBridgeService { }; let invoke_key = app_handle.invoke_key().to_string(); let request = InvokeRequest { - cmd: req.cmd.clone(), + cmd: req.command.clone(), callback: CallbackFn(0), error: CallbackFn(1), url: local_url, @@ -767,8 +747,8 @@ impl ObsBridgeService { invoke_key, }; - let req_id = req.req_id; - let cmd = req.cmd.clone(); + let request_id = req.request_id; + let cmd = req.command.clone(); let addr_clone = *addr; // OwnedInvokeResponder: 응답을 rpc_tx 채널로 전송 @@ -792,10 +772,10 @@ impl ObsBridgeService { } InvokeResponse::Err(err) => Err(err.0), }; - let _ = rpc_tx.send((req_id, result)); + let _ = rpc_tx.send((request_id, result)); }); - log::debug!("[ObsBridge] {addr_clone}: invoke cmd={cmd} reqId={req_id}"); + log::debug!("[ObsBridge] {addr_clone}: invoke cmd={cmd}"); webview_window.on_message(request, responder); } @@ -953,18 +933,6 @@ fn percent_decode(input: &str) -> String { /// ObsBroadcast → JSON envelope 변환 fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value { match broadcast { - ObsBroadcast::KeyEvent { key, state, mode } => { - let payload = serde_json::to_value(KeyEventPayload { - key: key.clone(), - state: state.clone(), - mode: mode.clone(), - }) - .unwrap_or_default(); - make_envelope("key_event", seq, payload) - } - ObsBroadcast::SettingsDiff(diff) => make_envelope("settings_diff", seq, diff.clone()), - ObsBroadcast::LayoutDiff(diff) => make_envelope("layout_diff", seq, diff.clone()), - ObsBroadcast::CounterUpdate(data) => make_envelope("counter_update", seq, data.clone()), ObsBroadcast::Snapshot(snapshot) => make_envelope("snapshot", seq, snapshot.clone()), ObsBroadcast::TauriEvent { event, data } => make_envelope( "tauri_event", diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 39047e14..a280ca74 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -31,7 +31,7 @@ use crate::{ audio::{KeySoundEngine, KeySoundStatus}, keyboard::KeyboardManager, models::{ - obs::KeyState as ObsKeyState, overlay_resize_anchor_from_str, BootstrapOverlayState, + overlay_resize_anchor_from_str, BootstrapOverlayState, BootstrapPayload, DefaultsPayload, KeyCounterSettings, KeyCounters, KeyMappings, OverlayBounds, OverlayResizeAnchor, SettingsDiff, SettingsState, }, @@ -274,12 +274,8 @@ impl AppState { if let Some(value) = diff.changed.key_counter_enabled { self.key_counter_enabled.store(value, Ordering::SeqCst); } - // OBS 브릿지 설정 변경 브로드캐스트 + 캐시 갱신 + // OBS 브릿지 캐시 갱신 (이벤트는 register_event_forwarding이 자동 포워딩) if self.obs_bridge.is_running() { - if let Ok(diff_json) = serde_json::to_value(&diff.changed) { - self.obs_bridge.broadcast_settings_diff(diff_json); - } - // cached_snapshot도 갱신 (새 클라이언트 접속 시 최신 설정 제공) let bp = self.bootstrap_payload(); if let Ok(snap) = serde_json::to_value(&bp) { self.obs_bridge.update_snapshot(snap); @@ -395,28 +391,26 @@ impl AppState { } } - /// OBS 브릿지에 설정 diff 전송 + 캐시 스냅샷 갱신 (전체 스냅샷 broadcast 없음) + /// OBS 브릿지 캐시 스냅샷 갱신 (이벤트는 register_event_forwarding이 자동 포워딩) /// CSS 등 개별 설정 변경이 OBS 런타임 상태(키 시그널, KPS)를 리셋하지 않도록 사용 - pub fn notify_obs_settings_diff(&self, diff: serde_json::Value) { + pub fn notify_obs_settings_diff(&self, _diff: serde_json::Value) { if !self.obs_bridge.is_running() { return; } - self.obs_bridge.broadcast_settings_diff(diff); - // 캐시 스냅샷 갱신 (새 클라이언트 접속 시 최신 설정 제공) let bp = self.bootstrap_payload(); if let Ok(snap) = serde_json::to_value(&bp) { self.obs_bridge.update_snapshot(snap); } } - /// OBS 브릿지에 카운터 상태 브로드캐스트 + /// OBS 브릿지 캐시 스냅샷 갱신 (카운터 이벤트는 register_event_forwarding이 자동 포워딩) pub fn obs_broadcast_counters(&self) { if !self.obs_bridge.is_running() { return; } - let counters = self.snapshot_key_counters(); - if let Ok(data) = serde_json::to_value(&counters) { - self.obs_bridge.broadcast_counter_update(data); + let bp = self.bootstrap_payload(); + if let Ok(snap) = serde_json::to_value(&bp) { + self.obs_bridge.update_snapshot(snap); } } @@ -966,20 +960,6 @@ impl AppState { } let payload = json!({ "key": key_label, "state": state, "mode": mode }); - // OBS 브릿지 키 이벤트 브로드캐스트 - if app_state.obs_bridge.is_running() { - let obs_key_state = if state == "DOWN" { - ObsKeyState::Down - } else { - ObsKeyState::Up - }; - app_state.obs_bridge.broadcast_key_event( - key_label.to_string(), - obs_key_state, - mode.to_string(), - ); - } - let mut emitted = false; if let Some(overlay) = overlay_window.as_ref() { match overlay.emit("keys:state", &payload) { diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts index 02e80805..c15c5136 100644 --- a/src/renderer/api/ipcShim.ts +++ b/src/renderer/api/ipcShim.ts @@ -267,33 +267,6 @@ function onWsMessage(envelope: ObsEnvelope) { break; } - // [과도기] 기존 WS 메시지 → Tauri 이벤트 매핑 - // 백엔드가 tauri_event로 통합하면 아래 case들 제거 - case 'key_event': - dispatchEvent('keys:state', envelope.payload); - break; - - case 'settings_diff': { - const patch = envelope.payload as Record; - dispatchEvent('settings:changed', { changed: patch }); - if (snapshotCache) { - Object.assign(snapshotCache.settings, patch); - } - break; - } - - case 'counter_update': { - const counters = envelope.payload as Record< - string, - Record - >; - dispatchEvent('keys:counters', counters); - if (snapshotCache) { - snapshotCache.keyCounters = counters; - } - break; - } - case 'snapshot': { const snapshot = envelope.payload as BootstrapPayload; const prev = snapshotCache; From 3aeb48e6a5d1d9d4cc103370fa77bd616696face Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 13:56:10 +0900 Subject: [PATCH 38/56] =?UTF-8?q?fix:=20snapshotCache=20=EC=A6=9D=EB=B6=84?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0=20=EB=B3=B5=EC=9B=90=20+=20obs.ts=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=ED=83=80=EC=9E=85=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ipcShim.ts: tauri_event 핸들러에서 settings:changed, keys:counters 수신 시 snapshotCache를 증분 갱신 (getter 폴백 정합성 유지) - obs.ts: KeyEventPayload, ObsMessageType 미사용 타입 제거 Co-Authored-By: Claude Opus 4.6 --- src/renderer/api/ipcShim.ts | 14 ++++++++++++++ src/types/obs.ts | 24 ------------------------ 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts index c15c5136..460c069f 100644 --- a/src/renderer/api/ipcShim.ts +++ b/src/renderer/api/ipcShim.ts @@ -244,6 +244,20 @@ function onWsMessage(envelope: ObsEnvelope) { event: string; data: unknown; }; + // snapshotCache 증분 갱신 (getter 폴백 정합성 유지) + if (snapshotCache) { + if (event === 'settings:changed') { + const patch = (data as Record)?.changed; + if (patch && typeof patch === 'object') { + Object.assign(snapshotCache.settings, patch); + } + } else if (event === 'keys:counters') { + snapshotCache.keyCounters = data as Record< + string, + Record + >; + } + } dispatchEvent(event, data); break; } diff --git a/src/types/obs.ts b/src/types/obs.ts index 717497b5..9793a030 100644 --- a/src/types/obs.ts +++ b/src/types/obs.ts @@ -29,33 +29,9 @@ export interface HelloAckPayload { denyList?: string[]; } -export interface KeyEventPayload { - key: string; - state: 'DOWN' | 'UP'; - mode: string; -} - export interface ObsStatus { running: boolean; port: number; clientCount: number; token?: string; } - -// ── WS 메시지 타입 문자열 ── - -export type ObsMessageType = - | 'hello' - | 'hello_ack' - | 'snapshot' - | 'key_event' - | 'settings_diff' - | 'layout_diff' - | 'counter_update' - | 'ping' - | 'pong' - | 'resync_request' - | 'error' - | 'invoke_request' - | 'invoke_response' - | 'tauri_event'; From 8ee4a8d040c7bc891a12eb61d6b3d45dbcb26031 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 14:09:20 +0900 Subject: [PATCH 39/56] =?UTF-8?q?refactor:=20ipcShim=EC=9D=84=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=20generic=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=BA=90=EC=8B=9C/=EB=B6=84=EA=B8=B0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleCacheCommand + snapshotCache 제거 (모든 invoke는 WS RPC로) - tauri_event 증분 캐싱 분기 제거 (settings:changed, keys:counters) - snapshot 개별 이벤트 디스패치 제거 (12개 dispatchEvent 호출) - DEFAULT_DENY_LIST 하드코딩 폴백 제거 (백엔드 단일 책임) - onWsMessage: tauri_event/invoke_response/snapshot 모두 generic 처리 Co-Authored-By: Claude Opus 4.6 --- src/renderer/api/ipcShim.ts | 170 +++--------------------------------- 1 file changed, 10 insertions(+), 160 deletions(-) diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts index 460c069f..a4fc4e20 100644 --- a/src/renderer/api/ipcShim.ts +++ b/src/renderer/api/ipcShim.ts @@ -8,12 +8,10 @@ * 설계 원칙 (§12.4): * - 커맨드별 분기 없음. 3단계만: plugin:event → deny → WS RPC * - deny 리스트는 hello_ack에서 수신 (백엔드가 유일한 source of truth) - * - 백엔드 WS RPC 미구현 시 캐시 폴백 (과도기) */ import { OBS_PROTOCOL_VERSION } from '@src/types/obs'; import type { ObsEnvelope, HelloAckPayload } from '@src/types/obs'; -import type { BootstrapPayload } from '@src/types/app'; // ── 내부 상태 ── @@ -26,45 +24,8 @@ let connHost = '127.0.0.1'; let connPort = '34891'; let connToken = ''; -// deny 리스트 — hello_ack에서 수신 (백엔드가 source of truth) -// 구 버전 백엔드가 denyList를 보내지 않을 때의 폴백 -const DEFAULT_DENY_LIST = [ - // 오버레이 제어 (OBS에서 조작 불가) - 'overlay_resize', - 'overlay_set_visible', - 'overlay_set_lock', - 'overlay_set_anchor', - 'overlay_get', - // 윈도우/앱 제어 - 'window_minimize', - 'window_close', - 'window_show_main', - 'window_open_devtools_all', - 'app_quit', - 'app_restart', - 'app_open_external', - 'app_auto_update', - // OBS 서버 제어 (자기 자신 종료/재시작 방지) - 'obs_start', - 'obs_stop', - // 파일 대화상자 / 파일 쓰기 (로컬 파일 시스템 접근) - 'image_load', - 'font_load', - 'sound_load', - 'sound_save_processed_wav', - 'css_load', - 'css_reset', - 'js_load', - 'js_reset', - 'js_reload', - 'preset_load', - 'preset_load_tab', - // Tauri 플러그인 (네이티브 윈도우/메뉴/리소스) - 'plugin:window|', - 'plugin:menu|', - 'plugin:resources|', -]; -let denyList: string[] = DEFAULT_DENY_LIST; +// deny 리스트 — hello_ack에서 수신 (백엔드가 유일한 source of truth) +let denyList: string[] = []; // 콜백 레지스트리 (transformCallback/runCallback) const callbacks = new Map void>(); @@ -84,60 +45,8 @@ const pendingRpc = new Map< { resolve: (value: unknown) => void; reject: (reason: unknown) => void } >(); -// ── [과도기] 캐시 — 백엔드 WS RPC 구현 후 제거 예정 ── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let snapshotCache: (BootstrapPayload & Record) | null = null; - -/** - * [과도기] 백엔드 WS RPC가 없는 동안 snapshot 캐시에서 응답. - * §12.11 백엔드 구현 후 이 함수와 snapshotCache를 제거. - */ -function handleCacheCommand( - cmd: string, - _args: Record, -): unknown | undefined { - if (!snapshotCache) return undefined; - - switch (cmd) { - case 'app_bootstrap': - return snapshotCache; - case 'settings_get': - return snapshotCache.settings; - case 'keys_get': - return snapshotCache.keys; - case 'positions_get': - return snapshotCache.positions; - case 'stat_positions_get': - return snapshotCache.statPositions; - case 'graph_positions_get': - return snapshotCache.graphPositions; - case 'custom_tabs_list': - return snapshotCache.customTabs; - case 'layer_groups_get': - return snapshotCache.layerGroups ?? {}; - case 'note_tab_get_all': - return snapshotCache.tabNoteOverrides ?? {}; - case 'css_get': - return snapshotCache.settings?.customCSS ?? { content: '', path: null }; - case 'css_get_use': - return snapshotCache.settings?.useCustomCSS ?? false; - case 'css_tab_get_all': - return snapshotCache.tabCssOverrides ?? {}; - case 'js_get': - return ( - snapshotCache.settings?.customJS ?? { - content: '', - path: null, - plugins: [], - } - ); - case 'js_get_use': - return snapshotCache.settings?.useCustomJS ?? false; - default: - return undefined; - } -} +// snapshot 수신 여부 (initIpcShim에서 연결 준비 확인용) +let snapshotReceived = false; // ── deny 체크 ── @@ -244,20 +153,6 @@ function onWsMessage(envelope: ObsEnvelope) { event: string; data: unknown; }; - // snapshotCache 증분 갱신 (getter 폴백 정합성 유지) - if (snapshotCache) { - if (event === 'settings:changed') { - const patch = (data as Record)?.changed; - if (patch && typeof patch === 'object') { - Object.assign(snapshotCache.settings, patch); - } - } else if (event === 'keys:counters') { - snapshotCache.keyCounters = data as Record< - string, - Record - >; - } - } dispatchEvent(event, data); break; } @@ -282,47 +177,8 @@ function onWsMessage(envelope: ObsEnvelope) { } case 'snapshot': { - const snapshot = envelope.payload as BootstrapPayload; - const prev = snapshotCache; - snapshotCache = snapshot; - - // 개별 이벤트 디스패치 (useAppBootstrap이 구독) - dispatchEvent('keys:changed', snapshot.keys); - dispatchEvent('positions:changed', snapshot.positions); - dispatchEvent('statPositions:changed', snapshot.statPositions); - dispatchEvent('graphPositions:changed', snapshot.graphPositions); - dispatchEvent('keys:mode-changed', { - mode: snapshot.selectedKeyType, - }); - dispatchEvent('keys:counters', snapshot.keyCounters); - - dispatchEvent('customTabs:changed', { - customTabs: snapshot.customTabs, - selectedKeyType: snapshot.selectedKeyType, - }); - - dispatchEvent('tabNote:changed_all', snapshot.tabNoteOverrides ?? {}); - - dispatchEvent('layerGroups:changed', snapshot.layerGroups ?? {}); - - // preset:snapshot (프리셋 로드 시) - if (prev) { - dispatchEvent('preset:snapshot', { - keys: snapshot.keys, - positions: snapshot.positions, - statPositions: snapshot.statPositions, - graphPositions: snapshot.graphPositions, - customTabs: snapshot.customTabs, - selectedKeyType: snapshot.selectedKeyType, - tabNoteOverrides: snapshot.tabNoteOverrides ?? {}, - }); - } - - if (snapshot.settings) { - dispatchEvent('settings:changed', { - changed: snapshot.settings, - }); - } + // 재연결 시 snapshot 수신 — 연결 준비 신호로만 사용 + snapshotReceived = true; break; } } @@ -367,13 +223,7 @@ async function shimInvoke( return; } - // 3-a. [과도기] 캐시 폴백 — 백엔드 WS RPC 구현 후 제거 - const cached = handleCacheCommand(cmd, args); - if (cached !== undefined) { - return cached; - } - - // 3-b. WS RPC (백엔드가 처리) + // 3. WS RPC (백엔드가 처리) if (!ws || ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error(`[IPC Shim] WS not connected: ${cmd}`)); } @@ -482,7 +332,7 @@ export function initIpcShim(wsUrl: string, token: string): Promise { // snapshot 수신 시 글로벌 설치 후 resolve if (envelope.type === 'snapshot' && !resolved) { - snapshotCache = envelope.payload as BootstrapPayload; + snapshotReceived = true; installGlobals(); resolved = true; resolve(); @@ -575,6 +425,6 @@ export function disposeIpcShim() { callbacks.clear(); eventListeners.clear(); eventListenersByName.clear(); - denyList = DEFAULT_DENY_LIST; - snapshotCache = null; + denyList = []; + snapshotReceived = false; } From 2783c5746102bff84523931906ee002f5f14b4c2 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 14:28:56 +0900 Subject: [PATCH 40/56] =?UTF-8?q?refactor:=20listen=20=E2=86=92=20listen?= =?UTF-8?q?=5Fany=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20+=20OBS=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - register_event_forwarding에서 app.listen → app.listen_any로 변경 (emit_to로 특정 윈도우에 보낸 이벤트도 OBS에 포워딩) - CLAUDE.md, AGENTS.md에 OBS 이벤트 포워딩 등록 가이드 추가 Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ src-tauri/src/services/obs_bridge.rs | 8 ++++---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7809763..a4bd149d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,3 +144,8 @@ src-tauri/src/ 2. **린트**: `cd src-tauri && cargo clippy` 3. **포맷팅**: `cd src-tauri && cargo fmt` 4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인 + +### OBS 모드 이벤트 포워딩 + +- 새로운 Tauri 이벤트(`app.emit("event:name", ...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 필요 +- OBS 모드의 IPC shim(`src/renderer/api/ipcShim.ts`)은 generic 설계이므로 수정 불필요 — 백엔드에서 포워딩만 등록하면 프론트는 자동으로 수신 diff --git a/CLAUDE.md b/CLAUDE.md index 73006a12..80637ad9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,3 +144,8 @@ src-tauri/src/ 2. **린트**: `cd src-tauri && cargo clippy` 3. **포맷팅**: `cd src-tauri && cargo fmt` 4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인 + +### OBS 모드 이벤트 포워딩 + +- 새로운 Tauri 이벤트(`app.emit("event:name", ...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 필요 +- OBS 모드의 IPC shim(`src/renderer/api/ipcShim.ts`)은 generic 설계이므로 수정 불필요 — 백엔드에서 포워딩만 등록하면 프론트는 자동으로 수신 diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index cf23ec85..83496161 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -15,7 +15,7 @@ use uuid::Uuid; use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; use tauri::webview::InvokeRequest; -use tauri::{AppHandle, Manager, Wry}; +use tauri::{AppHandle, Listener, Manager, Wry}; use crate::models::obs::{ make_envelope, HelloAckPayload, InvokeRequestPayload, ObsBroadcast, ObsEnvelope, ObsStatus, @@ -138,8 +138,6 @@ impl ObsBridgeService { /// Tauri 이벤트를 OBS WS 클라이언트에 포워딩하는 리스너 등록 pub fn register_event_forwarding(&self, app: &AppHandle) { - use tauri::Listener; - // 기존 리스너 해제 (중복 호출 시 누적 방지) for id in self.event_listener_ids.write().drain(..) { app.unlisten(id); @@ -171,11 +169,13 @@ impl ObsBridgeService { "plugin-bridge:message", ]; + // listen_any: 모든 타깃(App/Window/Webview)의 이벤트를 캡처 + // emit_to()로 특정 윈도우에 보낸 이벤트도 포워딩됨 let mut ids = Vec::with_capacity(forwarded_events.len()); for event_name in &forwarded_events { let tx = self.broadcast_tx.clone(); let name = event_name.to_string(); - let id = app.listen(*event_name, move |evt| { + let id = app.listen_any(*event_name, move |evt| { let data: Value = serde_json::from_str(evt.payload()).unwrap_or(Value::Null); let _ = tx.send(ObsBroadcast::TauriEvent { event: name.clone(), From b1f20aa5891b80e937bbfd6277d67cba492337ee Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 14:29:43 +0900 Subject: [PATCH 41/56] =?UTF-8?q?docs:=20OBS=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=EB=A5=BC=20Tauri=20=EC=BB=A4?= =?UTF-8?q?=EB=A7=A8=EB=93=9C=20=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20+=20deny=20list=20=EC=84=9C=EC=88=A0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 10 ++++++---- CLAUDE.md | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a4bd149d..0fa95104 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,12 @@ src-tauri/src/ - 동기 `fn` 기본, `async fn`은 실제 await가 필요한 경우만 사용 - 에러 타입: `Result` (향후 `CmdResult` 전환 예정) +### OBS 모드 (WebSocket 브릿지) + +- **이벤트 포워딩**: 새 Tauri 이벤트(`app.emit(...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 +- **deny 리스트**: OBS 클라이언트에서 실행 불가능한 커맨드는 `obs_bridge.rs`의 `DENIED_WS_COMMANDS`에 등록 (백엔드가 유일한 source of truth) +- **IPC shim**: `src/renderer/api/ipcShim.ts`는 generic 설계 — 커맨드/이벤트별 분기 없음. 이벤트나 커맨드 추가 시 수정 불필요 + ### 주석 - 기술 용어(React, Tauri, KPS 등)를 제외하면 **한글**로 작성 @@ -145,7 +151,3 @@ src-tauri/src/ 3. **포맷팅**: `cd src-tauri && cargo fmt` 4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인 -### OBS 모드 이벤트 포워딩 - -- 새로운 Tauri 이벤트(`app.emit("event:name", ...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 필요 -- OBS 모드의 IPC shim(`src/renderer/api/ipcShim.ts`)은 generic 설계이므로 수정 불필요 — 백엔드에서 포워딩만 등록하면 프론트는 자동으로 수신 diff --git a/CLAUDE.md b/CLAUDE.md index 80637ad9..4156b55a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,12 @@ src-tauri/src/ - 동기 `fn` 기본, `async fn`은 실제 await가 필요한 경우만 사용 - 에러 타입: `Result` (향후 `CmdResult` 전환 예정) +### OBS 모드 (WebSocket 브릿지) + +- **이벤트 포워딩**: 새 Tauri 이벤트(`app.emit(...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 +- **deny 리스트**: OBS 클라이언트에서 실행 불가능한 커맨드는 `obs_bridge.rs`의 `DENIED_WS_COMMANDS`에 등록 (백엔드가 유일한 source of truth) +- **IPC shim**: `src/renderer/api/ipcShim.ts`는 generic 설계 — 커맨드/이벤트별 분기 없음. 이벤트나 커맨드 추가 시 수정 불필요 + ### 주석 - 기술 용어(React, Tauri, KPS 등)를 제외하면 **한글**로 작성 @@ -145,7 +151,3 @@ src-tauri/src/ 3. **포맷팅**: `cd src-tauri && cargo fmt` 4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인 -### OBS 모드 이벤트 포워딩 - -- 새로운 Tauri 이벤트(`app.emit("event:name", ...)`)를 추가할 때, OBS 오버레이에도 전달되어야 하면 `src-tauri/src/services/obs_bridge.rs`의 `register_event_forwarding()` 이벤트 목록에 등록 필요 -- OBS 모드의 IPC shim(`src/renderer/api/ipcShim.ts`)은 generic 설계이므로 수정 불필요 — 백엔드에서 포워딩만 등록하면 프론트는 자동으로 수신 From 8ff86682332c85a3552e13bf6d1c5611e07709b8 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 15:12:28 +0900 Subject: [PATCH 42/56] =?UTF-8?q?feat:=20OBS=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=9C=88?= =?UTF-8?q?=EB=8F=84=EC=9A=B0=20destroy=20+=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obs_hide_overlay: hide 대신 window.close()로 오버레이 destroy (메모리/GPU 절약) - overlay_obs_close 플래그로 CloseRequested 핸들러에서 OBS close 허용 - obs_restore_overlay: 플래그 리셋 후 set_overlay_visibility(true)로 윈도우 재생성 - auto_start_obs: OBS 모드 부팅 시 오버레이 생성 건너뛰기 (create→destroy 낭비 방지) - obs:status 이벤트 emit + 프론트 이벤트 구독 (obsApi.onStatus) - Settings.tsx: 전체 3초 폴링 → 이벤트 구독 + clientCount 5초 폴링으로 전환 - SettingTool: OBS 모드 시 오버레이 토글 버튼 비활성화 (disabled + 전용 툴팁) Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 14 ++-- src-tauri/src/state/app_state.rs | 70 +++++++++++++------ src/renderer/api/modules/obsApi.ts | 3 + src/renderer/components/main/Settings.tsx | 37 ++++++---- .../components/main/Tool/SettingTool.tsx | 45 +++++++++--- src/renderer/locales/en.json | 1 + src/renderer/locales/ko.json | 1 + src/renderer/locales/ru.json | 1 + src/renderer/locales/zh-Hant.json | 1 + src/renderer/locales/zh-cn.json | 1 + 10 files changed, 123 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index ae33a697..00f00dc2 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, State}; use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; @@ -44,17 +44,21 @@ pub async fn obs_start( .map_err(crate::errors::CommandError::msg)?; // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) state.refresh_obs_snapshot(); - // 오버레이 숨김 (이전 상태 보존) + // 오버레이 destroy (이전 상태 보존) state.obs_hide_overlay(&app); - Ok(state.obs_bridge.status()) + let status = state.obs_bridge.status(); + let _ = app.emit("obs:status", &status); + Ok(status) } #[tauri::command] pub fn obs_stop(app: AppHandle, state: State<'_, AppState>) -> CmdResult { state.obs_bridge.stop(); - // 오버레이 복원 + // 오버레이 재생성 + 복원 state.obs_restore_overlay(&app); - Ok(state.obs_bridge.status()) + let status = state.obs_bridge.status(); + let _ = app.emit("obs:status", &status); + Ok(status) } #[tauri::command] diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index a280ca74..e0426457 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -67,6 +67,8 @@ pub struct AppState { pub obs_bridge: Arc, /// OBS 모드 시작 전 오버레이 가시성 상태 (복원용) obs_previous_overlay_visible: Arc>>, + /// OBS 모드에서 오버레이 윈도우를 destroy할 때 close를 허용하는 플래그 + overlay_obs_close: Arc, } impl AppState { @@ -100,14 +102,18 @@ impl AppState { css_watcher: RwLock::new(None), obs_bridge, obs_previous_overlay_visible: Arc::new(RwLock::new(None)), + overlay_obs_close: Arc::new(AtomicBool::new(false)), }) } pub fn initialize_runtime(&self, app: &AppHandle) -> Result<()> { self.attach_main_window_handlers(app); - self.ensure_overlay_window(app)?; - // 개발자 모드가 켜져 있으면 시작 시 DevTools 오픈 허용 및 자동 오픈 시도 let snapshot = self.store.snapshot(); + // OBS 모드가 활성화된 상태로 부팅하면 오버레이 생성 건너뛰기 (create→destroy 낭비 방지) + if !snapshot.obs_mode_enabled { + self.ensure_overlay_window(app)?; + } + // 개발자 모드가 켜져 있으면 시작 시 DevTools 오픈 허용 및 자동 오픈 시도 if snapshot.developer_mode_enabled { if let Some(main) = app.get_webview_window("main") { let _ = main.open_devtools(); @@ -318,17 +324,18 @@ impl AppState { // Tauri 이벤트 → OBS WS 포워딩 리스너 등록 bridge.register_event_forwarding(app); - // 오버레이 숨김 (부팅 시 flash 방지) - self.obs_hide_overlay(app); - - let obs_previous_overlay_visible = self.obs_previous_overlay_visible.clone(); - let overlay_visible = self.overlay_visible.clone(); + // 부팅 시에는 오버레이를 생성하지 않았으므로 상태만 저장 + // (initialize_runtime에서 obs_mode_enabled일 때 ensure_overlay_window 건너뜀) + let was_visible = self.store.with_state(|s| s.overlay_visible); + *self.obs_previous_overlay_visible.write() = Some(was_visible); // async start를 tokio 런타임에서 실행 tauri::async_runtime::spawn(async move { match bridge.start(port).await { Ok(()) => { log::info!("[ObsBridge] auto-start 성공 (port={})", port); + let state = app_handle.state::(); + let _ = app_handle.emit("obs:status", &state.obs_bridge.status()); } Err(e) => { log::error!( @@ -338,36 +345,51 @@ impl AppState { let _ = store.update(|state| { state.obs_mode_enabled = false; }); - // 실패 시 오버레이 상태 복원 (플래그 + 실제 윈도우) - if let Some(true) = obs_previous_overlay_visible.write().take() { - *overlay_visible.write() = true; - let _ = store.update(|state| { - state.overlay_visible = true; - }); - if let Some(window) = app_handle.get_webview_window(OVERLAY_LABEL) { - let _ = window.show(); - } - } + // 실패 시 오버레이 복원 (윈도우 재생성 포함) + let state = app_handle.state::(); + state.obs_restore_overlay(&app_handle); + let _ = app_handle.emit("obs:status", &state.obs_bridge.status()); } } }); } - /// OBS 시작 시 오버레이 숨김 (이전 상태 보존) + /// 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); - if was_visible { - if let Err(e) = self.set_overlay_visibility(app, false) { - log::warn!("[ObsBridge] 오버레이 숨기기 실패: {}", e); + // 오버레이 윈도우 destroy + if let Some(window) = app.get_webview_window(OVERLAY_LABEL) { + self.overlay_obs_close.store(true, Ordering::SeqCst); + if let Err(e) = window.close() { + log::warn!("[ObsBridge] 오버레이 destroy 실패: {}", e); + self.overlay_obs_close.store(false, Ordering::SeqCst); + // close 실패 시 hide로 fallback + if was_visible { + if let Err(e) = self.set_overlay_visibility(app, false) { + log::warn!("[ObsBridge] 오버레이 hide fallback 실패: {}", e); + } + } + return; } } + // close 성공(또는 윈도우 부재) 후 가시성 상태 갱신 + *self.overlay_visible.write() = false; + let _ = self.store.update(|state| { + state.overlay_visible = false; + }); + let _ = app.emit("overlay:visibility", &json!({ "visible": false })); } - /// OBS 중지 시 오버레이 복원 + /// OBS 중지 시 오버레이 재생성 + 복원 pub fn obs_restore_overlay(&self, app: &AppHandle) { + // 이전 close() 요청이 아직 처리 중일 수 있으므로 플래그를 리셋하여 + // 지연된 CloseRequested가 새로 복원된 오버레이를 destroy하지 않도록 방지 + self.overlay_obs_close.store(false, Ordering::SeqCst); + let prev = self.obs_previous_overlay_visible.write().take(); if let Some(true) = prev { + // set_overlay_visibility(true) 내부에서 ensure_overlay_window + show + store 갱신 + emit 처리 if let Err(e) = self.set_overlay_visibility(app, true) { log::warn!("[ObsBridge] 오버레이 복원 실패: {}", e); } @@ -1201,12 +1223,16 @@ impl AppState { let app_handle = app.clone(); let overlay_window = window.clone(); let force_close_flag = self.overlay_force_close.clone(); + let obs_close_flag = self.overlay_obs_close.clone(); let initializing_flag = self.overlay_initializing.clone(); window.on_window_event(move |event| match event { WindowEvent::CloseRequested { api, .. } => { if force_close_flag.swap(false, Ordering::SeqCst) { + // 앱 종료 시 — 실제 close 허용 *overlay_visible.write() = false; + } else if obs_close_flag.swap(false, Ordering::SeqCst) { + // OBS 모드 시 — 실제 close 허용 (visibility는 obs_hide_overlay에서 처리) } else { api.prevent_close(); if let Err(err) = overlay_window.hide() { diff --git a/src/renderer/api/modules/obsApi.ts b/src/renderer/api/modules/obsApi.ts index 4c9312db..36b814e5 100644 --- a/src/renderer/api/modules/obsApi.ts +++ b/src/renderer/api/modules/obsApi.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; +import { subscribe } from './shared'; import type { ObsStatus } from '@src/types/obs'; @@ -6,4 +7,6 @@ export const obsApi = { start: (port?: number) => invoke('obs_start', { port }), stop: () => invoke('obs_stop'), status: () => invoke('obs_status'), + onStatus: (listener: (status: ObsStatus) => void) => + subscribe('obs:status', listener), }; diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 1debef31..bfe47c12 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -174,27 +174,36 @@ const Settings = ({ } }, [isMacOS, angleMode, setAngleMode]); - // OBS 상태 초기 로드 + 주기적 폴링 + // OBS 상태 이벤트 구독 + clientCount 폴링 useEffect(() => { let mounted = true; - const fetchStatus = async () => { + obsApi + .status() + .then((status) => { + if (mounted) setObsStatus(status); + }) + .catch(() => undefined); + + // start/stop 이벤트 구독 + const unsubscribe = obsApi.onStatus((status) => { + if (mounted) setObsStatus(status); + }); + + // clientCount는 connect/disconnect 이벤트가 없으므로 폴링 유지 + const interval = setInterval(async () => { try { const status = await obsApi.status(); - if (!mounted) return; - setObsStatus((prev) => - prev.running === status.running && - prev.port === status.port && - prev.clientCount === status.clientCount && - prev.token === status.token - ? prev - : status, - ); + if (mounted) { + setObsStatus((prev) => + prev.clientCount === status.clientCount ? prev : status, + ); + } } catch {} - }; - fetchStatus(); - const interval = setInterval(fetchStatus, 3000); + }, 5000); + return () => { mounted = false; + unsubscribe(); clearInterval(interval); }; }, []); diff --git a/src/renderer/components/main/Tool/SettingTool.tsx b/src/renderer/components/main/Tool/SettingTool.tsx index 61641a92..c305e515 100644 --- a/src/renderer/components/main/Tool/SettingTool.tsx +++ b/src/renderer/components/main/Tool/SettingTool.tsx @@ -17,6 +17,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { getCounterSnapshot } from '@stores/signals/keyCounterSignals'; +import { obsApi } from '@api/modules/obsApi'; interface SettingToolProps { isSettingsOpen?: boolean; @@ -35,6 +36,7 @@ const SettingTool = ({ SettingToolProps) => { const { t } = useTranslation(); const [isOverlayVisible, setIsOverlayVisible] = useState(true); + const [isObsModeActive, setIsObsModeActive] = useState(false); const [isExportImportOpenLocal, setIsExportImportOpenLocal] = useState(false); // const [isExtrasOpen, setIsExtrasOpenLocal] = useState(false); const exportImportRef = useRef(null); @@ -89,6 +91,20 @@ SettingToolProps) => { }; }, []); + // OBS 모드 상태 구독 + useEffect(() => { + obsApi + .status() + .then((status) => setIsObsModeActive(status.running)) + .catch(() => undefined); + + const unsubscribeObs = obsApi.onStatus((status) => { + setIsObsModeActive(status.running); + }); + + return () => unsubscribeObs(); + }, []); + // const menuItems: ListItem[] = [ // { id: "note", label: t("tooltip.noteSettings"), disabled: !noteEffect }, // ]; @@ -275,14 +291,17 @@ SettingToolProps) => {
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index c520f4fe..3bb2a342 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -187,6 +187,7 @@ "importExport": "Import/Export", "overlayClose": "Close Overlay", "overlayOpen": "Open Overlay", + "overlayObsDisabled": "OBS mode active", "back": "Back", "settings": "Settings", "etcSettings": "Etc", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 9c70929d..09d7cde7 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -187,6 +187,7 @@ "importExport": "불러오기/내보내기", "overlayClose": "오버레이 닫기", "overlayOpen": "오버레이 열기", + "overlayObsDisabled": "OBS 모드 사용 중", "back": "돌아가기", "settings": "설정", "etcSettings": "기타 설정", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 441854ce..f6f7873e 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -187,6 +187,7 @@ "importExport": "Импорт/экспорт", "overlayClose": "Закрыть оверлей", "overlayOpen": "Открыть оверлей", + "overlayObsDisabled": "Режим OBS активен", "back": "Назад", "settings": "Настройки", "etcSettings": "Прочее", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 5a78c22f..8cc9ca9c 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -187,6 +187,7 @@ "importExport": "導入/導出", "overlayClose": "關閉懸浮窗", "overlayOpen": "打開懸浮窗", + "overlayObsDisabled": "OBS 模式使用中", "back": "返回", "settings": "設定", "etcSettings": "其他設定", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index e38745b8..ac2d3b00 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -187,6 +187,7 @@ "importExport": "导入/导出", "overlayClose": "关闭悬浮窗", "overlayOpen": "打开悬浮窗", + "overlayObsDisabled": "OBS 模式使用中", "back": "返回", "settings": "设置", "etcSettings": "其他设置", From f2b95ac8f1281467cd4daf56116e063cfdf7bcb4 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 15:24:37 +0900 Subject: [PATCH 43/56] =?UTF-8?q?fix:=20auto=5Fstart=5Fobs=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B4=88=EA=B8=B0=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bridge.start() 성공 후 refresh_obs_snapshot()을 호출하지 않아 OBS 브라우저 연결 시 키 위치/노트 효과 등 초기 상태가 비어있던 문제 수정 Co-Authored-By: Claude Opus 4.6 --- .claude/skills/codex-debug/SKILL.md | 4 ++++ .claude/skills/codex-review/SKILL.md | 4 ++++ .claude/skills/plan/SKILL.md | 4 ++++ src-tauri/src/state/app_state.rs | 2 ++ 4 files changed, 14 insertions(+) diff --git a/.claude/skills/codex-debug/SKILL.md b/.claude/skills/codex-debug/SKILL.md index ce20d777..bf864117 100644 --- a/.claude/skills/codex-debug/SKILL.md +++ b/.claude/skills/codex-debug/SKILL.md @@ -109,6 +109,10 @@ codex exec -C "$(pwd)" -s danger-full-access --json "다음 증상의 원인 후 - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). - Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. - 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. +- **중요: 원인 확정/수정 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.** + - `status: running` → Codex가 조사 중이므로 대기. 대기 중에는 선분석 정리만 수행. + - `status: completed` → 결과를 수집하여 반영. + - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 디버깅). 대기하지 않음. ### 진행 상황 확인 백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다. diff --git a/.claude/skills/codex-review/SKILL.md b/.claude/skills/codex-review/SKILL.md index c9834d4b..d24327cd 100644 --- a/.claude/skills/codex-review/SKILL.md +++ b/.claude/skills/codex-review/SKILL.md @@ -72,6 +72,10 @@ codex exec resume --last "해당 이슈에 대한 구체적인 수정 코드를 - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). - Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. - 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. +- **중요: 리뷰 결론을 내리기 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.** + - `status: running` → Codex가 작업 중이므로 대기. 단독으로 리뷰를 완료하지 않음. + - `status: completed` → 결과를 수집하여 반영. + - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 리뷰). 대기하지 않음. ## 실패 처리 diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md index 047dac8e..d1f9ed5e 100644 --- a/.claude/skills/plan/SKILL.md +++ b/.claude/skills/plan/SKILL.md @@ -76,6 +76,10 @@ codex exec -C "$(pwd)" -s danger-full-access --json "다음 작업에 대한 구 - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). - Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. - 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. +- **중요: 결론을 내리기 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.** + - `status: running` → Codex가 작업 중이므로 대기. 대기 중에는 Claude 선분석 등 병렬 가능한 작업만 수행. + - `status: completed` → 결과를 수집하여 반영. + - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 진행). 대기하지 않음. ### 진행 상황 확인 백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다. diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index e0426457..9a1b3dc4 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -335,6 +335,8 @@ impl AppState { Ok(()) => { log::info!("[ObsBridge] auto-start 성공 (port={})", port); let state = app_handle.state::(); + // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) + state.refresh_obs_snapshot(); let _ = app_handle.emit("obs:status", &state.obs_bridge.status()); } Err(e) => { From 5591074868f8f65f92144a473a29f89e34eec931 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Sun, 8 Mar 2026 16:18:11 +0900 Subject: [PATCH 44/56] =?UTF-8?q?fix:=20obs=5Fstop=EC=9D=84=20async?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20WebView2=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20+=20destroy=20=EB=B0=A9=EC=8B=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - obs_stop을 async fn으로 변경 (sync 컨텍스트에서 WebviewWindowBuilder::build() 시 메시지 루프 차단으로 빈 창이 생성되는 Windows WebView2 문제 수정) - overlay_obs_close 플래그 제거 — destroy()는 CloseRequested 이벤트를 발생시키지 않으므로 별도 플래그 불필요 - obs_hide_overlay: close() → destroy()로 변경, store.overlay_visible 미변경 (ensure_overlay_window 재생성 시 원래 상태 기준으로 show/hide 결정) - invoke_request 디스패치: overlay 부재 시 main 윈도우 fallback 추가 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/app/obs.rs | 4 ++-- src-tauri/src/services/obs_bridge.rs | 10 +++++++--- src-tauri/src/state/app_state.rs | 25 ++++++------------------- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 00f00dc2..607ab5ed 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -52,9 +52,9 @@ pub async fn obs_start( } #[tauri::command] -pub fn obs_stop(app: AppHandle, state: State<'_, AppState>) -> CmdResult { +pub async fn obs_stop(app: AppHandle, state: State<'_, AppState>) -> CmdResult { state.obs_bridge.stop(); - // 오버레이 재생성 + 복원 + // 오버레이 재생성 + 복원 (async context에서 실행해야 WebView2 초기화가 정상 완료됨) state.obs_restore_overlay(&app); let status = state.obs_bridge.status(); let _ = app.emit("obs:status", &status); diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 83496161..e7a554a6 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -717,13 +717,17 @@ impl ObsBridgeService { } }; - let webview_window = match app_handle.get_webview_window("overlay") { + // OBS 모드에서 오버레이가 destroy된 상태일 수 있으므로 main window로 fallback + let webview_window = match app_handle + .get_webview_window("overlay") + .or_else(|| app_handle.get_webview_window("main")) + { Some(w) => w, None => { - log::warn!("[ObsBridge] {addr}: overlay webview 없음"); + log::warn!("[ObsBridge] {addr}: webview 없음 (overlay/main 모두)"); let _ = rpc_tx.send(( req.request_id, - Err(serde_json::json!("Overlay webview not found")), + Err(serde_json::json!("No webview window available")), )); return; } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 9a1b3dc4..4f3ddbcf 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -67,8 +67,6 @@ pub struct AppState { pub obs_bridge: Arc, /// OBS 모드 시작 전 오버레이 가시성 상태 (복원용) obs_previous_overlay_visible: Arc>>, - /// OBS 모드에서 오버레이 윈도우를 destroy할 때 close를 허용하는 플래그 - overlay_obs_close: Arc, } impl AppState { @@ -102,7 +100,6 @@ impl AppState { css_watcher: RwLock::new(None), obs_bridge, obs_previous_overlay_visible: Arc::new(RwLock::new(None)), - overlay_obs_close: Arc::new(AtomicBool::new(false)), }) } @@ -360,13 +357,11 @@ impl AppState { 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 + // destroy()는 CloseRequested 이벤트 없이 즉시 윈도우를 파괴 if let Some(window) = app.get_webview_window(OVERLAY_LABEL) { - self.overlay_obs_close.store(true, Ordering::SeqCst); - if let Err(e) = window.close() { + if let Err(e) = window.destroy() { log::warn!("[ObsBridge] 오버레이 destroy 실패: {}", e); - self.overlay_obs_close.store(false, Ordering::SeqCst); - // close 실패 시 hide로 fallback + // destroy 실패 시 hide로 fallback if was_visible { if let Err(e) = self.set_overlay_visibility(app, false) { log::warn!("[ObsBridge] 오버레이 hide fallback 실패: {}", e); @@ -375,20 +370,15 @@ impl AppState { return; } } - // close 성공(또는 윈도우 부재) 후 가시성 상태 갱신 + // destroy 성공(또는 윈도우 부재) 후 런타임 플래그만 갱신 + // store.overlay_visible은 변경하지 않음 — ensure_overlay_window가 재생성 시 + // 이 값을 기준으로 show/hide를 결정하므로, 원래 값을 유지해야 함 *self.overlay_visible.write() = false; - let _ = self.store.update(|state| { - state.overlay_visible = false; - }); let _ = app.emit("overlay:visibility", &json!({ "visible": false })); } /// OBS 중지 시 오버레이 재생성 + 복원 pub fn obs_restore_overlay(&self, app: &AppHandle) { - // 이전 close() 요청이 아직 처리 중일 수 있으므로 플래그를 리셋하여 - // 지연된 CloseRequested가 새로 복원된 오버레이를 destroy하지 않도록 방지 - self.overlay_obs_close.store(false, Ordering::SeqCst); - let prev = self.obs_previous_overlay_visible.write().take(); if let Some(true) = prev { // set_overlay_visibility(true) 내부에서 ensure_overlay_window + show + store 갱신 + emit 처리 @@ -1225,7 +1215,6 @@ impl AppState { let app_handle = app.clone(); let overlay_window = window.clone(); let force_close_flag = self.overlay_force_close.clone(); - let obs_close_flag = self.overlay_obs_close.clone(); let initializing_flag = self.overlay_initializing.clone(); window.on_window_event(move |event| match event { @@ -1233,8 +1222,6 @@ impl AppState { if force_close_flag.swap(false, Ordering::SeqCst) { // 앱 종료 시 — 실제 close 허용 *overlay_visible.write() = false; - } else if obs_close_flag.swap(false, Ordering::SeqCst) { - // OBS 모드 시 — 실제 close 허용 (visibility는 obs_hide_overlay에서 처리) } else { api.prevent_close(); if let Err(err) = overlay_window.hide() { From 5bbb353c13b7ebd8ac38acb4511152c338d7fcab Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Mon, 9 Mar 2026 17:48:01 +0900 Subject: [PATCH 45/56] =?UTF-8?q?agents:=20=EC=8A=A4=ED=82=AC=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 2 +- .../skills/codebase-memory-exploring/SKILL.md | 90 ++++++++++ .../skills/codebase-memory-quality/SKILL.md | 101 ++++++++++++ .../skills/codebase-memory-reference/SKILL.md | 154 ++++++++++++++++++ .../skills/codebase-memory-tracing/SKILL.md | 125 ++++++++++++++ .claude/skills/codex-debug/SKILL.md | 5 +- .claude/skills/{plan => codex-plan}/SKILL.md | 10 +- .claude/skills/codex-review/SKILL.md | 5 +- .../skills/codebase-memory-exploring/SKILL.md | 90 ++++++++++ .../skills/codebase-memory-quality/SKILL.md | 101 ++++++++++++ .../skills/codebase-memory-reference/SKILL.md | 154 ++++++++++++++++++ .../skills/codebase-memory-tracing/SKILL.md | 125 ++++++++++++++ .gitignore | 3 +- 13 files changed, 958 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/codebase-memory-exploring/SKILL.md create mode 100644 .claude/skills/codebase-memory-quality/SKILL.md create mode 100644 .claude/skills/codebase-memory-reference/SKILL.md create mode 100644 .claude/skills/codebase-memory-tracing/SKILL.md rename .claude/skills/{plan => codex-plan}/SKILL.md (90%) create mode 100644 .codex/skills/codebase-memory-exploring/SKILL.md create mode 100644 .codex/skills/codebase-memory-quality/SKILL.md create mode 100644 .codex/skills/codebase-memory-reference/SKILL.md create mode 100644 .codex/skills/codebase-memory-tracing/SKILL.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index cc7a2be9..e325e6b1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,7 +5,7 @@ 모든 구현 작업은 다음 워크플로를 따릅니다. ### 1. 계획 수립 (필수) -사용자의 구현 요청이나 설계 논의가 있으면 `/plan` 스킬을 사용하여 Codex와 함께 계획을 수립합니다. +사용자의 구현 요청이나 설계 논의가 있으면 `/codex-plan` 스킬을 사용하여 Codex와 함께 계획을 수립합니다. - 단순 버그 수정이나 1~2줄 변경이 아닌 이상 항상 plan을 먼저 실행 - Claude 선분석 → Codex 검증 → 최종 계획 확정 diff --git a/.claude/skills/codebase-memory-exploring/SKILL.md b/.claude/skills/codebase-memory-exploring/SKILL.md new file mode 100644 index 00000000..cc45a8be --- /dev/null +++ b/.claude/skills/codebase-memory-exploring/SKILL.md @@ -0,0 +1,90 @@ +--- +name: codebase-memory-exploring +description: > + This skill should be used when the user asks to "explore the codebase", + "understand the architecture", "what functions exist", "show me the structure", + "how is the code organized", "find functions matching", "search for classes", + "list all routes", "show API endpoints", or needs codebase orientation. +--- + +# Codebase Exploration via Knowledge Graph + +Use graph tools for structural code questions. They return precise results in ~500 tokens vs ~80K for grep-based exploration. + +## Workflow + +### Step 1: Check if project is indexed + +``` +list_projects +``` + +If the project is missing from the list: + +``` +index_repository(repo_path="/path/to/project") +``` + +If already indexed, skip — auto-sync keeps the graph fresh. + +### Step 2: Get a structural overview + +``` +get_graph_schema +``` + +This returns node label counts (functions, classes, routes, etc.), edge type counts, and relationship patterns. Use it to understand what's in the graph before querying. + +### Step 3: Find specific code elements + +Find functions by name pattern: +``` +search_graph(label="Function", name_pattern=".*Handler.*") +``` + +Find classes: +``` +search_graph(label="Class", name_pattern=".*Service.*") +``` + +Find all REST routes: +``` +search_graph(label="Route") +``` + +Find modules/packages: +``` +search_graph(label="Module") +``` + +Scope to a specific directory: +``` +search_graph(label="Function", qn_pattern=".*services\\.order\\..*") +``` + +### Step 4: Read source code + +After finding a function via search, read its source: +``` +get_code_snippet(qualified_name="project.path.to.FunctionName") +``` + +### Step 5: Understand structure + +For file/directory exploration within the indexed project: +``` +list_directory(path="src/services") +``` + +## When to Use Grep Instead + +- Searching for **string literals** or error messages → `search_code` or Grep +- Finding a file by exact name → Glob +- The graph doesn't index text content, only structural elements + +## Key Tips + +- Results default to 10 per page. Check `has_more` and use `offset` to paginate. +- Use `project` parameter when multiple repos are indexed. +- Route nodes have a `properties.handler` field with the actual handler function name. +- `exclude_labels` removes noise (e.g., `exclude_labels=["Route"]` when searching by name pattern). diff --git a/.claude/skills/codebase-memory-quality/SKILL.md b/.claude/skills/codebase-memory-quality/SKILL.md new file mode 100644 index 00000000..1542eee2 --- /dev/null +++ b/.claude/skills/codebase-memory-quality/SKILL.md @@ -0,0 +1,101 @@ +--- +name: codebase-memory-quality +description: > + This skill should be used when the user asks about "dead code", + "find dead code", "detect dead code", "show dead code", "dead code analysis", + "unused functions", "find unused functions", "unreachable code", + "identify high fan-out functions", "find complex functions", + "code quality audit", "find functions nobody calls", + "reduce codebase size", "refactor candidates", "cleanup candidates", + or needs code quality analysis. +--- + +# Code Quality Analysis via Knowledge Graph + +Use graph degree filtering to find dead code, high-complexity functions, and refactor candidates — all in single tool calls. + +## Workflow + +### Dead Code Detection + +Find functions with zero inbound CALLS edges, excluding entry points: + +``` +search_graph( + label="Function", + relationship="CALLS", + direction="inbound", + max_degree=0, + exclude_entry_points=true +) +``` + +`exclude_entry_points=true` removes route handlers, `main()`, and framework-registered functions that have zero callers by design. + +### Verify Dead Code Candidates + +Before deleting, verify each candidate truly has no callers: + +``` +trace_call_path(function_name="SuspectFunction", direction="inbound", depth=1) +``` + +Also check for read references (callbacks, stored in variables): + +``` +query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'SuspectFunction' RETURN a.name, a.file_path LIMIT 10") +``` + +### High Fan-Out Functions (calling 10+ others) + +These are often doing too much and are refactor candidates: + +``` +search_graph( + label="Function", + relationship="CALLS", + direction="outbound", + min_degree=10 +) +``` + +### High Fan-In Functions (called by 10+ others) + +These are critical functions — changes have wide impact: + +``` +search_graph( + label="Function", + relationship="CALLS", + direction="inbound", + min_degree=10 +) +``` + +### Files That Change Together (Hidden Coupling) + +Find files with high git change coupling: + +``` +query_graph(query="MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score, r.co_change_count ORDER BY r.coupling_score DESC LIMIT 20") +``` + +High coupling between unrelated files suggests hidden dependencies. + +### Unused Imports + +``` +search_graph( + relationship="IMPORTS", + direction="outbound", + max_degree=0, + label="Module" +) +``` + +## Key Tips + +- `search_graph` with degree filters has no row cap (unlike `query_graph` which caps at 200). +- Use `file_pattern` to scope analysis to specific directories: `file_pattern="**/services/**"`. +- Dead code detection works best after a full index — run `index_repository` if the project was recently set up. +- Paginate results with `limit` and `offset` — check `has_more` in the response. diff --git a/.claude/skills/codebase-memory-reference/SKILL.md b/.claude/skills/codebase-memory-reference/SKILL.md new file mode 100644 index 00000000..97dbfd62 --- /dev/null +++ b/.claude/skills/codebase-memory-reference/SKILL.md @@ -0,0 +1,154 @@ +--- +name: codebase-memory-reference +description: > + This skill should be used when the user asks about "codebase-memory-mcp tools", + "graph query syntax", "Cypher query examples", "edge types", + "how to use search_graph", "query_graph examples", or needs reference + documentation for the codebase knowledge graph tools. +--- + +# Codebase Memory MCP — Tool Reference + +## Tools (14 total) + +| Tool | Purpose | +|------|---------| +| `index_repository` | Parse and ingest repo into graph (only once — auto-sync keeps it fresh) | +| `index_status` | Check indexing status (ready/indexing/not found) | +| `list_projects` | List all indexed projects with timestamps and counts | +| `delete_project` | Remove a project from the graph | +| `search_graph` | Structured search with filters (name, label, degree, file pattern) | +| `search_code` | Grep-like text search within indexed project files | +| `trace_call_path` | BFS call chain traversal (exact name match required). Supports `risk_labels=true` for impact classification. | +| `detect_changes` | Map git diff to affected symbols + blast radius with risk scoring | +| `query_graph` | Cypher-like graph queries (200-row cap) | +| `get_graph_schema` | Node/edge counts, relationship patterns | +| `get_code_snippet` | Read source code by qualified name | +| `read_file` | Read any file from indexed project | +| `list_directory` | List files/directories with glob filter | +| `ingest_traces` | Ingest OpenTelemetry traces to validate HTTP_CALLS edges | + +## Edge Types + +| Type | Meaning | +|------|---------| +| `CALLS` | Direct function call within same service | +| `HTTP_CALLS` | Synchronous cross-service HTTP request | +| `ASYNC_CALLS` | Async dispatch (Cloud Tasks, Pub/Sub, SQS, Kafka) | +| `IMPORTS` | Module/package import | +| `DEFINES` / `DEFINES_METHOD` | Module/class defines a function/method | +| `HANDLES` | Route node handled by a function | +| `IMPLEMENTS` | Type implements an interface | +| `OVERRIDE` | Struct method overrides an interface method | +| `USAGE` | Read reference (callback, variable assignment) | +| `FILE_CHANGES_WITH` | Git history change coupling | +| `CONTAINS_FILE` / `CONTAINS_FOLDER` / `CONTAINS_PACKAGE` | Structural containment | + +## Node Labels + +`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route` + +## Qualified Name Format + +`..` — file path with `/` replaced by `.`, extension removed. + +Examples: +- `myproject.cmd.server.main.HandleRequest` (Go) +- `myproject.services.orders.ProcessOrder` (Python) +- `myproject.src.components.App.App` (TypeScript) + +Use `search_graph` to discover qualified names, then pass them to `get_code_snippet`. + +## Cypher Subset (for query_graph) + +**Supported:** +- `MATCH` with node labels and relationship types +- Variable-length paths: `-[:CALLS*1..3]->` +- `WHERE` with `=`, `<>`, `>`, `<`, `>=`, `<=`, `=~` (regex), `CONTAINS`, `STARTS WITH` +- `WHERE` with `AND`, `OR`, `NOT` +- `RETURN` with property access, `COUNT(x)`, `DISTINCT` +- `ORDER BY` with `ASC`/`DESC` +- `LIMIT` +- Edge property access: `r.confidence`, `r.url_path`, `r.coupling_score` + +**Not supported:** `WITH`, `COLLECT`, `SUM`, `CREATE/DELETE/SET`, `OPTIONAL MATCH`, `UNION` + +## Common Cypher Patterns + +``` +# Cross-service HTTP calls with confidence +MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence LIMIT 20 + +# Filter by URL path +MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name + +# Interface implementations +MATCH (s)-[r:OVERRIDE]->(i) RETURN s.name, i.name LIMIT 20 + +# Change coupling +MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score + +# Functions calling a specific function +MATCH (f:Function)-[:CALLS]->(g:Function) WHERE g.name = 'ProcessOrder' RETURN f.name LIMIT 20 +``` + +## Regex-Powered Search (No Full-Text Index Needed) + +`search_graph` and `search_code` support full Go regex, making full-text search indexes unnecessary. Regex patterns provide precise, composable queries that cover all common discovery scenarios: + +### search_graph — name_pattern / qn_pattern + +| Pattern | Matches | Use case | +|---------|---------|----------| +| `.*Handler$` | names ending in Handler | Find all handlers | +| `(?i)auth` | case-insensitive "auth" | Find auth-related symbols | +| `get\|fetch\|load` | any of three words | Find data-loading functions | +| `^on[A-Z]` | names starting with on + uppercase | Find event handlers | +| `.*Service.*Impl` | Service...Impl pattern | Find service implementations | +| `^(Get\|Set\|Delete)` | CRUD prefixes | Find CRUD operations | +| `.*_test$` | names ending in _test | Find test functions | +| `.*\\.controllers\\..*` | qn_pattern for directory scoping | Scope to controllers dir | + +### search_code — regex=true + +| Pattern | Matches | Use case | +|---------|---------|----------| +| `TODO\|FIXME\|HACK` | multi-pattern scan | Find tech debt markers | +| `(?i)password\|secret\|token` | case-insensitive secrets | Security scan | +| `func\\s+Test` | Go test functions | Find test entry points | +| `api[._/]v[0-9]` | API version references | Find versioned API usage | +| `import.*from ['"]@` | scoped npm imports | Find package imports | + +### Combining Filters for Surgical Queries + +``` +# Find unused auth handlers +search_graph(name_pattern="(?i).*auth.*handler.*", max_degree=0, exclude_entry_points=true) + +# Find high fan-out functions in the services directory +search_graph(qn_pattern=".*\\.services\\..*", min_degree=10, relationship="CALLS", direction="outbound") + +# Find all route handlers matching a URL pattern +search_code(pattern="(?i)(POST|PUT).*\\/api\\/v[0-9]\\/orders", regex=true) +``` + +## Critical Pitfalls + +1. **`search_graph(relationship="HTTP_CALLS")` does NOT return edges** — it filters nodes by degree. Use `query_graph` with Cypher to see actual edges. +2. **`query_graph` has a 200-row cap** before aggregation — COUNT queries silently undercount on large codebases. Use `search_graph` with `min_degree`/`max_degree` for counting. +3. **`trace_call_path` needs exact names** — use `search_graph(name_pattern=".*Partial.*")` first to discover names. +4. **`direction="outbound"` misses cross-service callers** — use `direction="both"` for full context. + +## Decision Matrix + +| Question | Use | +|----------|-----| +| Who calls X? | `trace_call_path(direction="inbound")` | +| What does X call? | `trace_call_path(direction="outbound")` | +| Full call context | `trace_call_path(direction="both")` | +| Find by name pattern | `search_graph(name_pattern="...")` | +| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` | +| Cross-service edges | `query_graph` with Cypher | +| Impact of local changes | `detect_changes()` | +| Risk-classified trace | `trace_call_path(risk_labels=true)` | +| Text search | `search_code` or Grep | diff --git a/.claude/skills/codebase-memory-tracing/SKILL.md b/.claude/skills/codebase-memory-tracing/SKILL.md new file mode 100644 index 00000000..bc14abe7 --- /dev/null +++ b/.claude/skills/codebase-memory-tracing/SKILL.md @@ -0,0 +1,125 @@ +--- +name: codebase-memory-tracing +description: > + This skill should be used when the user asks "who calls this function", + "what does X call", "trace the call chain", "find callers of", + "show dependencies", "what depends on", "trace call path", + "find all references to", "impact analysis", or needs to understand + function call relationships and dependency chains. +--- + +# Call Chain Tracing via Knowledge Graph + +Use graph tools to trace function call relationships. One `trace_call_path` call replaces dozens of grep searches across files. + +## Workflow + +### Step 1: Discover the exact function name + +`trace_call_path` requires an **exact** name match. If you don't know the exact name, discover it first with regex: + +``` +search_graph(name_pattern=".*Order.*", label="Function") +``` + +Use full regex for precise discovery — no full-text search needed: +- `(?i)order` — case-insensitive +- `^(Get|Set|Delete)Order` — CRUD variants +- `.*Order.*Handler$` — handlers only +- `qn_pattern=".*services\\.order\\..*"` — scope to order service directory + +This returns matching functions with their qualified names and file locations. + +### Step 2: Trace callers (who calls this function?) + +``` +trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3) +``` + +Returns a hop-by-hop list of all functions that call `ProcessOrder`, up to 3 levels deep. + +### Step 3: Trace callees (what does this function call?) + +``` +trace_call_path(function_name="ProcessOrder", direction="outbound", depth=3) +``` + +### Step 4: Full context (both callers and callees) + +``` +trace_call_path(function_name="ProcessOrder", direction="both", depth=3) +``` + +**Always use `direction="both"` for complete context.** Cross-service HTTP_CALLS edges from other services appear as inbound edges — `direction="outbound"` alone misses them. + +### Step 5: Read suspicious code + +After finding interesting callers/callees, read their source: + +``` +get_code_snippet(qualified_name="project.path.module.FunctionName") +``` + +## Cross-Service HTTP Calls + +To see all HTTP links between services with URLs and confidence scores: + +``` +query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence ORDER BY r.confidence DESC LIMIT 20") +``` + +Filter by URL path: +``` +query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name, r.url_path") +``` + +## Async Dispatch (Cloud Tasks, Pub/Sub, etc.) + +Find dispatch functions by name pattern, then trace: +``` +search_graph(name_pattern=".*CreateTask.*|.*send_to_pubsub.*") +trace_call_path(function_name="CreateMultidataTask", direction="both") +``` + +## Interface Implementations + +Find which structs implement an interface method: +``` +query_graph(query="MATCH (s)-[r:OVERRIDE]->(i) WHERE i.name = 'Read' RETURN s.name, i.name LIMIT 20") +``` + +## Read References (callbacks, variable assignments) + +``` +query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'ProcessOrder' RETURN a.name, a.file_path LIMIT 20") +``` + +## Risk-Classified Impact Analysis + +Add `risk_labels=true` to get risk classification on each node: + +``` +trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3, risk_labels=true) +``` + +Returns nodes with `risk` (CRITICAL/HIGH/MEDIUM/LOW) based on hop depth, plus an `impact_summary` with counts. Risk mapping: hop 1=CRITICAL, 2=HIGH, 3=MEDIUM, 4+=LOW. + +## Detect Changes (Git Diff Impact) + +Map uncommitted changes to affected symbols and their blast radius: + +``` +detect_changes() +detect_changes(scope="staged") +detect_changes(scope="branch", base_branch="main") +``` + +Returns changed files, changed symbols, and impacted callers with risk classification. Scopes: `unstaged`, `staged`, `all` (default), `branch`. + +## Key Tips + +- Start with `depth=1` for quick answers, increase only if needed (max 5). +- Edge types in trace results: `CALLS` (direct), `HTTP_CALLS` (cross-service), `ASYNC_CALLS` (async dispatch), `USAGE` (read reference), `OVERRIDE` (interface implementation). +- `search_graph(relationship="HTTP_CALLS")` filters nodes by degree — it does NOT return edges. Use `query_graph` with Cypher to see actual edges with properties. +- Results are capped at 200 nodes per trace. +- `detect_changes` requires git in PATH. diff --git a/.claude/skills/codex-debug/SKILL.md b/.claude/skills/codex-debug/SKILL.md index bf864117..42676db2 100644 --- a/.claude/skills/codex-debug/SKILL.md +++ b/.claude/skills/codex-debug/SKILL.md @@ -13,7 +13,10 @@ Claude가 증상을 분석하고 원인 가설을 수립한 뒤, Codex가 실제 ## 절차 ### 1. Claude 선분석 -사용자의 버그 리포트를 기반으로 관련 코드를 Read/Grep으로 확인하고 정리합니다. +사용자의 버그 리포트를 기반으로 **codebase-memory-mcp 그래프 도구를 우선** 사용하여 관련 코드를 확인하고 정리합니다. +- `search_graph`로 관련 함수/클래스 탐색, `trace_call_path`로 콜체인 추적 +- `detect_changes`로 최근 변경의 영향 범위 분석 +- 그래프에 없는 정보(에러 문자열, 설정값 등)만 Read/Grep으로 보완 - 증상 및 기대 동작 - 재현 조건 - 의심 범위 (관련 파일/함수) diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/codex-plan/SKILL.md similarity index 90% rename from .claude/skills/plan/SKILL.md rename to .claude/skills/codex-plan/SKILL.md index d1f9ed5e..5aeb50d1 100644 --- a/.claude/skills/plan/SKILL.md +++ b/.claude/skills/codex-plan/SKILL.md @@ -1,5 +1,5 @@ --- -name: plan +name: codex-plan description: "작업 전 Codex(GPT 5.4)와 협업하여 구현 계획을 수립. 작업 계획, 설계 논의, 접근 방식 검토 시 사용." disable-model-invocation: false argument-hint: "[작업 설명]" @@ -14,7 +14,11 @@ Claude가 코드를 분석하고 초안 계획을 작성한 뒤, Codex(GPT 5.4) ### 기본 모드 (B: 순차 협업) 대부분의 작업에 사용합니다. -1. Claude가 Read, Grep, Glob으로 관련 코드를 분석합니다. +1. Claude가 **codebase-memory-mcp 그래프 도구를 우선** 사용하여 관련 코드를 분석합니다. + - `search_graph`로 관련 함수/클래스/모듈 탐색 + - `trace_call_path`로 콜체인 및 영향 범위 추적 + - `get_architecture`로 구조 파악 + - 그래프에 없는 정보(문자열 리터럴, 설정값 등)만 Read/Grep/Glob으로 보완 2. 코드 구조, 영향 범위, 초안 계획을 정리합니다. 3. 분석 결과를 Codex에게 전달하여 검증/보완을 요청합니다. 4. Codex 피드백을 반영하여 최종 계획을 확정합니다. @@ -26,7 +30,7 @@ Claude가 코드를 분석하고 초안 계획을 작성한 뒤, Codex(GPT 5.4) - 실패 비용이 큰 리팩토링/마이그레이션일 때 1. Claude 분석과 Codex 분석을 동시에 진행합니다. - - Claude: Read, Grep, Glob으로 코드 분석 + - Claude: 그래프 도구(search_graph, trace_call_path 등) 우선, 필요시 Read/Grep 보완 - Codex: `codex exec`로 독립적 분석 (백그라운드) 2. 두 분석 결과를 종합하여 최종 계획을 작성합니다. diff --git a/.claude/skills/codex-review/SKILL.md b/.claude/skills/codex-review/SKILL.md index d24327cd..7f975ce2 100644 --- a/.claude/skills/codex-review/SKILL.md +++ b/.claude/skills/codex-review/SKILL.md @@ -13,7 +13,10 @@ Codex는 `danger-full-access` 권한으로 직접 파일 읽기, 쉘 명령 실 ## 절차 1. Claude가 `git diff --stat`으로 변경 범위를 파악합니다. -2. 핵심 변경 파일을 Read로 확인하고 **diff 요약, 의도 추정, 위험 포인트**를 정리합니다. +2. **codebase-memory-mcp 그래프 도구를 우선** 사용하여 변경의 영향 범위를 분석합니다. + - `detect_changes`로 변경된 심볼과 blast radius 확인 + - `trace_call_path`로 변경 함수의 호출자/피호출자 추적 + - 핵심 변경 파일은 Read로 확인하고 **diff 요약, 의도 추정, 위험 포인트**를 정리합니다. 3. Claude의 선분석 결과를 Codex에게 전달하여 검증/심층 리뷰를 요청합니다 (백그라운드). 4. 진행 상황을 주기적으로 확인하여 사용자에게 보고합니다. 5. 필요시 `codex exec resume --last`로 심화 리뷰합니다. diff --git a/.codex/skills/codebase-memory-exploring/SKILL.md b/.codex/skills/codebase-memory-exploring/SKILL.md new file mode 100644 index 00000000..cc45a8be --- /dev/null +++ b/.codex/skills/codebase-memory-exploring/SKILL.md @@ -0,0 +1,90 @@ +--- +name: codebase-memory-exploring +description: > + This skill should be used when the user asks to "explore the codebase", + "understand the architecture", "what functions exist", "show me the structure", + "how is the code organized", "find functions matching", "search for classes", + "list all routes", "show API endpoints", or needs codebase orientation. +--- + +# Codebase Exploration via Knowledge Graph + +Use graph tools for structural code questions. They return precise results in ~500 tokens vs ~80K for grep-based exploration. + +## Workflow + +### Step 1: Check if project is indexed + +``` +list_projects +``` + +If the project is missing from the list: + +``` +index_repository(repo_path="/path/to/project") +``` + +If already indexed, skip — auto-sync keeps the graph fresh. + +### Step 2: Get a structural overview + +``` +get_graph_schema +``` + +This returns node label counts (functions, classes, routes, etc.), edge type counts, and relationship patterns. Use it to understand what's in the graph before querying. + +### Step 3: Find specific code elements + +Find functions by name pattern: +``` +search_graph(label="Function", name_pattern=".*Handler.*") +``` + +Find classes: +``` +search_graph(label="Class", name_pattern=".*Service.*") +``` + +Find all REST routes: +``` +search_graph(label="Route") +``` + +Find modules/packages: +``` +search_graph(label="Module") +``` + +Scope to a specific directory: +``` +search_graph(label="Function", qn_pattern=".*services\\.order\\..*") +``` + +### Step 4: Read source code + +After finding a function via search, read its source: +``` +get_code_snippet(qualified_name="project.path.to.FunctionName") +``` + +### Step 5: Understand structure + +For file/directory exploration within the indexed project: +``` +list_directory(path="src/services") +``` + +## When to Use Grep Instead + +- Searching for **string literals** or error messages → `search_code` or Grep +- Finding a file by exact name → Glob +- The graph doesn't index text content, only structural elements + +## Key Tips + +- Results default to 10 per page. Check `has_more` and use `offset` to paginate. +- Use `project` parameter when multiple repos are indexed. +- Route nodes have a `properties.handler` field with the actual handler function name. +- `exclude_labels` removes noise (e.g., `exclude_labels=["Route"]` when searching by name pattern). diff --git a/.codex/skills/codebase-memory-quality/SKILL.md b/.codex/skills/codebase-memory-quality/SKILL.md new file mode 100644 index 00000000..1542eee2 --- /dev/null +++ b/.codex/skills/codebase-memory-quality/SKILL.md @@ -0,0 +1,101 @@ +--- +name: codebase-memory-quality +description: > + This skill should be used when the user asks about "dead code", + "find dead code", "detect dead code", "show dead code", "dead code analysis", + "unused functions", "find unused functions", "unreachable code", + "identify high fan-out functions", "find complex functions", + "code quality audit", "find functions nobody calls", + "reduce codebase size", "refactor candidates", "cleanup candidates", + or needs code quality analysis. +--- + +# Code Quality Analysis via Knowledge Graph + +Use graph degree filtering to find dead code, high-complexity functions, and refactor candidates — all in single tool calls. + +## Workflow + +### Dead Code Detection + +Find functions with zero inbound CALLS edges, excluding entry points: + +``` +search_graph( + label="Function", + relationship="CALLS", + direction="inbound", + max_degree=0, + exclude_entry_points=true +) +``` + +`exclude_entry_points=true` removes route handlers, `main()`, and framework-registered functions that have zero callers by design. + +### Verify Dead Code Candidates + +Before deleting, verify each candidate truly has no callers: + +``` +trace_call_path(function_name="SuspectFunction", direction="inbound", depth=1) +``` + +Also check for read references (callbacks, stored in variables): + +``` +query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'SuspectFunction' RETURN a.name, a.file_path LIMIT 10") +``` + +### High Fan-Out Functions (calling 10+ others) + +These are often doing too much and are refactor candidates: + +``` +search_graph( + label="Function", + relationship="CALLS", + direction="outbound", + min_degree=10 +) +``` + +### High Fan-In Functions (called by 10+ others) + +These are critical functions — changes have wide impact: + +``` +search_graph( + label="Function", + relationship="CALLS", + direction="inbound", + min_degree=10 +) +``` + +### Files That Change Together (Hidden Coupling) + +Find files with high git change coupling: + +``` +query_graph(query="MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score, r.co_change_count ORDER BY r.coupling_score DESC LIMIT 20") +``` + +High coupling between unrelated files suggests hidden dependencies. + +### Unused Imports + +``` +search_graph( + relationship="IMPORTS", + direction="outbound", + max_degree=0, + label="Module" +) +``` + +## Key Tips + +- `search_graph` with degree filters has no row cap (unlike `query_graph` which caps at 200). +- Use `file_pattern` to scope analysis to specific directories: `file_pattern="**/services/**"`. +- Dead code detection works best after a full index — run `index_repository` if the project was recently set up. +- Paginate results with `limit` and `offset` — check `has_more` in the response. diff --git a/.codex/skills/codebase-memory-reference/SKILL.md b/.codex/skills/codebase-memory-reference/SKILL.md new file mode 100644 index 00000000..97dbfd62 --- /dev/null +++ b/.codex/skills/codebase-memory-reference/SKILL.md @@ -0,0 +1,154 @@ +--- +name: codebase-memory-reference +description: > + This skill should be used when the user asks about "codebase-memory-mcp tools", + "graph query syntax", "Cypher query examples", "edge types", + "how to use search_graph", "query_graph examples", or needs reference + documentation for the codebase knowledge graph tools. +--- + +# Codebase Memory MCP — Tool Reference + +## Tools (14 total) + +| Tool | Purpose | +|------|---------| +| `index_repository` | Parse and ingest repo into graph (only once — auto-sync keeps it fresh) | +| `index_status` | Check indexing status (ready/indexing/not found) | +| `list_projects` | List all indexed projects with timestamps and counts | +| `delete_project` | Remove a project from the graph | +| `search_graph` | Structured search with filters (name, label, degree, file pattern) | +| `search_code` | Grep-like text search within indexed project files | +| `trace_call_path` | BFS call chain traversal (exact name match required). Supports `risk_labels=true` for impact classification. | +| `detect_changes` | Map git diff to affected symbols + blast radius with risk scoring | +| `query_graph` | Cypher-like graph queries (200-row cap) | +| `get_graph_schema` | Node/edge counts, relationship patterns | +| `get_code_snippet` | Read source code by qualified name | +| `read_file` | Read any file from indexed project | +| `list_directory` | List files/directories with glob filter | +| `ingest_traces` | Ingest OpenTelemetry traces to validate HTTP_CALLS edges | + +## Edge Types + +| Type | Meaning | +|------|---------| +| `CALLS` | Direct function call within same service | +| `HTTP_CALLS` | Synchronous cross-service HTTP request | +| `ASYNC_CALLS` | Async dispatch (Cloud Tasks, Pub/Sub, SQS, Kafka) | +| `IMPORTS` | Module/package import | +| `DEFINES` / `DEFINES_METHOD` | Module/class defines a function/method | +| `HANDLES` | Route node handled by a function | +| `IMPLEMENTS` | Type implements an interface | +| `OVERRIDE` | Struct method overrides an interface method | +| `USAGE` | Read reference (callback, variable assignment) | +| `FILE_CHANGES_WITH` | Git history change coupling | +| `CONTAINS_FILE` / `CONTAINS_FOLDER` / `CONTAINS_PACKAGE` | Structural containment | + +## Node Labels + +`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route` + +## Qualified Name Format + +`..` — file path with `/` replaced by `.`, extension removed. + +Examples: +- `myproject.cmd.server.main.HandleRequest` (Go) +- `myproject.services.orders.ProcessOrder` (Python) +- `myproject.src.components.App.App` (TypeScript) + +Use `search_graph` to discover qualified names, then pass them to `get_code_snippet`. + +## Cypher Subset (for query_graph) + +**Supported:** +- `MATCH` with node labels and relationship types +- Variable-length paths: `-[:CALLS*1..3]->` +- `WHERE` with `=`, `<>`, `>`, `<`, `>=`, `<=`, `=~` (regex), `CONTAINS`, `STARTS WITH` +- `WHERE` with `AND`, `OR`, `NOT` +- `RETURN` with property access, `COUNT(x)`, `DISTINCT` +- `ORDER BY` with `ASC`/`DESC` +- `LIMIT` +- Edge property access: `r.confidence`, `r.url_path`, `r.coupling_score` + +**Not supported:** `WITH`, `COLLECT`, `SUM`, `CREATE/DELETE/SET`, `OPTIONAL MATCH`, `UNION` + +## Common Cypher Patterns + +``` +# Cross-service HTTP calls with confidence +MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence LIMIT 20 + +# Filter by URL path +MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name + +# Interface implementations +MATCH (s)-[r:OVERRIDE]->(i) RETURN s.name, i.name LIMIT 20 + +# Change coupling +MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score + +# Functions calling a specific function +MATCH (f:Function)-[:CALLS]->(g:Function) WHERE g.name = 'ProcessOrder' RETURN f.name LIMIT 20 +``` + +## Regex-Powered Search (No Full-Text Index Needed) + +`search_graph` and `search_code` support full Go regex, making full-text search indexes unnecessary. Regex patterns provide precise, composable queries that cover all common discovery scenarios: + +### search_graph — name_pattern / qn_pattern + +| Pattern | Matches | Use case | +|---------|---------|----------| +| `.*Handler$` | names ending in Handler | Find all handlers | +| `(?i)auth` | case-insensitive "auth" | Find auth-related symbols | +| `get\|fetch\|load` | any of three words | Find data-loading functions | +| `^on[A-Z]` | names starting with on + uppercase | Find event handlers | +| `.*Service.*Impl` | Service...Impl pattern | Find service implementations | +| `^(Get\|Set\|Delete)` | CRUD prefixes | Find CRUD operations | +| `.*_test$` | names ending in _test | Find test functions | +| `.*\\.controllers\\..*` | qn_pattern for directory scoping | Scope to controllers dir | + +### search_code — regex=true + +| Pattern | Matches | Use case | +|---------|---------|----------| +| `TODO\|FIXME\|HACK` | multi-pattern scan | Find tech debt markers | +| `(?i)password\|secret\|token` | case-insensitive secrets | Security scan | +| `func\\s+Test` | Go test functions | Find test entry points | +| `api[._/]v[0-9]` | API version references | Find versioned API usage | +| `import.*from ['"]@` | scoped npm imports | Find package imports | + +### Combining Filters for Surgical Queries + +``` +# Find unused auth handlers +search_graph(name_pattern="(?i).*auth.*handler.*", max_degree=0, exclude_entry_points=true) + +# Find high fan-out functions in the services directory +search_graph(qn_pattern=".*\\.services\\..*", min_degree=10, relationship="CALLS", direction="outbound") + +# Find all route handlers matching a URL pattern +search_code(pattern="(?i)(POST|PUT).*\\/api\\/v[0-9]\\/orders", regex=true) +``` + +## Critical Pitfalls + +1. **`search_graph(relationship="HTTP_CALLS")` does NOT return edges** — it filters nodes by degree. Use `query_graph` with Cypher to see actual edges. +2. **`query_graph` has a 200-row cap** before aggregation — COUNT queries silently undercount on large codebases. Use `search_graph` with `min_degree`/`max_degree` for counting. +3. **`trace_call_path` needs exact names** — use `search_graph(name_pattern=".*Partial.*")` first to discover names. +4. **`direction="outbound"` misses cross-service callers** — use `direction="both"` for full context. + +## Decision Matrix + +| Question | Use | +|----------|-----| +| Who calls X? | `trace_call_path(direction="inbound")` | +| What does X call? | `trace_call_path(direction="outbound")` | +| Full call context | `trace_call_path(direction="both")` | +| Find by name pattern | `search_graph(name_pattern="...")` | +| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` | +| Cross-service edges | `query_graph` with Cypher | +| Impact of local changes | `detect_changes()` | +| Risk-classified trace | `trace_call_path(risk_labels=true)` | +| Text search | `search_code` or Grep | diff --git a/.codex/skills/codebase-memory-tracing/SKILL.md b/.codex/skills/codebase-memory-tracing/SKILL.md new file mode 100644 index 00000000..bc14abe7 --- /dev/null +++ b/.codex/skills/codebase-memory-tracing/SKILL.md @@ -0,0 +1,125 @@ +--- +name: codebase-memory-tracing +description: > + This skill should be used when the user asks "who calls this function", + "what does X call", "trace the call chain", "find callers of", + "show dependencies", "what depends on", "trace call path", + "find all references to", "impact analysis", or needs to understand + function call relationships and dependency chains. +--- + +# Call Chain Tracing via Knowledge Graph + +Use graph tools to trace function call relationships. One `trace_call_path` call replaces dozens of grep searches across files. + +## Workflow + +### Step 1: Discover the exact function name + +`trace_call_path` requires an **exact** name match. If you don't know the exact name, discover it first with regex: + +``` +search_graph(name_pattern=".*Order.*", label="Function") +``` + +Use full regex for precise discovery — no full-text search needed: +- `(?i)order` — case-insensitive +- `^(Get|Set|Delete)Order` — CRUD variants +- `.*Order.*Handler$` — handlers only +- `qn_pattern=".*services\\.order\\..*"` — scope to order service directory + +This returns matching functions with their qualified names and file locations. + +### Step 2: Trace callers (who calls this function?) + +``` +trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3) +``` + +Returns a hop-by-hop list of all functions that call `ProcessOrder`, up to 3 levels deep. + +### Step 3: Trace callees (what does this function call?) + +``` +trace_call_path(function_name="ProcessOrder", direction="outbound", depth=3) +``` + +### Step 4: Full context (both callers and callees) + +``` +trace_call_path(function_name="ProcessOrder", direction="both", depth=3) +``` + +**Always use `direction="both"` for complete context.** Cross-service HTTP_CALLS edges from other services appear as inbound edges — `direction="outbound"` alone misses them. + +### Step 5: Read suspicious code + +After finding interesting callers/callees, read their source: + +``` +get_code_snippet(qualified_name="project.path.module.FunctionName") +``` + +## Cross-Service HTTP Calls + +To see all HTTP links between services with URLs and confidence scores: + +``` +query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence ORDER BY r.confidence DESC LIMIT 20") +``` + +Filter by URL path: +``` +query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name, r.url_path") +``` + +## Async Dispatch (Cloud Tasks, Pub/Sub, etc.) + +Find dispatch functions by name pattern, then trace: +``` +search_graph(name_pattern=".*CreateTask.*|.*send_to_pubsub.*") +trace_call_path(function_name="CreateMultidataTask", direction="both") +``` + +## Interface Implementations + +Find which structs implement an interface method: +``` +query_graph(query="MATCH (s)-[r:OVERRIDE]->(i) WHERE i.name = 'Read' RETURN s.name, i.name LIMIT 20") +``` + +## Read References (callbacks, variable assignments) + +``` +query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'ProcessOrder' RETURN a.name, a.file_path LIMIT 20") +``` + +## Risk-Classified Impact Analysis + +Add `risk_labels=true` to get risk classification on each node: + +``` +trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3, risk_labels=true) +``` + +Returns nodes with `risk` (CRITICAL/HIGH/MEDIUM/LOW) based on hop depth, plus an `impact_summary` with counts. Risk mapping: hop 1=CRITICAL, 2=HIGH, 3=MEDIUM, 4+=LOW. + +## Detect Changes (Git Diff Impact) + +Map uncommitted changes to affected symbols and their blast radius: + +``` +detect_changes() +detect_changes(scope="staged") +detect_changes(scope="branch", base_branch="main") +``` + +Returns changed files, changed symbols, and impacted callers with risk classification. Scopes: `unstaged`, `staged`, `all` (default), `branch`. + +## Key Tips + +- Start with `depth=1` for quick answers, increase only if needed (max 5). +- Edge types in trace results: `CALLS` (direct), `HTTP_CALLS` (cross-service), `ASYNC_CALLS` (async dispatch), `USAGE` (read reference), `OVERRIDE` (interface implementation). +- `search_graph(relationship="HTTP_CALLS")` filters nodes by degree — it does NOT return edges. Use `query_graph` with Cypher to see actual edges with properties. +- Results are capped at 200 nodes per trace. +- `detect_changes` requires git in PATH. diff --git a/.gitignore b/.gitignore index 20bf3632..df5d1d6e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ src-tauri/target src-tauri/webview2-fixed-runtime/* !src-tauri/webview2-fixed-runtime/placeholder.txt -.mcp.json \ No newline at end of file +.mcp.json +mcp.json \ No newline at end of file From 7ff5a7e761fc25300fe917d23f0c7e20b881d655 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Mon, 9 Mar 2026 19:39:42 +0900 Subject: [PATCH 46/56] =?UTF-8?q?agents:=20Codex=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=AA=85=EC=8B=9C=20=E2=80=94=20timeout?= =?UTF-8?q?=20=E2=89=A0=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/CLAUDE.md | 46 +++++++++++++++++++++------- .claude/skills/codex-debug/SKILL.md | 32 +++++++++++-------- .claude/skills/codex-plan/SKILL.md | 29 +++++++++++++----- .claude/skills/codex-review/SKILL.md | 29 +++++++++++++----- 4 files changed, 97 insertions(+), 39 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e325e6b1..ef227d2c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,21 +1,45 @@ # 로컬 워크플로 규칙 +## 구현 요청 시 필수 행동 + +사용자가 다음 중 하나에 해당하는 요청을 하면, **무조건 첫 번째 도구 호출로 `Skill` 도구를 사용하여 `/codex-plan`을 실행**해야 합니다: +- 기능 구현/추가 ("~해줘", "~추가해줘", "~구현해줘", "~만들어줘") +- 리팩토링/아키텍처 변경 +- 설계 논의/계획 수립 + +이것은 이 프로젝트의 **절대 규칙**입니다: +1. 사용자 메시지를 읽는다 +2. 구현 요청인지 판단한다 +3. 구현 요청이면 → **즉시 `Skill(skill: "codex-plan", args: "사용자 요청 내용")`을 호출한다** +4. Read, Grep, Glob, Agent 등 다른 도구는 `/codex-plan` 스킬 실행 후에만 사용한다 + +**위반 금지**: 파일을 먼저 읽거나, 코드를 분석하거나, 계획을 직접 세우는 행위는 이 규칙의 위반입니다. + +예외: 단순 버그 수정(1~2줄), 오타/포맷팅 수정, 질문/설명 요청 + ## 기본 워크플로 모든 구현 작업은 다음 워크플로를 따릅니다. ### 1. 계획 수립 (필수) -사용자의 구현 요청이나 설계 논의가 있으면 `/codex-plan` 스킬을 사용하여 Codex와 함께 계획을 수립합니다. -- 단순 버그 수정이나 1~2줄 변경이 아닌 이상 항상 plan을 먼저 실행 -- Claude 선분석 → Codex 검증 → 최종 계획 확정 +위 규칙에 따라 `/codex-plan` 스킬을 호출합니다. +- Claude 선분석 → Codex 독립 검증 → 최종 계획 확정 ### 2. 단계별 구현 + 코드 리뷰 -확정된 계획을 단계별로 나누어 구현하고, 각 단계마다 `/codex-review`로 코드 리뷰를 수행합니다. +확정된 계획을 단계별로 나누어 구현합니다. - 한 번에 모든 변경을 하지 않고, 논리적 단위로 분리 -- 각 단계 완료 후 Codex 리뷰 → 이슈 있으면 수정 → 리뷰 통과 확인 +- 각 단계 완료 후 변경의 규모와 리스크를 판단하여 Codex 리뷰 필요 여부를 결정 +- Codex 리뷰가 필요하다고 판단되면 `/codex-review` 실행 +- 리뷰 없이 진행할 경우 그 판단 근거를 간단히 명시 + +### 3. 빌드/린트 검증 +변경 내용에 따라 적절한 검증을 수행합니다. +- Rust 변경: `cargo check`, `cargo clippy` +- TypeScript 변경: `npx tsc --noEmit`, `npm run lint` +- 검증 주체(Claude/Codex)는 상황에 따라 자유롭게 결정 -### 3. 자동 커밋 -각 단계의 리뷰가 통과되면 해당 변경사항을 즉시 커밋합니다. +### 4. 자동 커밋 +각 단계의 검증이 통과되면 해당 변경사항을 즉시 커밋합니다. - 커밋 메시지는 변경 내용에 맞게 작성 - 단계별로 커밋하여 git 히스토리를 깔끔하게 유지 @@ -23,13 +47,13 @@ ``` 사용자: "키보드 입력 시스템에 딜레이 옵션 추가해줘" -1. /plan 실행 → Codex와 구현 계획 수립 +1. Skill(skill: "codex-plan", args: "키보드 입력 시스템에 딜레이 옵션 추가") ← 무조건 첫 번째 2. 단계 1: Rust 백엔드 커맨드 추가 → /codex-review → 커밋 -3. 단계 2: 프론트엔드 설정 UI 추가 → /codex-review → 커밋 +3. 단계 2: 프론트엔드 설정 UI 추가 → 변경 소규모로 판단, Claude 자체 검증 → 커밋 4. 단계 3: 오버레이 반영 로직 수정 → /codex-review → 커밋 ``` -### 4. 최종 보고 +### 5. 최종 보고 모든 단계가 완료되면 작업 결과를 정리하여 보고합니다. - 변경된 파일 목록과 각 변경 요지 - 커밋 히스토리 요약 @@ -37,5 +61,5 @@ - 남은 리스크나 후속 작업이 있으면 명시 ## 예외 -- 1~2줄 수정, 오타 수정, 포맷팅 등 사소한 변경은 plan/review 없이 직접 처리 +- 사소한 변경(오타, 포맷팅, import 정리 등)은 plan/review 없이 직접 처리 - 긴급 핫픽스는 plan 없이 구현 후 review만 수행 diff --git a/.claude/skills/codex-debug/SKILL.md b/.claude/skills/codex-debug/SKILL.md index 42676db2..5e2b09f7 100644 --- a/.claude/skills/codex-debug/SKILL.md +++ b/.claude/skills/codex-debug/SKILL.md @@ -25,7 +25,7 @@ Claude가 증상을 분석하고 원인 가설을 수립한 뒤, Codex가 실제 재현 정보가 부족하면 사용자에게 최소한의 추가 정보만 요청합니다. ### 2. Codex 1차 조사 -Claude의 선분석을 Codex에게 전달하여 가설 검증을 요청합니다 (백그라운드). +증상, 관련 파일, 재현 조건(사실 정보)을 Codex에게 전달하여 독립적으로 원인을 추적하게 합니다 (백그라운드). - 관련 파일 읽기, 에러 문자열/함수명 검색 - 재현 절차 수립 및 실행 - 가설별 지지/반증 근거 정리 @@ -47,30 +47,36 @@ Claude가 원인, 수정 내용, 검증 결과, 남은 리스크를 정리하여 ## Codex 호출 방법 ### 1차 조사 요청 -Claude의 선분석을 포함하여 원인 추적을 요청합니다. +사실 정보(증상, 관련 파일, 재현 조건)만 전달하고, Claude의 원인 추정은 포함하지 않습니다. ```bash -codex exec -C "$(pwd)" -s danger-full-access --json "다음 버그의 원인을 추적해주세요. +codex exec -C "$(pwd)" -s danger-full-access --json "다음 버그의 원인을 독립적으로 추적해주세요. 관련 파일을 직접 읽고, 필요하면 검색/빌드/테스트 명령을 실행해주세요. 버그 증상: (사용자 입력) -Claude 선분석: -- 증상: ... +관련 정보: - 기대 동작: ... -- 의심 범위: ... -- 원인 가설: - 1. ... - 2. ... - 3. ... +- 재현 조건: ... +- 관련 파일/함수: (경로 목록) +- 최근 변경: (관련 커밋/diff 범위) 요청: -- 가설별로 맞는지/아닌지 근거를 정리해주세요 +- 원인 후보를 독립적으로 도출하고 각각 근거를 제시해주세요 - 재현 가능하면 최소 절차를 제시하고 직접 검증해주세요 - 원인 미확정 시 가장 효율적인 추가 확인 1개를 제안해주세요 - 수정은 하지 말고 원인 분석만 해주세요 +출력 형식 (반드시 준수): +## 원인 후보 +1. (후보) - 근거: ... / 검증: ... +2. (후보) - 근거: ... / 검증: ... +## 재현 결과 +(재현 시도 내용과 결과) +## 추가 확인 필요 +(미확정 시 다음 확인 사항) + 한글로 간결하게 답변해주세요." 2>/dev/null ``` @@ -108,14 +114,16 @@ codex exec -C "$(pwd)" -s danger-full-access --json "다음 증상의 원인 후 ``` ### 호출 규칙 +- Claude의 판단/결론은 Codex에 전달하지 않음 — 사실 정보만 전달 (anchoring bias 방지). - `-C "$(pwd)"`, `-s danger-full-access`, `--json` 기본 적용. - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). - Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. - 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. - **중요: 원인 확정/수정 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.** - - `status: running` → Codex가 조사 중이므로 대기. 대기 중에는 선분석 정리만 수행. + - `status: running` → Codex가 조사 중이므로 **완료될 때까지 대기**. TaskOutput 타임아웃은 Codex 실패가 아님 — `status: running`이면 `TaskOutput(block: true)`로 재시도하여 끝까지 기다림. 대기 중에는 선분석 정리만 수행. - `status: completed` → 결과를 수집하여 반영. - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 디버깅). 대기하지 않음. + - **fallback 조건은 오직 `status: failed`/에러뿐**. 시간이 오래 걸리는 것은 fallback 사유가 아님. ### 진행 상황 확인 백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다. diff --git a/.claude/skills/codex-plan/SKILL.md b/.claude/skills/codex-plan/SKILL.md index 5aeb50d1..fbbb6e83 100644 --- a/.claude/skills/codex-plan/SKILL.md +++ b/.claude/skills/codex-plan/SKILL.md @@ -20,7 +20,7 @@ Claude가 코드를 분석하고 초안 계획을 작성한 뒤, Codex(GPT 5.4) - `get_architecture`로 구조 파악 - 그래프에 없는 정보(문자열 리터럴, 설정값 등)만 Read/Grep/Glob으로 보완 2. 코드 구조, 영향 범위, 초안 계획을 정리합니다. -3. 분석 결과를 Codex에게 전달하여 검증/보완을 요청합니다. +3. 사실 정보(파일 목록, 코드 구조, 영향 범위)를 Codex에게 전달하여 독립 검증을 요청합니다. 4. Codex 피드백을 반영하여 최종 계획을 확정합니다. ### 병렬 모드 (C: 독립 분석) @@ -37,22 +37,33 @@ Claude가 코드를 분석하고 초안 계획을 작성한 뒤, Codex(GPT 5.4) ## Codex 호출 방법 ### 기본 모드 프롬프트 -Claude의 분석 결과를 프롬프트에 포함하여 검증을 요청합니다. +사실 정보(파일 목록, 코드 구조, 영향 범위)만 전달하고, Claude의 판단/결론은 포함하지 않습니다. ```bash -codex exec -C "$(pwd)" -s danger-full-access --json "다음은 Claude(Opus)가 작성한 구현 계획 초안입니다. 검증하고 보완해주세요. +codex exec -C "$(pwd)" -s danger-full-access --json "다음 작업에 대한 구현 계획을 독립적으로 검증하고 보완해주세요. 프로젝트 기술 스택: Tauri(Rust + React), Zustand, Preact Signals, Tailwind CSS, Vite 필요하면 관련 파일을 직접 읽어서 확인해주세요. 작업 내용: (사용자 요청) -Claude 분석 결과: -(코드 구조, 영향 범위, 초안 계획) +관련 코드 정보: +- 관련 파일: (파일 경로 목록) +- 코드 구조: (함수/모듈 관계, 콜체인 등 사실 정보) +- 영향 범위: (변경 시 영향받는 파일/함수 목록) -검증해줄 사항: +요청: +- 위 작업의 구현 계획을 수립해주세요 - 누락된 영향 범위나 리스크가 있는지 - 더 나은 접근 방식이 있는지 -- 초안 계획의 순서나 우선순위가 적절한지 +- 단계별 순서와 우선순위 제안 + +출력 형식 (반드시 준수): +## 구현 계획 +1. (단계별 작업 - 파일명과 변경 내용) +## 리스크 +- (잠재적 문제점과 근거) +## 대안 +- (고려한 다른 접근 방식이 있다면) 한글로 간결하게 답변해주세요." 2>/dev/null ``` @@ -76,14 +87,16 @@ codex exec -C "$(pwd)" -s danger-full-access --json "다음 작업에 대한 구 ``` ### 공통 규칙 +- Claude의 판단/결론은 Codex에 전달하지 않음 — 사실 정보만 전달 (anchoring bias 방지). - `-C "$(pwd)"`, `-s danger-full-access`, `--json` 기본 적용. - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). - Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. - 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. - **중요: 결론을 내리기 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.** - - `status: running` → Codex가 작업 중이므로 대기. 대기 중에는 Claude 선분석 등 병렬 가능한 작업만 수행. + - `status: running` → Codex가 작업 중이므로 **완료될 때까지 대기**. TaskOutput 타임아웃은 Codex 실패가 아님 — `status: running`이면 `TaskOutput(block: true)`로 재시도하여 끝까지 기다림. 대기 중에는 Claude 선분석 등 병렬 가능한 작업만 수행. - `status: completed` → 결과를 수집하여 반영. - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 진행). 대기하지 않음. + - **fallback 조건은 오직 `status: failed`/에러뿐**. 시간이 오래 걸리는 것은 fallback 사유가 아님. ### 진행 상황 확인 백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다. diff --git a/.claude/skills/codex-review/SKILL.md b/.claude/skills/codex-review/SKILL.md index 7f975ce2..70b91a32 100644 --- a/.claude/skills/codex-review/SKILL.md +++ b/.claude/skills/codex-review/SKILL.md @@ -17,7 +17,7 @@ Codex는 `danger-full-access` 권한으로 직접 파일 읽기, 쉘 명령 실 - `detect_changes`로 변경된 심볼과 blast radius 확인 - `trace_call_path`로 변경 함수의 호출자/피호출자 추적 - 핵심 변경 파일은 Read로 확인하고 **diff 요약, 의도 추정, 위험 포인트**를 정리합니다. -3. Claude의 선분석 결과를 Codex에게 전달하여 검증/심층 리뷰를 요청합니다 (백그라운드). +3. 변경 파일 목록과 영향 범위(사실 정보)를 Codex에게 전달하여 독립 리뷰를 요청합니다 (백그라운드). 4. 진행 상황을 주기적으로 확인하여 사용자에게 보고합니다. 5. 필요시 `codex exec resume --last`로 심화 리뷰합니다. 6. Codex 피드백을 종합하여 최종 리뷰 결과를 보고합니다. @@ -25,18 +25,19 @@ Codex는 `danger-full-access` 권한으로 직접 파일 읽기, 쉘 명령 실 ## Codex 호출 방법 ### 리뷰 요청 -Claude의 선분석 결과를 프롬프트에 포함하여 검증을 요청합니다. +변경된 파일 목록과 diff 범위만 전달하고, Claude의 판단/결론은 포함하지 않습니다. ```bash -codex exec -C "$(pwd)" -s danger-full-access --json "다음은 Claude(Opus)가 작성한 코드 리뷰 선분석입니다. 검증하고 심층 리뷰해주세요. +codex exec -C "$(pwd)" -s danger-full-access --json "다음 변경사항을 독립적으로 코드 리뷰해주세요. git diff와 git diff --cached를 직접 실행하여 변경사항을 확인하고, 필요하면 관련 파일의 전체 컨텍스트도 읽어주세요. -Claude 선분석: -(diff 요약, 의도 추정, 위험 포인트) +변경 정보: +- 변경 파일: (git diff --stat 결과) +- 변경 의도: (사용자가 요청한 작업 내용) +- 영향 범위: (변경 함수의 호출자/피호출자 목록) -검증해줄 사항: -- Claude가 놓친 이슈가 있는지 +리뷰 항목: - 타입 안정성, 네이밍 컨벤션 준수 여부 - React Compiler 호환성 (useSignals 시 'use no memo' 필수) - 불필요한 리렌더링 패턴 @@ -45,6 +46,16 @@ Claude 선분석: 리뷰 초점: (사용자 인자 또는 전반적 리뷰) +출력 형식 (반드시 준수): +## 리뷰 요약 +(전체 코드 품질 한 줄 평가) +## 이슈 +- [Critical] 파일:라인 - 설명 +- [Warning] 파일:라인 - 설명 +- [Suggestion] 파일:라인 - 설명 +## 개선 제안 +(구체적 수정 코드 - before/after) + 한글로 간결하게 답변해주세요." 2>/dev/null ``` @@ -71,14 +82,16 @@ codex exec resume --last "해당 이슈에 대한 구체적인 수정 코드를 ``` ### 호출 규칙 +- Claude의 판단/결론은 Codex에 전달하지 않음 — 사실 정보만 전달 (anchoring bias 방지). - `-C "$(pwd)"`, `-s danger-full-access`, `--json` 기본 적용. - `--ephemeral`은 사용하지 않습니다 (후속 resume 보존). - Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다. - 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다. - **중요: 리뷰 결론을 내리기 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.** - - `status: running` → Codex가 작업 중이므로 대기. 단독으로 리뷰를 완료하지 않음. + - `status: running` → Codex가 작업 중이므로 **완료될 때까지 대기**. TaskOutput 타임아웃은 Codex 실패가 아님 — `status: running`이면 `TaskOutput(block: true)`로 재시도하여 끝까지 기다림. 단독으로 리뷰를 완료하지 않음. - `status: completed` → 결과를 수집하여 반영. - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 리뷰). 대기하지 않음. + - **fallback 조건은 오직 `status: failed`/에러뿐**. 시간이 오래 걸리는 것은 fallback 사유가 아님. ## 실패 처리 From 9efa796e87f880b9351d93bbacba82b5c005a221 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 10:08:13 +0900 Subject: [PATCH 47/56] =?UTF-8?q?feat:=20OBS=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=98=81=EA=B5=AC=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?+=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit obs_token을 AppStoreData에 저장하여 앱 재시작 시 동일 토큰 재사용. 토큰 재생성 커맨드(obs_regenerate_token) + 확인 모달 UI 추가. OBS 브릿지 바인드를 0.0.0.0으로 변경하여 LAN 접근 허용. Co-Authored-By: Claude Opus 4.6 --- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/permissions/dmnote-allow-all.json | 1 + src-tauri/src/commands/app/obs.rs | 38 ++++++++++++- src-tauri/src/main.rs | 1 + src-tauri/src/models/mod.rs | 4 ++ src-tauri/src/services/obs_bridge.rs | 34 +++++++----- src-tauri/src/state/app_state.rs | 22 ++++++-- src/renderer/api/modules/obsApi.ts | 1 + .../content/dialogs/ObsTokenRegenModal.tsx | 54 +++++++++++++++++++ src/renderer/components/main/Settings.tsx | 39 +++++++++++--- .../components/main/Tool/SettingTool.tsx | 11 ++-- src/renderer/locales/en.json | 6 ++- src/renderer/locales/ko.json | 6 ++- src/renderer/locales/ru.json | 6 ++- src/renderer/locales/zh-Hant.json | 6 ++- src/renderer/locales/zh-cn.json | 6 ++- 16 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 91d89bfe..e4669d80 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_open_external","app_quit","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_load","css_reset","css_set_content","css_tab_clear","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_status","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","keys_update","layer_groups_get","layer_groups_update","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_start","obs_status","obs_stop","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","plugin_bridge_send","plugin_bridge_send_to","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","positions_update","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_save_processed_wav","sound_set_enabled","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_open_external","app_quit","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_load","css_reset","css_set_content","css_tab_clear","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_status","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","keys_update","layer_groups_get","layer_groups_update","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","plugin_bridge_send","plugin_bridge_send_to","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","positions_update","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_save_processed_wav","sound_set_enabled","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index 69f0b1b9..a41475d9 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -67,6 +67,7 @@ "note_tab_get", "note_tab_get_all", "note_tab_set", + "obs_regenerate_token", "obs_start", "obs_status", "obs_stop", diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 607ab5ed..52f611e3 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -1,9 +1,27 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; +use uuid::Uuid; use crate::{errors::CmdResult, models::obs::ObsStatus, state::AppState}; +/// 저장된 토큰 재사용 또는 신규 생성 후 store에 저장 +fn resolve_and_save_token(state: &AppState) -> String { + let existing = state.store.with_state(|s| s.obs_token.clone()); + if let Some(token) = existing { + if !token.is_empty() { + return token; + } + } + // 신규 생성 후 저장 + let token = Uuid::new_v4().simple().to_string(); + let t = token.clone(); + let _ = state.store.update(|s| { + s.obs_token = Some(t.clone()); + }); + token +} + #[tauri::command] pub async fn obs_start( app: AppHandle, @@ -11,6 +29,7 @@ pub async fn obs_start( port: Option, ) -> CmdResult { let port = port.unwrap_or(state.store.with_state(|s| s.obs_port)); + let token = resolve_and_save_token(&state); // OBS 정적 파일 서빙 설정 if cfg!(debug_assertions) { @@ -39,7 +58,7 @@ pub async fn obs_start( state .obs_bridge - .start(port) + .start(port, token) .await .map_err(crate::errors::CommandError::msg)?; // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨) @@ -65,3 +84,20 @@ pub async fn obs_stop(app: AppHandle, state: State<'_, AppState>) -> CmdResult) -> CmdResult { Ok(state.obs_bridge.status()) } + +#[tauri::command] +pub fn obs_regenerate_token(app: AppHandle, state: State<'_, AppState>) -> CmdResult { + let token = Uuid::new_v4().simple().to_string(); + // store에 저장 + let t = token.clone(); + let _ = state.store.update(|s| { + s.obs_token = Some(t.clone()); + }); + // 실행 중이면 bridge 메모리 토큰도 교체 + if state.obs_bridge.is_running() { + state.obs_bridge.set_token(token); + } + let status = state.obs_bridge.status(); + let _ = app.emit("obs:status", &status); + Ok(status) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fbbde589..0fd314b1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -156,6 +156,7 @@ fn main() { commands::app::obs::obs_start, commands::app::obs::obs_stop, commands::app::obs::obs_status, + commands::app::obs::obs_regenerate_token, // 에디터 콘텐츠 commands::editor::css::css_get, commands::editor::css::css_get_use, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 6293a664..4185842a 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1294,6 +1294,9 @@ pub struct AppStoreData { /// 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, @@ -1346,6 +1349,7 @@ impl Default for AppStoreData { sound_library: HashMap::new(), obs_mode_enabled: false, obs_port: default_obs_port(), + obs_token: None, plugin_data: HashMap::new(), } } diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index e7a554a6..8414cc8b 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -7,15 +7,13 @@ 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, Listener, Manager, Wry}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, oneshot}; use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -use tauri::ipc::{CallbackFn, InvokeBody, InvokeResponse, InvokeResponseBody}; -use tauri::webview::InvokeRequest; -use tauri::{AppHandle, Listener, Manager, Wry}; use crate::models::obs::{ make_envelope, HelloAckPayload, InvokeRequestPayload, ObsBroadcast, ObsEnvelope, ObsStatus, @@ -42,6 +40,7 @@ const DENIED_WS_COMMANDS: &[&str] = &[ // OBS 서버 제어 (자기 자신 종료/재시작 방지) "obs_start", "obs_stop", + "obs_regenerate_token", // 파일 대화상자 / 파일 쓰기 (로컬 파일 시스템 접근) "image_load", "font_load", @@ -234,8 +233,8 @@ impl ObsBridgeService { let _ = self.broadcast_tx.send(ObsBroadcast::Snapshot(snapshot)); } - /// WS 서버 시작 - pub async fn start(self: &Arc, port: u16) -> Result<(), String> { + /// WS 서버 시작 (토큰은 호출자가 전달) + pub async fn start(self: &Arc, port: u16, token: String) -> Result<(), String> { // 원자적 check-and-set if self .running @@ -253,15 +252,14 @@ impl ObsBridgeService { } } - // 세션 토큰 생성 (UUID v4, 하이픈 제거 = 32자 hex) - *self.session_token.write() = Uuid::new_v4().simple().to_string(); + // 세션 토큰 설정 (호출자가 생성/재사용 결정) + *self.session_token.write() = token; - let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = match TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { self.running.store(false, Ordering::Relaxed); - self.session_token.write().clear(); return Err(format!("포트 {port} 바인드 실패: {e}")); } }; @@ -279,7 +277,7 @@ impl ObsBridgeService { }); *self.server_handle.lock().await = Some(handle); - log::info!("[ObsBridge] 서버 시작: http://127.0.0.1:{actual_port}"); + log::info!("[ObsBridge] 서버 시작: http://0.0.0.0:{actual_port}"); Ok(()) } @@ -301,10 +299,15 @@ impl ObsBridgeService { let _ = tx.send(()); } self.running.store(false, Ordering::Relaxed); - self.session_token.write().clear(); + // 토큰은 유지 (재시작 시 동일 토큰 재사용) log::info!("[ObsBridge] 서버 종료"); } + /// 세션 토큰 교체 (실행 중 호출 가능) + pub fn set_token(&self, token: String) { + *self.session_token.write() = token; + } + async fn server_loop( self: &Arc, listener: TcpListener, @@ -699,7 +702,10 @@ impl ObsBridgeService { log::debug!("[ObsBridge] {addr}: denied cmd={}", req.command); let _ = rpc_tx.send(( req.request_id, - Err(serde_json::json!(format!("Command denied: {}", req.command))), + Err(serde_json::json!(format!( + "Command denied: {}", + req.command + ))), )); return; } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 4f3ddbcf..b205ccac 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -31,9 +31,9 @@ use crate::{ audio::{KeySoundEngine, KeySoundStatus}, keyboard::KeyboardManager, models::{ - overlay_resize_anchor_from_str, BootstrapOverlayState, - BootstrapPayload, DefaultsPayload, KeyCounterSettings, KeyCounters, KeyMappings, - OverlayBounds, OverlayResizeAnchor, SettingsDiff, SettingsState, + overlay_resize_anchor_from_str, BootstrapOverlayState, BootstrapPayload, DefaultsPayload, + KeyCounterSettings, KeyCounters, KeyMappings, OverlayBounds, OverlayResizeAnchor, + SettingsDiff, SettingsState, }, services::{css_watcher::CssWatcher, obs_bridge::ObsBridgeService, settings::SettingsService}, }; @@ -295,7 +295,19 @@ impl AppState { fn auto_start_obs(&self, app: &AppHandle) { let bridge = self.obs_bridge.clone(); let store = self.store.clone(); - let port = store.with_state(|s| s.obs_port); + let (port, existing_token) = store.with_state(|s| (s.obs_port, s.obs_token.clone())); + // 저장된 토큰 재사용 또는 신규 생성 + let token = match existing_token { + Some(t) if !t.is_empty() => t, + _ => { + let t = uuid::Uuid::new_v4().simple().to_string(); + let tc = t.clone(); + let _ = store.update(|s| { + s.obs_token = Some(tc.clone()); + }); + t + } + }; let app_handle = app.clone(); // dev 모드: Vite dev server로 리다이렉트 @@ -328,7 +340,7 @@ impl AppState { // async start를 tokio 런타임에서 실행 tauri::async_runtime::spawn(async move { - match bridge.start(port).await { + match bridge.start(port, token).await { Ok(()) => { log::info!("[ObsBridge] auto-start 성공 (port={})", port); let state = app_handle.state::(); diff --git a/src/renderer/api/modules/obsApi.ts b/src/renderer/api/modules/obsApi.ts index 36b814e5..ffb6a0a4 100644 --- a/src/renderer/api/modules/obsApi.ts +++ b/src/renderer/api/modules/obsApi.ts @@ -7,6 +7,7 @@ export const obsApi = { start: (port?: number) => invoke('obs_start', { port }), stop: () => invoke('obs_stop'), status: () => invoke('obs_status'), + regenerateToken: () => invoke('obs_regenerate_token'), onStatus: (listener: (status: ObsStatus) => void) => subscribe('obs:status', listener), }; diff --git a/src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx b/src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx new file mode 100644 index 00000000..56e6c93a --- /dev/null +++ b/src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import Modal from '@components/main/Modal/Modal'; + +interface ObsTokenRegenModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + t: (key: string) => string; +} + +export function ObsTokenRegenModal({ + isOpen, + onClose, + onConfirm, + t, +}: ObsTokenRegenModalProps) { + if (!isOpen) return null; + + return ( + +
event.stopPropagation()} + > +
+ + {t('settings.obsTokenRegenMessage')} + +
+ +
+ + {t('settings.obsTokenRegenWarning')} + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index bfe47c12..af281009 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -8,6 +8,7 @@ import Dropdown from '@components/main/common/Dropdown'; import FlaskIcon from '@assets/svgs/flask.svg'; import { PluginManagerModal } from '@components/main/Modal/content/managers/PluginManagerModal'; import { PluginDataDeleteModal } from '@components/main/Modal/content/dialogs/PluginDataDeleteModal'; +import { ObsTokenRegenModal } from '@components/main/Modal/content/dialogs/ObsTokenRegenModal'; import ShortcutSettingsModal from '@components/main/Modal/content/settings/ShortcutSettingsModal'; import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; import { extractPluginId } from '@utils/plugin/pluginUtils'; @@ -136,6 +137,8 @@ const Settings = ({ }); const [obsPort, setObsPort] = useState(String(storedObsPort)); const [obsLoading, setObsLoading] = useState(false); + const [isTokenRegenModalOpen, setTokenRegenModalOpen] = + useState(false); // 스토어 포트 변경 시 로컬 상태 동기화 (bootstrap 비동기 로딩 대응) useEffect(() => { @@ -622,6 +625,16 @@ const Settings = ({ } }; + const handleObsRegenerateToken = async (): Promise => { + setTokenRegenModalOpen(false); + try { + const status = await obsApi.regenerateToken(); + setObsStatus(status); + } catch (error) { + console.error('Failed to regenerate OBS token', error); + } + }; + const handleDeveloperModeToggle = async (): Promise => { const next: boolean = !developerModeEnabled; setDeveloperModeEnabled(next); @@ -967,12 +980,20 @@ const Settings = ({
{obsStatus.running && ( - + <> + + + )}
); }; diff --git a/src/renderer/components/main/Tool/SettingTool.tsx b/src/renderer/components/main/Tool/SettingTool.tsx index c305e515..7d71e1e2 100644 --- a/src/renderer/components/main/Tool/SettingTool.tsx +++ b/src/renderer/components/main/Tool/SettingTool.tsx @@ -294,8 +294,8 @@ SettingToolProps) => { isObsModeActive ? t('tooltip.overlayObsDisabled') : isOverlayVisible - ? t('tooltip.overlayClose') - : t('tooltip.overlayOpen') + ? t('tooltip.overlayClose') + : t('tooltip.overlayOpen') } >
-
-

- {t('settings.obsPort')} -

- setObsPort(e.target.value)} - disabled={obsStatus.running} - className="w-[80px] bg-[#2A2A31] border border-[#3A3944] rounded-[5px] px-[8px] py-[3px] text-style-2 text-[#DBDEE8] disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - min={1024} - max={65535} - /> -
{obsStatus.running && ( <> diff --git a/src/renderer/defaults.ts b/src/renderer/defaults.ts index 260dd883..06b4efd0 100644 --- a/src/renderer/defaults.ts +++ b/src/renderer/defaults.ts @@ -6,7 +6,6 @@ import type { NoteSettings } from '@src/types/settings/noteSettings'; import type { GridSettings, SettingsState } from '@src/types/settings/settings'; import type { ShortcutsState } from '@src/types/settings/shortcuts'; import type { FontSettings } from '@src/types/settings/fonts'; -import { DEFAULT_OBS_PORT } from '@src/types/obs'; export interface DefaultsPayload { settings: SettingsState; @@ -212,6 +211,5 @@ function FALLBACK_SETTINGS_STATE(): SettingsState { gridSettings: FALLBACK_GRID_SETTINGS(), shortcuts: FALLBACK_SHORTCUTS(), obsModeEnabled: false, - obsPort: DEFAULT_OBS_PORT, }; } diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index 29a71175..11f52866 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -27,7 +27,6 @@ import { refreshCursorSettings, } from '@utils/grid/cursorUtils'; import type { CustomJs, JsPlugin } from '@src/types/plugin/js'; -import { DEFAULT_OBS_PORT } from '@src/types/obs'; function clonePlugins(source?: CustomJs | null): JsPlugin[] { if (!source) return []; @@ -209,7 +208,6 @@ export function useAppBootstrap() { bootstrap.settings.gridSettings ?? getDefaultGridSettings(), shortcuts: bootstrap.settings.shortcuts ?? getDefaultShortcuts(), obsModeEnabled: bootstrap.settings.obsModeEnabled ?? false, - obsPort: bootstrap.settings.obsPort ?? DEFAULT_OBS_PORT, }); useFontStore.setState({ customFonts: bootstrap.settings.fontSettings.customFonts.map( diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 56bcdc4d..fc59ac11 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -74,7 +74,6 @@ "counterReset": "Key counters have been reset.", "counterResetFailed": "Failed to reset key counters.", "obsMode": "OBS Mode", - "obsPort": "Port", "obsStart": "Start", "obsStop": "Stop", "obsRunning": "Running", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 9c155979..eecdc05a 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -74,7 +74,6 @@ "counterReset": "키 카운터가 초기화되었습니다.", "counterResetFailed": "키 카운터 초기화에 실패했습니다.", "obsMode": "OBS 모드", - "obsPort": "포트", "obsStart": "시작", "obsStop": "중지", "obsRunning": "실행 중", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 30d3816e..2776079f 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -74,7 +74,6 @@ "counterReset": "Счётчики сброшены.", "counterResetFailed": "Ошибка сброса счётчиков.", "obsMode": "OBS Mode", - "obsPort": "Port", "obsStart": "Start", "obsStop": "Stop", "obsRunning": "Running", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 51998b11..e7179c9d 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -74,7 +74,6 @@ "counterReset": "按鍵計數器已重置.", "counterResetFailed": "重置按鍵計數器失敗.", "obsMode": "OBS Mode", - "obsPort": "Port", "obsStart": "Start", "obsStop": "Stop", "obsRunning": "Running", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index eb5befc9..8b66ad24 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -74,7 +74,6 @@ "counterReset": "按键计数器已重置.", "counterResetFailed": "重置按键计数器失败.", "obsMode": "OBS Mode", - "obsPort": "Port", "obsStart": "Start", "obsStop": "Stop", "obsRunning": "Running", diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts index 2ae6d2a7..a982b37b 100644 --- a/src/renderer/stores/useSettingsStore.ts +++ b/src/renderer/stores/useSettingsStore.ts @@ -7,7 +7,6 @@ import type { FontSettings } from '@src/types/settings/fonts'; import type { OverlayResizeAnchor } from '@src/types/settings/settings'; import type { JsPlugin } from '@src/types/plugin/js'; import type { ShortcutsState } from '@src/types/settings/shortcuts'; -import { DEFAULT_OBS_PORT } from '@src/types/obs'; import { getDefaultNoteSettings, getDefaultFontSettings, @@ -48,7 +47,6 @@ interface SettingsState { gridSettings: GridSettings; shortcuts: ShortcutsState; obsModeEnabled: boolean; - obsPort: number; setAll: (payload: SettingsStateSnapshot) => void; merge: (payload: Partial) => void; setLaboratoryEnabled: (value: boolean) => void; @@ -75,7 +73,6 @@ interface SettingsState { setGridSettings: (value: GridSettings) => void; setShortcuts: (value: ShortcutsState) => void; setObsModeEnabled: (value: boolean) => void; - setObsPort: (value: number) => void; } export type SettingsStateSnapshot = Omit< @@ -106,7 +103,6 @@ export type SettingsStateSnapshot = Omit< | 'setGridSettings' | 'setShortcuts' | 'setObsModeEnabled' - | 'setObsPort' >; const initialState: SettingsStateSnapshot = { @@ -134,7 +130,6 @@ const initialState: SettingsStateSnapshot = { gridSettings: getDefaultGridSettings(), shortcuts: getDefaultShortcuts(), obsModeEnabled: false, - obsPort: DEFAULT_OBS_PORT, }; function mergeSnapshot( @@ -214,5 +209,4 @@ export const useSettingsStore = create((set) => ({ setGridSettings: (value) => set({ gridSettings: value }), setShortcuts: (value) => set({ shortcuts: value }), setObsModeEnabled: (value) => set({ obsModeEnabled: value }), - setObsPort: (value) => set({ obsPort: value }), })); diff --git a/src/types/settings/settings.ts b/src/types/settings/settings.ts index 803bf1f1..02771be4 100644 --- a/src/types/settings/settings.ts +++ b/src/types/settings/settings.ts @@ -56,7 +56,6 @@ export interface SettingsState { gridSettings: GridSettings; shortcuts: ShortcutsState; obsModeEnabled: boolean; - obsPort: number; } /** @deprecated Use getDefaultSettingsState() from @src/renderer/defaults */ From ce84c6475299d62c672f802f61e3f6bc00bc56b5 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 10:28:36 +0900 Subject: [PATCH 49/56] =?UTF-8?q?docs:=20obs-mode-improvements=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20+=20Codex=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=EC=97=90=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §1(토큰 영구 저장), §5(자동 포트 fallback) 구현 완료 반영 - codex-review/plan/debug 스킬에 오버엔지니어링 피드백 필터링 규칙 추가 Co-Authored-By: Claude Opus 4.6 --- docs/obs-mode-improvements.md | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/obs-mode-improvements.md diff --git a/docs/obs-mode-improvements.md b/docs/obs-mode-improvements.md new file mode 100644 index 00000000..4a865eda --- /dev/null +++ b/docs/obs-mode-improvements.md @@ -0,0 +1,194 @@ +# OBS 모드 개선 계획 + +> 작성일: 2026-03-08 (최종 업데이트: 2026-03-10) +> 기반 문서: [obs-mode-design.md](obs-mode-design.md) +> 상태: §1, §3, §5 구현 완료 / §2, §4 계획 중 + +--- + +## 배경 + +v4까지 OBS 모드 핵심 기능은 완성되었으나, 실사용 편의성에서 개선이 필요한 부분이 확인됨. +특히 투컴 구성(게임 PC + OBS PC) 및 모바일 접속 시나리오에서 불편 발생. + +--- + +## 1. 세션 토큰 영구 저장 (완료) + +> 2026-03-10 구현 완료 (`9efa796`) + +- `AppStoreData`에 `obs_token: Option` 필드 추가 +- `obs_start` 시 저장된 토큰 재사용, 없으면 생성 후 저장 +- `stop()` 시 토큰 유지 (재시작 시 동일 토큰 사용) +- `obs_regenerate_token` 커맨드 + 확인 모달 UI 추가 +- `DENIED_WS_COMMANDS`에 `obs_regenerate_token` 등록 + +--- + +## 2. LAN 접속 지원 (URL 복사 + dev 모드) + +> 상세 논의 예정 — 추가 요구사항 확인 후 보완 + +### 현재 문제 + +#### 2-1. URL 복사 시 localhost 고정 + +- `Settings.tsx:615`에서 `http://localhost:${port}?token=${token}`으로 하드코딩 +- 투컴 구성에서 서브 PC가 메인 PC의 OBS 서버에 접속하려면 LAN IP가 필요 +- 모바일(폰) 접속 시에도 LAN IP 필요 + +#### 2-2. dev 모드 리다이렉트 문제 + +- `obs_bridge.rs:383-407`에서 Vite dev server로 `http://localhost:3400`으로 302 리다이렉트 +- LAN IP로 접속해도 `localhost:3400`으로 리다이렉트되어 원격 기기에서 실패 +- Vite dev server 자체도 `localhost`에 바인딩되어 있을 수 있음 + +### 해결 방향 + +URL에 IP 직접 노출을 피하면서 접속 편의성을 확보하는 방안 검토 결과: + +| 방안 | IP 은닉 | 서버 필요 | 지연 | 안정성 | +|------|---------|----------|------|--------| +| IP 직접 노출 | X | X | <1ms | 높음 | +| mDNS (`dmnote.local`) | O | X | <1ms | 환경 따라 불안정 (OBS CEF, Android 미지원 가능) | +| UI 마스킹 + QR코드 | 부분적 | X | <1ms | 높음 | +| DNS 서버 (도메인→IP) | 부분적 (서버에 IP 전송) | O | <1ms | 높음 | +| 릴레이 서버 (트래픽 경유) | 완전 | O | 20~100ms | 서버 의존 | + +**현재 단계 결정: UI 마스킹 + QR코드** + +- 설정 화면에서 URL 텍스트를 직접 보여주지 않음 (예: `OBS 서버 실행 중 (포트: 34891)`) +- 복사 버튼 클릭 시에만 클립보드에 full URL 복사 +- QR코드 제공으로 모바일 접속 시 IP 텍스트 미노출 +- LAN IP 조회: `if-addrs` 크레이트 사용 (다중 NIC 후보 대응) +- dev 모드 리다이렉트 시 `host` 파라미터도 함께 전달 + +**향후 확장: 릴레이 서버 (§4 참조)** + +--- + +## 3. 바인딩 주소 변경 (완료) + +> 2026-03-08 적용 완료 + +- `obs_bridge.rs:259`: `127.0.0.1` → `0.0.0.0`으로 변경 +- 같은 네트워크의 다른 기기에서 접속 가능하도록 개방 +- 세션 토큰 인증이 있으므로 URL을 모르는 기기는 접근 불가 + +--- + +## 4. 외부 공유 링크 — 릴레이 서버 (향후 확장) + +> 상태: 검토 완료, 구현 미정 + +### 개요 + +공유 링크(`https://abc123.obs.dmnote.com`)를 통해 외부 네트워크의 누구나 키뷰어를 실시간으로 볼 수 있는 1:N 브로드캐스트 기능. + +### 아키텍처 + +``` +같은 네트워크 (LAN): + [PC] ──── LAN 직접 연결 (<1ms) ────→ [OBS/폰] + +외부 네트워크 (릴레이): + [PC] → WS → [릴레이 서버] → WS → [뷰어 1] + → WS → [뷰어 2] + → WS → [뷰어 N (최대 8명)] +``` + +### LAN/릴레이 자동 분기 + +하나의 URL로 접속해도 클라이언트가 자동으로 최적 경로를 선택: + +``` +1. 뷰어가 https://abc123.obs.dmnote.com 접속 +2. 서버가 호스트의 LAN IP 정보를 내려줌 +3. 클라이언트가 LAN IP로 직접 연결 시도 (타임아웃 1~2초) +4. 성공 → LAN 직접 연결 (<1ms) + 실패 → 릴레이 서버 경유 (30~100ms) +``` + +### 지연 시간 + +| 경로 | 구간 | 예상 지연 | +|------|------|----------| +| LAN 직접 (현재) | PC → LAN → OBS/폰 | <1ms | +| 릴레이 (같은 네트워크) | PC → 서버 → OBS/폰 | 20~60ms | +| 릴레이 (외부 뷰어) | PC → 서버 → 외부 뷰어 | 30~100ms | + +키뷰어를 "보는" 용도이므로 30~100ms는 체감상 문제 없는 수준. + +### 서버 비용 예상 (뷰어 최대 8명 제한, 실사용 하루 3시간/호스트 기준) + +| 규모 | 등록 호스트 | 동시 호스트 (~15%) | 동시 연결 수 | 트래픽/월 | 월 비용 | +|------|-----------|-------------------|-------------|----------|--------| +| 초기 | 50 | ~10 | ~90 | ~56GB | ~$5 | +| 중간 | 500 | ~80 | ~720 | ~450GB | $5~10 | +| 대규모 | 5,000 | ~800 | ~7,200 | ~4.5TB | $15~40 | + +트래픽 산출 근거: +- 키 이벤트 ~10회/초 × ~200B = ~2KB/s (호스트→서버) +- 서버→뷰어: ~2KB/s × 8명 = ~16KB/s +- 호스트당 총 ~18KB/s ≈ 5.6GB/월 (하루 3시간 기준) + +### 추천 서버 옵션 + +| 옵션 | 장점 | 단점 | 비용 | +|------|------|------|------| +| **Cloudflare Workers + Durable Objects** | 글로벌 엣지, WS Hibernation으로 유휴 비용 0, egress 무료 | Durable Objects 학습 곡선 | $5 기본 + 사용량 | +| Fly.io (도쿄) | 한국 근접, 배포 간편 | 무료 티어 없음 | shared-1x ~$3 + 트래픽 | +| VPS (Hetzner/Contabo) | 고정 비용, 트래픽 무제한 | 스케일링 수동, 운영 부담 | $5~15 | + +### 프라이버시 정책 + +릴레이 서버는 호스트의 공인 IP를 알게 됨 (WS 연결 특성상 불가피). 이에 대한 대응: + +- **서버에 IP를 저장하지 않음** — 실시간 중계만 수행, 연결 종료 시 정보 폐기 +- **로그에 IP 미기록** — 접속 로그에서 IP 제외 +- **프라이버시 정책 명시** — "서버는 IP를 저장하지 않으며, 실시간 중계 목적으로만 사용됩니다" +- **외부 뷰어에게 호스트 IP 은닉** — 뷰어는 릴레이 서버 도메인만 알 수 있음 + +| 주체 | 호스트 공인 IP | 호스트 LAN IP | +|------|--------------|-------------| +| 릴레이 서버 | 알게 됨 (미저장) | 모름 | +| 외부 뷰어 | 모름 (서버가 가려줌) | 모름 | + +### 오픈소스 전략 + +DmNote는 오픈소스 프로젝트이므로, 릴레이 서버도 오픈소스로 공개 가능: + +- **클라이언트 코드 (서버 연결 관련)**: 전부 공개해도 문제 없음. 서버 주소, API 엔드포인트, 프로토콜 등은 네트워크 탭에서 확인 가능한 정보 +- **서버 코드**: 공개 가능. 보안은 코드 은닉이 아닌 인증/권한 설계로 해결 (JWT 시크릿 등은 `.env`로 분리) +- **셀프 호스팅 지원**: 유저가 자체 릴레이 서버를 운영하여 뷰어 제한 해제, 커스텀 도메인 등 가능 +- 가려야 할 것: 코드가 아닌 **환경변수** (API 키, DB 비밀번호, JWT 시크릿 등) + +### 현재 WS 프로토콜과의 호환 + +기존 WS 프로토콜(`hello` → `hello_ack` → `snapshot` → `tauri_event`)을 릴레이 서버가 그대로 중계하면 되므로, 프로토콜 변경 없이 확장 가능. 클라이언트(OBS 페이지) 입장에서는 직접 연결이든 릴레이든 동일한 프로토콜로 동작. + +--- + +## 5. 자동 포트 fallback (완료) + +> 2026-03-10 구현 완료 (`553e1cc`) + +### 변경 내용 + +- 포트 수동 설정 UI 제거 (입력 필드, 저장 로직, i18n 키) +- `SettingsState`, `SettingsPatchInput`, `SettingsDiff`에서 `obs_port` 제거 +- `obs_start` 커맨드에서 `port` 파라미터 제거 +- `obs_bridge.start()`가 자동 포트 fallback: 저장된 포트 시도 → 실패 시 +1~+9 순차 시도 +- 성공한 포트를 `AppStoreData.obs_port`에 저장 (다음 시작 시 재사용) +- `AppStoreData.obs_port`는 내부 저장용으로 유지 (프론트에 노출하지 않음) + +### 동작 흐름 + +``` +1. obs_start 호출 +2. store에서 obs_port 읽기 (기본값: 3333) +3. 해당 포트로 바인드 시도 +4. 실패 → +1, +2, ... +9까지 순차 시도 +5. 성공한 포트를 store에 저장 + ObsStatus.port로 반환 +6. 다음 시작 시 저장된 포트 우선 사용 +``` From c54c7f65ec5f87c2be70f4b765ce1c30867c3578 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 10:40:46 +0900 Subject: [PATCH 50/56] =?UTF-8?q?fix:=20clippy=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=BD=EA=B3=A0=20=EC=88=98=EC=A0=95,=20ESLint?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=A0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uninit_vec: Vec::with_capacity + set_len → vec![0u8; size] - 불필요한 raw pointer 캐스트 제거 (ipc.rs, app_state.rs) - 불필요한 참조 제거 (main.rs) - unused import 제거 (load.rs, app_state.rs) - &PathBuf → &Path (main.rs) - snapshotReceived → _snapshotReceived (ipcShim.ts) - cargo clippy --fix 자동 수정 반영 (obs_bridge.rs, windows.rs) Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/preset/load.rs | 4 ++-- src-tauri/src/ipc.rs | 4 ++-- src-tauri/src/keyboard/daemon/windows.rs | 11 +++++------ src-tauri/src/main.rs | 8 ++++---- src-tauri/src/services/obs_bridge.rs | 2 +- src-tauri/src/state/app_state.rs | 14 ++++++-------- src/renderer/api/ipcShim.ts | 8 ++++---- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 60541948..2b835d0c 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -10,7 +10,7 @@ use crate::{ errors::{CmdResult, CommandError}, models::{ CustomCssPatch, CustomJsPatch, FontType, GraphPositions, KeyMappings, KeyPositions, - NoteSettings, NoteSettingsPatch, SettingsPatchInput, StatPositions, + NoteSettingsPatch, SettingsPatchInput, StatPositions, }, state::AppState, }; @@ -52,7 +52,7 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult anyhow::Result { return Err(anyhow::anyhow!("ConnectNamedPipe failed: {:?}", err)); } } - let file = std::fs::File::from_raw_handle(handle.0 as *mut std::ffi::c_void); + let file = std::fs::File::from_raw_handle(handle.0); Ok(file) } } @@ -127,7 +127,7 @@ pub fn pipe_client_connect(name: &str) -> anyhow::Result { Ok(h) => h, Err(e) => return Err(anyhow::anyhow!("CreateFileW to pipe failed: {}", e)), }; - let file = std::fs::File::from_raw_handle(handle.0 as *mut std::ffi::c_void); + let file = std::fs::File::from_raw_handle(handle.0); Ok(file) } } diff --git a/src-tauri/src/keyboard/daemon/windows.rs b/src-tauri/src/keyboard/daemon/windows.rs index 84f9bfec..aefe1df3 100644 --- a/src-tauri/src/keyboard/daemon/windows.rs +++ b/src-tauri/src/keyboard/daemon/windows.rs @@ -134,7 +134,7 @@ fn vk_from_key_code(code: &str) -> Option { if let Some(rest) = code.strip_prefix("Key") { if rest.len() == 1 { let ch = rest.chars().next()?.to_ascii_uppercase(); - if ('A'..='Z').contains(&ch) { + if ch.is_ascii_uppercase() { return Some(ch as u32); } } @@ -143,7 +143,7 @@ fn vk_from_key_code(code: &str) -> Option { if let Some(rest) = code.strip_prefix("Digit") { if rest.len() == 1 { let ch = rest.chars().next()?; - if ('0'..='9').contains(&ch) { + if ch.is_ascii_digit() { return Some(ch as u32); } } @@ -323,8 +323,7 @@ pub(super) fn run_raw_input() -> Result<()> { continue; } - let mut buffer: Vec = Vec::with_capacity(size as usize); - buffer.set_len(size as usize); + let mut buffer: Vec = vec![0u8; size as usize]; let hraw = HRAWINPUT(msg.lParam.0 as *mut c_void); let res = GetRawInputData( @@ -367,7 +366,7 @@ pub(super) fn run_raw_input() -> Result<()> { let mut vk_norm = vkey; if vk_norm == 0 || vk_norm == 0xFF { let mapped = - MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX) as u32; + MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX); if mapped != 0 { vk_norm = mapped; } else { @@ -391,7 +390,7 @@ pub(super) fn run_raw_input() -> Result<()> { if vk_norm == VK_SHIFT { let mapped = - MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX) as u32; + MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX); match mapped { VK_LSHIFT | VK_RSHIFT => vk_norm = mapped, _ => match scan_code { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0fd314b1..a16eb68b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -122,7 +122,7 @@ fn main() { } let resolver = app.path(); - let store = AppStore::initialize(&resolver) + let store = AppStore::initialize(resolver) .map_err(|e| -> Box { e.into() })?; let app_state = AppState::initialize(store) .map_err(|e| -> Box { e.into() })?; @@ -131,10 +131,10 @@ fn main() { { let state = app.state::(); state - .initialize_runtime(&handle) + .initialize_runtime(handle) .map_err(|e| -> Box { e.into() })?; } - configure_main_window(&app.handle()); + configure_main_window(app.handle()); #[cfg(target_os = "macos")] launch_macos_dock_helper(); @@ -645,7 +645,7 @@ fn apply_webview2_fixed_runtime_override() { } #[cfg(target_os = "windows")] -fn is_valid_webview2_fixed_runtime_dir(dir: &PathBuf) -> bool { +fn is_valid_webview2_fixed_runtime_dir(dir: &std::path::Path) -> bool { dir.is_dir() && dir.join("msedgewebview2.exe").is_file() } diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 4d62b81b..648674d0 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -472,7 +472,7 @@ impl ObsBridgeService { let has_extension = normalized .rsplit('/') .next() - .map_or(false, |filename| filename.contains('.')); + .is_some_and(|filename| filename.contains('.')); if !has_extension { if let Some((content, mime)) = self.resolve_asset("obs/index.html").await { let response = format!( diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 17244def..866c254a 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -113,10 +113,10 @@ impl AppState { // 개발자 모드가 켜져 있으면 시작 시 DevTools 오픈 허용 및 자동 오픈 시도 if snapshot.developer_mode_enabled { if let Some(main) = app.get_webview_window("main") { - let _ = main.open_devtools(); + main.open_devtools(); } if let Some(overlay) = app.get_webview_window("overlay") { - let _ = overlay.open_devtools(); + overlay.open_devtools(); } } self.start_keyboard_hook(app.clone())?; @@ -861,8 +861,7 @@ impl AppState { crate::ipc::HookKeyState::Up => "UP", }; let labels_for_emit = message.labels.clone(); - let primary_label = labels_for_emit - .get(0) + let primary_label = labels_for_emit.first() .cloned() .unwrap_or_else(|| String::from("")); @@ -1376,10 +1375,10 @@ impl AppState { // 활성화 시에만 DevTools 열기 if enabled { if let Some(main) = app.get_webview_window("main") { - let _ = main.open_devtools(); + main.open_devtools(); } if let Some(overlay) = app.get_webview_window(OVERLAY_LABEL) { - let _ = overlay.open_devtools(); + overlay.open_devtools(); } } } @@ -1864,7 +1863,6 @@ fn set_window_no_activate(window: &WebviewWindow) -> Result<()> { /// (Electron의 hookWindowMessage 방식과 동일) #[cfg(target_os = "windows")] fn disable_system_context_menu(window: &WebviewWindow) -> Result<()> { - use std::ffi::c_void; use windows::Win32::{ Foundation::{HWND, LPARAM, LRESULT, WPARAM}, UI::{ @@ -1903,7 +1901,7 @@ fn disable_system_context_menu(window: &WebviewWindow) -> Result<()> { } let hwnd = window.hwnd()?; - let hwnd_win = HWND(hwnd.0 as *mut c_void); + let hwnd_win = HWND(hwnd.0); unsafe { SetWindowSubclass(hwnd_win, Some(subclass_proc), SUBCLASS_ID, 0) diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts index a4fc4e20..f9362058 100644 --- a/src/renderer/api/ipcShim.ts +++ b/src/renderer/api/ipcShim.ts @@ -46,7 +46,7 @@ const pendingRpc = new Map< >(); // snapshot 수신 여부 (initIpcShim에서 연결 준비 확인용) -let snapshotReceived = false; +let _snapshotReceived = false; // ── deny 체크 ── @@ -178,7 +178,7 @@ function onWsMessage(envelope: ObsEnvelope) { case 'snapshot': { // 재연결 시 snapshot 수신 — 연결 준비 신호로만 사용 - snapshotReceived = true; + _snapshotReceived = true; break; } } @@ -332,7 +332,7 @@ export function initIpcShim(wsUrl: string, token: string): Promise { // snapshot 수신 시 글로벌 설치 후 resolve if (envelope.type === 'snapshot' && !resolved) { - snapshotReceived = true; + _snapshotReceived = true; installGlobals(); resolved = true; resolve(); @@ -426,5 +426,5 @@ export function disposeIpcShim() { eventListeners.clear(); eventListenersByName.clear(); denyList = []; - snapshotReceived = false; + _snapshotReceived = false; } From b368d404253d9d822dec56bf61584d91550c520e Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 10:52:14 +0900 Subject: [PATCH 51/56] =?UTF-8?q?fix:=20clippy=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=ED=95=B4=EC=86=8C=20+=20cargo=20fmt=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_or(false, ...) → is_some_and(...) (engine.rs, obs_bridge.rs) - 불필요한 return 제거 (update.rs, daemon/mod.rs) - let _ = open_devtools() → open_devtools() (system.rs) - .iter().cloned().filter() → .iter().filter().cloned() (keys.rs) - 범위 비교 → contains() (lib.rs) - Default impl → #[derive(Default)] + #[default] (models/mod.rs) - field_reassign_with_default → 구조체 리터럴 (load.rs, store.rs, image.rs) - #[allow] 적용: too_many_arguments, enum_variant_names, module_inception Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/audio/engine.rs | 2 +- src-tauri/src/commands/app/obs.rs | 5 +- src-tauri/src/commands/app/system.rs | 4 +- src-tauri/src/commands/app/update.rs | 2 +- src-tauri/src/commands/keys/keys.rs | 2 +- src-tauri/src/commands/keys/mod.rs | 1 + src-tauri/src/commands/media/image.rs | 28 +++--- src-tauri/src/commands/preset/load.rs | 29 +++--- src-tauri/src/ipc.rs | 1 + src-tauri/src/keyboard/daemon/mod.rs | 2 +- src-tauri/src/keyboard/daemon/windows.rs | 6 +- src-tauri/src/lib.rs | 2 +- src-tauri/src/models/mod.rs | 112 +++-------------------- src-tauri/src/services/obs_bridge.rs | 4 +- src-tauri/src/state/app_state.rs | 1 + src-tauri/src/state/store.rs | 8 +- 16 files changed, 64 insertions(+), 145 deletions(-) diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs index aed476dc..4f25f085 100644 --- a/src-tauri/src/audio/engine.rs +++ b/src-tauri/src/audio/engine.rs @@ -568,7 +568,7 @@ fn play_on_stream( *stream_handler = OutputStream::try_default().ok(); stream_handler .as_ref() - .map_or(false, |h| try_play(&h.1, source, volume).is_ok()) + .is_some_and(|h| try_play(&h.1, source, volume).is_ok()) } Err(_) => false, } diff --git a/src-tauri/src/commands/app/obs.rs b/src-tauri/src/commands/app/obs.rs index 0610445f..57c6baaf 100644 --- a/src-tauri/src/commands/app/obs.rs +++ b/src-tauri/src/commands/app/obs.rs @@ -23,10 +23,7 @@ fn resolve_and_save_token(state: &AppState) -> String { } #[tauri::command] -pub async fn obs_start( - app: AppHandle, - state: State<'_, AppState>, -) -> CmdResult { +pub async fn obs_start(app: AppHandle, state: State<'_, AppState>) -> CmdResult { let port = state.store.with_state(|s| s.obs_port); let token = resolve_and_save_token(&state); diff --git a/src-tauri/src/commands/app/system.rs b/src-tauri/src/commands/app/system.rs index 4fba0839..48f31d3f 100644 --- a/src-tauri/src/commands/app/system.rs +++ b/src-tauri/src/commands/app/system.rs @@ -62,11 +62,11 @@ pub fn app_quit(app: AppHandle, state: State<'_, AppState>) -> CmdResult<()> { #[tauri::command] pub fn window_open_devtools_all(app: AppHandle) -> CmdResult<()> { if let Some(main) = app.get_webview_window("main") { - let _ = main.open_devtools(); + main.open_devtools(); let _ = main.show(); } if let Some(overlay) = app.get_webview_window("overlay") { - let _ = overlay.open_devtools(); + overlay.open_devtools(); let _ = overlay.show(); } Ok(()) diff --git a/src-tauri/src/commands/app/update.rs b/src-tauri/src/commands/app/update.rs index a01c6f5c..cf584119 100644 --- a/src-tauri/src/commands/app/update.rs +++ b/src-tauri/src/commands/app/update.rs @@ -14,7 +14,7 @@ pub struct AutoUpdateResult { pub fn app_auto_update(app: AppHandle, tag: String) -> CmdResult { #[cfg(target_os = "windows")] { - return app_auto_update_windows(app, &tag); + app_auto_update_windows(app, &tag) } #[cfg(not(target_os = "windows"))] diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 350dee27..c4e8c97d 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -413,8 +413,8 @@ pub fn custom_tabs_delete( let custom_tabs: Vec = snapshot .custom_tabs .iter() + .filter(|&tab| tab.id != id) .cloned() - .filter(|tab| tab.id != id) .collect(); let mut keys = snapshot.keys.clone(); let mut positions = snapshot.key_positions.clone(); diff --git a/src-tauri/src/commands/keys/mod.rs b/src-tauri/src/commands/keys/mod.rs index 984eb5cd..60e6e3a1 100644 --- a/src-tauri/src/commands/keys/mod.rs +++ b/src-tauri/src/commands/keys/mod.rs @@ -1,3 +1,4 @@ pub mod key_sound; +#[allow(clippy::module_inception)] pub mod keys; pub mod sound; diff --git a/src-tauri/src/commands/media/image.rs b/src-tauri/src/commands/media/image.rs index 6958dc0a..3e365869 100644 --- a/src-tauri/src/commands/media/image.rs +++ b/src-tauri/src/commands/media/image.rs @@ -242,20 +242,22 @@ fn convert_gif_to_webp(gif_bytes: &[u8], output_path: &Path) -> CmdResult<()> { let repeat = decoder.repeat(); let mut screen = Screen::new_decoder(&decoder); - let mut encoder_options = EncoderOptions::default(); - encoder_options.anim_params = AnimParams { - loop_count: gif_repeat_to_loop_count(repeat), - }; - encoder_options.allow_mixed = true; - encoder_options.minimize_size = true; - encoder_options.encoding_config = Some(EncodingConfig { - encoding_type: EncodingType::Lossy(LossyEncodingConfig { - alpha_compression: true, - ..Default::default() + let encoder_options = EncoderOptions { + anim_params: AnimParams { + loop_count: gif_repeat_to_loop_count(repeat), + }, + allow_mixed: true, + minimize_size: true, + encoding_config: Some(EncodingConfig { + encoding_type: EncodingType::Lossy(LossyEncodingConfig { + alpha_compression: true, + ..Default::default() + }), + quality: 78.0, + method: 4, }), - quality: 78.0, - method: 4, - }); + ..Default::default() + }; let mut encoder = Encoder::new_with_options((width, height), encoder_options) .map_err(|e| CommandError::msg(format!("WebP 인코더 초기화 실패: {e}")))?; diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 2b835d0c..8357a5ba 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -54,20 +54,21 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult InputDeviceKind { /// Command messages from keyboard daemon (e.g., global hotkeys) #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] +#[allow(clippy::enum_variant_names)] pub enum DaemonCommand { /// Toggle overlay visibility (Ctrl+Shift+O) ToggleOverlay, diff --git a/src-tauri/src/keyboard/daemon/mod.rs b/src-tauri/src/keyboard/daemon/mod.rs index 910bc588..7e48dc3d 100644 --- a/src-tauri/src/keyboard/daemon/mod.rs +++ b/src-tauri/src/keyboard/daemon/mod.rs @@ -38,7 +38,7 @@ fn write_command(sink: &mut Box, command: &DaemonCommand) -> R pub fn run() -> Result<()> { #[cfg(target_os = "windows")] { - return windows::run_raw_input(); + windows::run_raw_input() } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/keyboard/daemon/windows.rs b/src-tauri/src/keyboard/daemon/windows.rs index aefe1df3..473fe0ed 100644 --- a/src-tauri/src/keyboard/daemon/windows.rs +++ b/src-tauri/src/keyboard/daemon/windows.rs @@ -365,8 +365,7 @@ pub(super) fn run_raw_input() -> Result<()> { // 가짜 키는 스캔 코드 기반 복구 우선 let mut vk_norm = vkey; if vk_norm == 0 || vk_norm == 0xFF { - let mapped = - MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX); + let mapped = MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX); if mapped != 0 { vk_norm = mapped; } else { @@ -389,8 +388,7 @@ pub(super) fn run_raw_input() -> Result<()> { const VK_RMENU: u32 = 0xA5; if vk_norm == VK_SHIFT { - let mapped = - MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX); + let mapped = MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX); match mapped { VK_LSHIFT | VK_RSHIFT => vk_norm = mapped, _ => match scan_code { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5f4f7266..06319edd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -79,7 +79,7 @@ fn get_windows_text_scale_factor() -> f64 { } let factor = data as f64 / 100.0; - if factor.is_finite() && factor >= 1.0 && factor <= 2.25 { + if factor.is_finite() && (1.0..=2.25).contains(&factor) { factor } else { 1.0 diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 025a1a14..7015f2d8 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -44,19 +44,12 @@ pub struct CustomFont { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct FontSettings { #[serde(default)] pub custom_fonts: Vec, } -impl Default for FontSettings { - fn default() -> Self { - Self { - custom_fonts: Vec::new(), - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SoundSource { @@ -312,45 +305,33 @@ pub struct GraphPosition { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] +#[derive(Default)] pub enum KeyCounterPlacement { + #[default] Inside, Outside, } -impl Default for KeyCounterPlacement { - fn default() -> Self { - KeyCounterPlacement::Inside - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] +#[derive(Default)] pub enum KeyCounterAlign { + #[default] Top, Bottom, Left, Right, } -impl Default for KeyCounterAlign { - fn default() -> Self { - KeyCounterAlign::Top - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] +#[derive(Default)] pub enum KeyCounterAlignMode { + #[default] Center, Between, } -impl Default for KeyCounterAlignMode { - fn default() -> Self { - KeyCounterAlignMode::Center - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KeyCounterColor { @@ -796,19 +777,15 @@ pub enum FadePosition { /// 이미지 맞춤 설정 (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 ImageFit { - fn default() -> Self { - ImageFit::Cover - } -} - impl Default for NoteSettings { fn default() -> Self { Self { @@ -929,20 +906,12 @@ impl TabNoteSettings { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct CustomCss { pub path: Option, pub content: String, } -impl Default for CustomCss { - fn default() -> Self { - Self { - path: None, - content: String::new(), - } - } -} - /// 탭별 CSS 설정 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -974,6 +943,7 @@ 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, @@ -1003,26 +973,6 @@ pub struct TabNoteSettings { pub key_display_delay_ms: Option, } -impl Default for TabNoteSettings { - fn default() -> Self { - Self { - frame_limit: None, - speed: None, - track_height: None, - reverse: None, - fade_position: None, - fade_top_px: None, - fade_bottom_px: None, - reverse_fade_top_px: None, - reverse_fade_bottom_px: None, - delayed_note_enabled: None, - short_note_threshold_ms: None, - short_note_min_length_px: None, - key_display_delay_ms: None, - } - } -} - /// 탭별 노트 트랙 설정 오버라이드 맵 (키: 탭 ID, 값: TabNoteSettings) pub type TabNoteOverrides = HashMap; @@ -1038,6 +988,7 @@ pub struct JsPlugin { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[derive(Default)] pub struct CustomJs { #[serde(default)] pub path: Option, @@ -1047,16 +998,6 @@ pub struct CustomJs { pub plugins: Vec, } -impl Default for CustomJs { - fn default() -> Self { - Self { - path: None, - content: String::new(), - plugins: Vec::new(), - } - } -} - impl CustomJs { pub fn normalize(&mut self) -> bool { let mut mutated = false; @@ -1105,7 +1046,9 @@ impl CustomJs { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] +#[derive(Default)] pub enum OverlayResizeAnchor { + #[default] TopLeft, TopRight, BottomLeft, @@ -1114,12 +1057,6 @@ pub enum OverlayResizeAnchor { FixedPosition, } -impl Default for OverlayResizeAnchor { - fn default() -> Self { - OverlayResizeAnchor::TopLeft - } -} - /// 그리드 스마트 가이드 설정 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -1658,6 +1595,7 @@ impl Default for SettingsState { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] +#[derive(Default)] pub struct NoteSettingsPatch { pub frame_limit: Option, pub speed: Option, @@ -1674,26 +1612,6 @@ pub struct NoteSettingsPatch { pub key_display_delay_ms: Option, } -impl Default for NoteSettingsPatch { - fn default() -> Self { - Self { - frame_limit: None, - speed: None, - track_height: None, - reverse: None, - fade_position: None, - fade_top_px: None, - fade_bottom_px: None, - reverse_fade_top_px: None, - reverse_fade_bottom_px: None, - delayed_note_enabled: None, - short_note_threshold_ms: None, - short_note_min_length_px: None, - key_display_delay_ms: None, - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct SettingsPatchInput { diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 648674d0..c7e49b4d 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -264,9 +264,7 @@ impl ObsBridgeService { match TcpListener::bind(addr).await { Ok(l) => { if offset > 0 { - log::info!( - "[ObsBridge] 포트 {port} 사용 불가, {try_port}로 fallback" - ); + log::info!("[ObsBridge] 포트 {port} 사용 불가, {try_port}로 fallback"); } listener = Some(l); break; diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 866c254a..b4674f8a 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -535,6 +535,7 @@ impl AppState { Ok(updated.overlay_resize_anchor.as_str().to_string()) } + #[allow(clippy::too_many_arguments)] pub fn resize_overlay( &self, app: &AppHandle, diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index c30d31e2..4634b1ab 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -302,9 +302,11 @@ fn settings_from_store(store: &AppStoreData) -> SettingsState { fn initialize_default_state() -> AppStoreData { use crate::defaults::{default_keys, default_positions}; - let mut data = AppStoreData::default(); - data.keys = default_keys().clone(); - data.key_positions = default_positions().clone(); + let data = AppStoreData { + keys: default_keys().clone(), + key_positions: default_positions().clone(), + ..Default::default() + }; normalize_state(data) } From 2c11d70cbf93b951a30f386060656523060a6819 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 15:24:56 +0900 Subject: [PATCH 52/56] =?UTF-8?q?fix:=20Tauri=20ACL=20remote=20URL=20regex?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20200MB=20=ED=9E=99=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EB=A6=AC=20=EB=88=84=EC=88=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capability 파일에서 불필요한 remote URL 패턴 제거 및 dev capability를 debug_assertions 빌드에서만 런타임 등록하도록 변경. - main.json, dmnote-dev.json에서 remote URL 섹션 제거 (local:true만 유지) - tauri.conf.json에서 dmnote-dev capability 제거 (dev 빌드 시 런타임 등록) - register_dev_capability()를 cfg!(debug_assertions) 가드로 보호 - 프로파일링 코드 정리 (counting_alloc, mem_info, mem_log 등 제거) - dhat 의존성 및 heap-profile 프로필 제거 결과: Private Bytes 202.6MB → 7.3MB (96% 감소), 바이너리 ~2MB 축소 Co-Authored-By: Claude Opus 4.6 --- docs/memory-investigation-2026-03.md | 106 ++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 - src-tauri/capabilities/dmnote-dev.json | 13 +-- src-tauri/capabilities/main.json | 11 +-- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/main.rs | 6 +- src-tauri/tauri.conf.json | 3 +- 7 files changed, 116 insertions(+), 26 deletions(-) create mode 100644 docs/memory-investigation-2026-03.md diff --git a/docs/memory-investigation-2026-03.md b/docs/memory-investigation-2026-03.md new file mode 100644 index 00000000..5435ea20 --- /dev/null +++ b/docs/memory-investigation-2026-03.md @@ -0,0 +1,106 @@ +# DmNote 호스트 프로세스 메모리 200MB 문제 조사 보고서 + +## 날짜: 2026-03-10 + +## 증상 +- VMMap 기준 dm-note.exe Heap: 200,032K (~200 MB) +- 릴리즈 빌드(10MB exe)에서 발생 +- 실행 직후부터 200MB 고정, 기능 사용 여부와 무관 +- 동일 Tauri 기반 과거 버전에서는 이 정도 메모리를 사용하지 않았음 + +## 조사 과정 + +### 1단계: dhat-rs 힙 프로파일러 시도 (실패) +- `dhat::Profiler`가 내부 mutex를 사용 → WebView2/COM 초기화와 교착 (deadlock) +- 앱이 아예 실행되지 않음 (창 안 뜸) +- **결론**: dhat-rs는 Tauri/WebView2 Windows 환경에서 사용 불가 + +### 2단계: Working Set 스냅샷 측정 +`GetProcessMemoryInfo` API로 각 초기화 단계별 Working Set 측정: + +``` +process start: 9.5 MB +before generate_context: 9.6 MB +after generate_context: 167.3 MB ← +157.7 MB +setup closure entered: 180.0 MB +before AppStore::init: 217.4 MB ← +37.4 MB (register_dev_capability) +setup complete: 220.9 MB +``` + +**핵심 발견**: `generate_context!()` (+158 MB)와 `register_dev_capability()` (+37 MB)가 범인 + +### 3단계: Private Bytes + 모듈 분석 +Working Set vs Private Bytes vs 로드 모듈 수를 동시 측정: + +| 구간 | Private Bytes 변화 | 모듈 변화 | +|------|-------------------|-----------| +| `generate_context!()` | +158 MB | 변화 없음 (29개) | +| `.run(context)` → setup | +1.5 MB | 29→46 (+17 DLL) | +| `register_dev_capability()` | +37.6 MB | 변화 없음 | +| AppStore+AppState+Runtime | +3.4 MB | 46→53 | + +**핵심**: 힙 할당이며, DLL 로딩이 아님 + +### 4단계: Rust 카운팅 할당자로 확정 +`#[global_allocator]`에 atomic 카운터 추가하여 Rust 힙 할당량 직접 측정: + +| 구간 | RustHeap 변화 | 할당 횟수 | +|------|-------------|----------| +| `generate_context!()` | **+140.9 MB** | **4,255,624회** | +| `register_dev_capability()` | **+34.4 MB** | **912,538회** | +| 나머지 전부 | +0.6 MB | ~12K회 | + +## 근본 원인 + +### Tauri v2 ACL 시스템의 URL 패턴 중복 컴파일 + +`generate_context!()` 매크로가 컴파일 타임에 `Resolved` 구조체를 생성하고, 이를 토큰 스트림으로 변환하여 **런타임에 재구성**하는 코드를 생성한다. + +재구성 시 각 `ResolvedCommand`마다: +```rust +ExecutionContext::Remote { url: "http://localhost:3400/**".parse().unwrap() } +``` +이 코드가 실행되며, `RemoteUrlPattern::from_str()` → `urlpattern::UrlPattern::parse()` → **6개 `regex::Regex` 컴파일** (protocol, host, port, pathname, search, hash) + +### 프로젝트 설정이 문제를 3배 증폭 + +1. **`main.json`** (컴파일 타임): remote URL 5개 + dmnote-allow-all (102 cmd) + core:default (88 cmd) + → ~190 cmd × 5 URL × 6 regex = **5,700 regex** +2. **`dmnote-dev.json`** (컴파일 타임): remote URL 5개 + 동일 permissions + → ~191 cmd × 5 URL × 6 regex = **5,730 regex** +3. **`register_dev_capability()`** (런타임): remote URL 5개 + 동일 permissions + → ~191 cmd × 5 URL × 6 regex = **5,730 regex** + +**합계: ~17,160 regex 컴파일**, 같은 5개 URL이 수백 번씩 캐싱 없이 반복 컴파일 + +### 메모리 계산 +- regex::Regex 하나당 ~10-12 KB (NFA/DFA 테이블, IR 등) +- 17,160 × ~10 KB ≈ **168 MB** → 실측 175 MB와 일치 + +## 수정 방법 + +### main.json +- `remote` 섹션 제거 — 프로덕션에서 IPC는 `local: true`로 충분 +- `tauri://localhost`는 로컬 프로토콜이므로 remote 불필요 + +### dmnote-dev.json +- `tauri.conf.json`의 capabilities에서 제거 +- dev 빌드에서만 `register_dev_capability()`를 통해 런타임 등록 + +### register_dev_capability() +- `cfg!(debug_assertions)` 가드로 dev 빌드에서만 실행 +- 릴리즈에서는 remote URL이 전혀 컴파일/파싱되지 않음 + +### 예상 효과 +- generate_context: ~140 MB → ~1 MB (remote URL 0개 → regex 0개) +- register_dev_capability: ~34 MB → 0 MB (릴리즈에서 스킵) +- **총 메모리: ~200 MB → ~25 MB** (WebView2 + DLL + 앱 데이터만) + +## 관련 파일 +- `src-tauri/capabilities/main.json` +- `src-tauri/capabilities/dmnote-dev.json` +- `src-tauri/tauri.conf.json` +- `src-tauri/src/main.rs` (register_dev_capability) +- Tauri 소스: `tauri-utils/src/acl/mod.rs` (RemoteUrlPattern, ExecutionContext ToTokens) +- Tauri 소스: `tauri-utils/src/acl/resolved.rs` (Resolved ToTokens) +- Tauri 소스: `tauri-codegen/src/context.rs` (context_codegen, runtime_authority!) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b9629739..16f7074a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,7 +38,6 @@ symphonia = { version = "0.5", default-features = false, features = ["ogg", "vor tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros"] } tokio-tungstenite = "0.24" futures-util = "0.3" - [target."cfg(windows)".dependencies] windows = { version = "0.61.3", features = [ "Win32_Foundation", diff --git a/src-tauri/capabilities/dmnote-dev.json b/src-tauri/capabilities/dmnote-dev.json index 03b088da..3a08a940 100644 --- a/src-tauri/capabilities/dmnote-dev.json +++ b/src-tauri/capabilities/dmnote-dev.json @@ -1,6 +1,6 @@ { "identifier": "dmnote-dev", - "description": "Dev server capability", + "description": "Dev server capability (remote URLs are added at runtime by register_dev_capability)", "local": true, "windows": ["main", "overlay"], "webviews": ["main", "overlay"], @@ -8,14 +8,5 @@ "dmnote-allow-all", "core:default", "core:window:allow-start-dragging" - ], - "remote": { - "urls": [ - "http://localhost:3400/**", - "http://127.0.0.1:3400/**", - "http://localhost:3000/**", - "http://127.0.0.1:3000/**", - "tauri://localhost/**" - ] - } + ] } diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 52440191..de6842cf 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -11,14 +11,5 @@ "core:window:allow-outer-size", "core:window:allow-set-position", "dmnote-allow-all" - ], - "remote": { - "urls": [ - "http://127.0.0.1:3000/**", - "http://localhost:3000/**", - "http://127.0.0.1:3400/**", - "http://localhost:3400/**", - "tauri://localhost/**" - ] - } + ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index d69d5dd6..a9b68347 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"dmnote-dev":{"identifier":"dmnote-dev","description":"Dev server capability","remote":{"urls":["http://localhost:3400/**","http://127.0.0.1:3400/**","http://localhost:3000/**","http://127.0.0.1:3000/**","tauri://localhost/**"]},"local":true,"windows":["main","overlay"],"webviews":["main","overlay"],"permissions":["dmnote-allow-all","core:default","core:window:allow-start-dragging"]},"main":{"identifier":"main","description":"Main window capability granting DM Note access","remote":{"urls":["http://127.0.0.1:3000/**","http://localhost:3000/**","http://127.0.0.1:3400/**","http://localhost:3400/**","tauri://localhost/**"]},"local":true,"windows":["main","overlay"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-current-monitor","core:window:allow-outer-position","core:window:allow-outer-size","core:window:allow-set-position","dmnote-allow-all"]}} \ No newline at end of file +{"dmnote-dev":{"identifier":"dmnote-dev","description":"Dev server capability (remote URLs are added at runtime by register_dev_capability)","local":true,"windows":["main","overlay"],"webviews":["main","overlay"],"permissions":["dmnote-allow-all","core:default","core:window:allow-start-dragging"]},"main":{"identifier":"main","description":"Main window capability granting DM Note access","local":true,"windows":["main","overlay"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-current-monitor","core:window:allow-outer-position","core:window:allow-outer-size","core:window:allow-set-position","dmnote-allow-all"]}} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a16eb68b..3d21286d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -92,7 +92,11 @@ fn main() { } })) .setup(|app| { - register_dev_capability(app)?; + // dev 빌드에서만 remote URL capability 등록 (릴리즈에서는 local:true만 사용) + if cfg!(debug_assertions) { + register_dev_capability(app)?; + } + #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 697b142c..8e7a1542 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -45,8 +45,7 @@ ] }, "capabilities": [ - "main", - "dmnote-dev" + "main" ] }, "withGlobalTauri": true From a4f4310c88dd0254b52a0387e05fd6b229a5c6e7 Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 17:02:23 +0900 Subject: [PATCH 53/56] =?UTF-8?q?fix:=20OBS=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=ED=9B=84=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=ED=91=9C=EC=8B=9C=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=95=B1=20=ED=94=84=EB=A6=AC=EC=A7=95=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오버레이가 숨겨진 상태로 OBS 모드 진입→해제 시 obs_restore_overlay가 윈도우를 재생성하지 않아, 이후 sync 커맨드에서 WebView2 빌드가 메시지 루프를 블로킹하는 문제 수정. prev=false일 때도 ensure_overlay_window를 호출하여 async 컨텍스트에서 미리 재생성. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/state/app_state.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index b4674f8a..2fc733c4 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -397,11 +397,21 @@ impl AppState { /// OBS 중지 시 오버레이 재생성 + 복원 pub fn obs_restore_overlay(&self, app: &AppHandle) { let prev = self.obs_previous_overlay_visible.write().take(); - if let Some(true) = prev { - // set_overlay_visibility(true) 내부에서 ensure_overlay_window + show + store 갱신 + emit 처리 - if let Err(e) = self.set_overlay_visibility(app, true) { - log::warn!("[ObsBridge] 오버레이 복원 실패: {}", e); + 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 => {} } } From 3b4c82925373fd8a3faeabdbded5c9250e98126f Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 17:14:02 +0900 Subject: [PATCH 54/56] =?UTF-8?q?feat:=20=ED=8A=B8=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=A2=8C=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=8B=9C=20=EB=A9=94=EC=9D=B8=20=EC=9C=88=EB=8F=84=EC=9A=B0=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show_menu_on_left_click(false)로 좌클릭 메뉴 비활성화 후, on_tray_icon_event에서 Left+Up 클릭 시 show_main_window() 호출. 우클릭 컨텍스트 메뉴(Settings/Quit)는 기존 동작 유지. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/state/app_state.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 2fc733c4..330fb4ad 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -18,7 +18,7 @@ use parking_lot::RwLock; use serde_json::json; use tauri::{ menu::{Menu, MenuItem}, - tray::TrayIconBuilder, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Emitter, Manager, Monitor, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent, }; @@ -172,12 +172,26 @@ impl AppState { let menu = Menu::with_items(app, &[&settings_item, &quit_item])?; let overlay_force_close = self.overlay_force_close.clone(); - let mut tray_builder = TrayIconBuilder::with_id(TRAY_ICON_ID).menu(&menu); + let mut tray_builder = TrayIconBuilder::with_id(TRAY_ICON_ID) + .menu(&menu) + .show_menu_on_left_click(false); if let Some(icon) = app.default_window_icon().cloned() { tray_builder = tray_builder.icon(icon); } tray_builder + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + if let Err(err) = show_main_window(tray.app_handle()) { + log::error!("failed to show main window from tray click: {err}"); + } + } + }) .on_menu_event(move |app_handle, event| { if event.id() == TRAY_MENU_SETTINGS_ID { if let Err(err) = show_main_window(app_handle) { From 0f44ea4bd34478c5ee323a706e7d3c3d53b5f22d Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 17:31:56 +0900 Subject: [PATCH 55/56] =?UTF-8?q?refactor:=20OBS=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=84=B9=EC=85=98=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Start/Stop 버튼 → Checkbox 토글로 변경 (다른 설정과 일관) - 버튼 좌측 배치 → 라벨 좌 + 컨트롤 우 패턴 적용 - Copy URL/Regen Token 버튼 항상 렌더링 + disabled 스타일 - 상태 텍스트를 서브행 좌측으로 이동 (CSS 섹션 패턴) - 안내 텍스트 항상 표시 + off 시 dimmed 처리 Co-Authored-By: Claude Opus 4.6 --- src/renderer/components/main/Settings.tsx | 102 ++++++++++------------ 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index ce60dd12..dd08e8cf 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -923,71 +923,65 @@ const Settings = ({
{/* OBS 모드 */}
-
+

{t('settings.obsMode')}

-
- {obsStatus.running && ( - - {t('settings.obsClients', { - count: obsStatus.clientCount, - })} - - )} - - {obsStatus.running - ? t('settings.obsRunning') - : t('settings.obsStopped')} - -
+
+

+ {obsStatus.running + ? obsStatus.clientCount > 0 + ? `${t('settings.obsRunning')} · ${t('settings.obsClients', { count: obsStatus.clientCount })}` + : t('settings.obsRunning') + : t('settings.obsStopped')} +

- {obsStatus.running && ( - <> - - - - )} +
- {obsStatus.running && ( -
-

- {t('settings.obsGuide')} -

-

- {t('settings.obsOverlayHidden')} -

-
- )} +
+

+ {t('settings.obsGuide')} +

+

+ {t('settings.obsOverlayHidden')} +

+
{/* 기타 설정 */}
From 9888cef213cb3a043adb249057cae4daefb44dca Mon Sep 17 00:00:00 2001 From: lee-sihun Date: Tue, 10 Mar 2026 19:55:45 +0900 Subject: [PATCH 56/56] =?UTF-8?q?feat:=20obs=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 149 +++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/plugin/bridge.rs | 7 +- src-tauri/src/models/obs.rs | 3 + src-tauri/src/services/obs_bridge.rs | 4 + .../content/dialogs/ObsTokenRegenModal.tsx | 54 ------- src/renderer/components/main/Settings.tsx | 144 +++++++++-------- src/renderer/locales/en.json | 9 +- src/renderer/locales/ko.json | 11 +- src/renderer/locales/ru.json | 25 ++- src/renderer/locales/zh-Hant.json | 25 ++- src/renderer/locales/zh-cn.json | 25 ++- src/types/obs.ts | 1 + 13 files changed, 278 insertions(+), 180 deletions(-) delete mode 100644 src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b35d3914..aa3a1bca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -894,14 +894,38 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.111", ] [[package]] @@ -918,13 +942,24 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.111", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", "syn 2.0.111", ] @@ -951,6 +986,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.111", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1078,6 +1144,7 @@ dependencies = [ "futures-util", "gif", "gif-dispose", + "local-ip-address", "log", "notify", "notify-debouncer-mini", @@ -1645,6 +1712,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "gif" version = "0.13.3" @@ -2470,6 +2549,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "local-ip-address" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2680,6 +2770,35 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.10.0", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.111", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3471,6 +3590,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -4196,7 +4337,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.111", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 16f7074a..298ef760 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ symphonia = { version = "0.5", default-features = false, features = ["ogg", "vor tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros"] } tokio-tungstenite = "0.24" futures-util = "0.3" +local-ip-address = "0.6.10" [target."cfg(windows)".dependencies] windows = { version = "0.61.3", features = [ "Win32_Foundation", diff --git a/src-tauri/src/commands/plugin/bridge.rs b/src-tauri/src/commands/plugin/bridge.rs index 98fa936f..11026819 100644 --- a/src-tauri/src/commands/plugin/bridge.rs +++ b/src-tauri/src/commands/plugin/bridge.rs @@ -1,7 +1,8 @@ use serde_json::Value; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, State}; use crate::errors::{CmdResult, CommandError}; +use crate::state::AppState; /// 플러그인 간 윈도우 브릿지 메시지 전송 /// 모든 윈도우에 브로드캐스트 @@ -31,6 +32,7 @@ pub fn plugin_bridge_send( #[tauri::command] pub fn plugin_bridge_send_to( app: AppHandle, + state: State<'_, AppState>, target: String, message_type: String, data: Option, @@ -63,6 +65,9 @@ pub fn plugin_bridge_send_to( if let Some(window) = app.get_webview_window(window_label) { window.emit("plugin-bridge:message", payload)?; Ok(()) + } else if window_label == "overlay" && state.is_obs_mode_active() { + // OBS 모드에서 overlay가 destroy된 상태 — 정상 무시 + Ok(()) } else { Err(CommandError::msg(format!( "Window '{}' not found", diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs index b2966e42..12e7bd52 100644 --- a/src-tauri/src/models/obs.rs +++ b/src-tauri/src/models/obs.rs @@ -65,6 +65,9 @@ pub struct ObsStatus { /// 세션 보안 토큰 (서버 시작 시 생성, WS hello에서 검증) #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, + /// 로컬 네트워크 IP (같은 네트워크 내 다른 PC 접속용) + #[serde(skip_serializing_if = "Option::is_none")] + pub local_ip: Option, } /// JSON envelope 생성 헬퍼 diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index c7e49b4d..31cd9af7 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -207,11 +207,15 @@ impl ObsBridgeService { Some(t.clone()) } }; + let local_ip = local_ip_address::local_ip() + .ok() + .map(|ip| ip.to_string()); ObsStatus { running: self.is_running(), port: *self.port.read(), client_count: self.client_count(), token, + local_ip, } } diff --git a/src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx b/src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx deleted file mode 100644 index 56e6c93a..00000000 --- a/src/renderer/components/main/Modal/content/dialogs/ObsTokenRegenModal.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import Modal from '@components/main/Modal/Modal'; - -interface ObsTokenRegenModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - t: (key: string) => string; -} - -export function ObsTokenRegenModal({ - isOpen, - onClose, - onConfirm, - t, -}: ObsTokenRegenModalProps) { - if (!isOpen) return null; - - return ( - -
event.stopPropagation()} - > -
- - {t('settings.obsTokenRegenMessage')} - -
- -
- - {t('settings.obsTokenRegenWarning')} - -
- -
- - -
-
-
- ); -} diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index dd08e8cf..f1bd2e9f 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useLenis } from '@hooks/useLenis'; import { useTranslation } from '@contexts/useTranslation'; import { useSettingsStore } from '@stores/useSettingsStore'; @@ -6,9 +6,9 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import FlaskIcon from '@assets/svgs/flask.svg'; +import ResetIcon from '@assets/svgs/reset.svg'; import { PluginManagerModal } from '@components/main/Modal/content/managers/PluginManagerModal'; import { PluginDataDeleteModal } from '@components/main/Modal/content/dialogs/PluginDataDeleteModal'; -import { ObsTokenRegenModal } from '@components/main/Modal/content/dialogs/ObsTokenRegenModal'; import ShortcutSettingsModal from '@components/main/Modal/content/settings/ShortcutSettingsModal'; import { applyCounterSnapshot } from '@stores/signals/keyCounterSignals'; import { extractPluginId } from '@utils/plugin/pluginUtils'; @@ -134,9 +134,7 @@ const Settings = ({ port: DEFAULT_OBS_PORT, clientCount: 0, }); - const [obsLoading, setObsLoading] = useState(false); - const [isTokenRegenModalOpen, setTokenRegenModalOpen] = - useState(false); + const obsTogglingRef = useRef(false); // Lenis smooth scroll 적용 (전역 설정 사용) const { scrollContainerRef } = useLenis(); @@ -572,34 +570,29 @@ const Settings = ({ }; const handleObsToggle = async (): Promise => { - if (obsLoading) return; - setObsLoading(true); + const next = !obsStatus.running; + setObsStatus((prev) => ({ ...prev, running: next })); + if (obsTogglingRef.current) return; + obsTogglingRef.current = true; try { - if (obsStatus.running) { - const status = await obsApi.stop(); - setObsStatus(status); - await window.api.settings.update({ obsModeEnabled: false }); - } else { - const status = await obsApi.start(); - setObsStatus(status); - // 시작 성공 후에만 영속화 (실패 시 obsModeEnabled=true 잔류 방지) - await window.api.settings.update({ obsModeEnabled: true }); - } + const status = next ? await obsApi.start() : await obsApi.stop(); + setObsStatus(status); + await window.api.settings.update({ obsModeEnabled: next }); } catch (error) { console.error('Failed to toggle OBS mode', error); + setObsStatus((prev) => ({ ...prev, running: !next })); showAlert?.( - obsStatus.running - ? t('settings.obsStopFailed') - : t('settings.obsStartFailed'), + next ? t('settings.obsStartFailed') : t('settings.obsStopFailed'), ); } finally { - setObsLoading(false); + obsTogglingRef.current = false; } }; const handleObsCopyUrl = async (): Promise => { const tokenParam = obsStatus.token ? `?token=${obsStatus.token}` : ''; - const url = `http://localhost:${obsStatus.port}${tokenParam}`; + const host = obsStatus.localIp || 'localhost'; + const url = `http://${host}:${obsStatus.port}${tokenParam}`; try { await navigator.clipboard.writeText(url); showAlert?.(t('settings.obsCopied')); @@ -608,14 +601,20 @@ const Settings = ({ } }; - const handleObsRegenerateToken = async (): Promise => { - setTokenRegenModalOpen(false); - try { - const status = await obsApi.regenerateToken(); - setObsStatus(status); - } catch (error) { - console.error('Failed to regenerate OBS token', error); - } + const handleObsRegenerateToken = (): void => { + showConfirm( + t('settings.obsTokenRegenMessage'), + async () => { + try { + const status = await obsApi.regenerateToken(); + setObsStatus(status); + } catch (error) { + console.error('Failed to regenerate OBS token', error); + } + }, + undefined, + t('settings.obsTokenRegenConfirm'), + ); }; const handleDeveloperModeToggle = async (): Promise => { @@ -894,22 +893,31 @@ const Settings = ({ > {t('settings.pluginManageLabel')}

-
+
{/* OBS 모드 */} -
+
setHoveredKey('obsMode')} + onMouseLeave={() => setHoveredKey(null)} + >

@@ -948,40 +956,42 @@ const Settings = ({ > {obsStatus.running ? obsStatus.clientCount > 0 - ? `${t('settings.obsRunning')} · ${t('settings.obsClients', { count: obsStatus.clientCount })}` + ? `${t('settings.obsRunning')} · ${t( + 'settings.obsClients', + { count: obsStatus.clientCount }, + )}` : t('settings.obsRunning') : t('settings.obsStopped')}

-
-

- {t('settings.obsGuide')} -

-

- {t('settings.obsOverlayHidden')} -

-
{/* 기타 설정 */}
@@ -1090,6 +1100,14 @@ const Settings = ({
+ ) : hoveredKey === 'obsMode' ? ( +
+
+ + {t('settings.obsGuide')} + +
+
) : ( )} @@ -1130,12 +1148,6 @@ const Settings = ({ onSave={handleSaveShortcuts} /> )} - setTokenRegenModalOpen(false)} - onConfirm={handleObsRegenerateToken} - t={t} - />
); }; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index fc59ac11..ebfd6313 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -73,21 +73,18 @@ "counterResetButton": "Reset", "counterReset": "Key counters have been reset.", "counterResetFailed": "Failed to reset key counters.", - "obsMode": "OBS Mode", + "obsMode": "Enable OBS Mode", "obsStart": "Start", "obsStop": "Stop", "obsRunning": "Running", "obsStopped": "Stopped", "obsClients": "{{count}} client(s) connected", "obsCopyUrl": "Copy URL", - "obsCopied": "OBS URL copied to clipboard.", + "obsCopied": "URL copied to clipboard.", "obsStartFailed": "Failed to start OBS server.", "obsStopFailed": "Failed to stop OBS server.", - "obsGuide": "Add a Browser Source in OBS and paste the URL above. Set the width/height to match your overlay size.", - "obsOverlayHidden": "Overlay is hidden while OBS mode is active.", - "obsRegenToken": "Regenerate Token", + "obsGuide": "Displays the overlay via a browser source in OBS.", "obsTokenRegenMessage": "Regenerate the session token?", - "obsTokenRegenWarning": "After regeneration, you must update the URL in your OBS browser sources with the new token. Currently connected clients will need the new token on their next connection.", "obsTokenRegenConfirm": "Regenerate" }, "shortcutSetting": { diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index eecdc05a..ed1e310b 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -73,21 +73,18 @@ "counterResetButton": "초기화", "counterReset": "키 카운터가 초기화되었습니다.", "counterResetFailed": "키 카운터 초기화에 실패했습니다.", - "obsMode": "OBS 모드", + "obsMode": "OBS 모드 활성화", "obsStart": "시작", "obsStop": "중지", "obsRunning": "실행 중", "obsStopped": "중지됨", "obsClients": "클라이언트 {{count}}개 연결됨", "obsCopyUrl": "URL 복사", - "obsCopied": "OBS URL이 클립보드에 복사되었습니다.", + "obsCopied": "URL이 클립보드에 복사되었습니다.", "obsStartFailed": "OBS 서버 시작에 실패했습니다.", "obsStopFailed": "OBS 서버 중지에 실패했습니다.", - "obsGuide": "OBS에서 브라우저 소스를 추가하고 위 URL을 붙여넣으세요. 너비/높이를 오버레이 크기에 맞게 설정하세요.", - "obsOverlayHidden": "OBS 모드 활성화 중에는 오버레이가 숨겨집니다.", - "obsRegenToken": "토큰 재생성", - "obsTokenRegenMessage": "세션 토큰을 재생성하시겠습니까?", - "obsTokenRegenWarning": "토큰이 변경되면 기존 OBS 브라우저 소스의 URL을 새 토큰으로 업데이트해야 합니다. 현재 연결된 클라이언트는 다음 연결 시 새 토큰이 필요합니다.", + "obsGuide": "OBS에서 브라우저 소스로 오버레이를 표시합니다.", + "obsTokenRegenMessage": "세션 토큰을 재생성 하시겠습니까?", "obsTokenRegenConfirm": "재생성" }, "shortcutSetting": { diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 2776079f..5c1db637 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -73,21 +73,18 @@ "counterResetButton": "Сброс", "counterReset": "Счётчики сброшены.", "counterResetFailed": "Ошибка сброса счётчиков.", - "obsMode": "OBS Mode", - "obsStart": "Start", - "obsStop": "Stop", - "obsRunning": "Running", - "obsStopped": "Stopped", - "obsClients": "{{count}} client(s) connected", - "obsCopyUrl": "Copy URL", - "obsCopied": "OBS URL copied to clipboard.", - "obsStartFailed": "Failed to start OBS server.", - "obsStopFailed": "Failed to stop OBS server.", - "obsGuide": "Добавьте источник «Браузер» в OBS и вставьте URL выше. Установите ширину/высоту в соответствии с размером оверлея.", - "obsOverlayHidden": "Оверлей скрыт, пока активен режим OBS.", - "obsRegenToken": "Сгенерировать токен", + "obsMode": "Включить режим OBS", + "obsStart": "Запуск", + "obsStop": "Остановка", + "obsRunning": "Работает", + "obsStopped": "Остановлен", + "obsClients": "Подключено клиентов: {{count}}", + "obsCopyUrl": "Копировать URL", + "obsCopied": "URL скопирован в буфер обмена.", + "obsStartFailed": "Не удалось запустить сервер OBS.", + "obsStopFailed": "Не удалось остановить сервер OBS.", + "obsGuide": "Отображает оверлей через источник «Браузер» в OBS.", "obsTokenRegenMessage": "Сгенерировать новый токен сессии?", - "obsTokenRegenWarning": "После смены токена необходимо обновить URL в источниках браузера OBS. Текущие подключённые клиенты потребуют новый токен при следующем подключении.", "obsTokenRegenConfirm": "Сгенерировать" }, "shortcutSetting": { diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index e7179c9d..56257894 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -73,21 +73,18 @@ "counterResetButton": "重置", "counterReset": "按鍵計數器已重置.", "counterResetFailed": "重置按鍵計數器失敗.", - "obsMode": "OBS Mode", - "obsStart": "Start", - "obsStop": "Stop", - "obsRunning": "Running", - "obsStopped": "Stopped", - "obsClients": "{{count}} client(s) connected", - "obsCopyUrl": "Copy URL", - "obsCopied": "OBS URL copied to clipboard.", - "obsStartFailed": "Failed to start OBS server.", - "obsStopFailed": "Failed to stop OBS server.", - "obsGuide": "在 OBS 中新增瀏覽器來源並貼上上方 URL。將寬度/高度設定為與覆蓋層大小一致。", - "obsOverlayHidden": "OBS 模式啟用期間覆蓋層已隱藏。", - "obsRegenToken": "重新產生權杖", + "obsMode": "啟用 OBS 模式", + "obsStart": "啟動", + "obsStop": "停止", + "obsRunning": "執行中", + "obsStopped": "已停止", + "obsClients": "已連線 {{count}} 個用戶端", + "obsCopyUrl": "複製 URL", + "obsCopied": "URL 已複製到剪貼簿。", + "obsStartFailed": "OBS 伺服器啟動失敗。", + "obsStopFailed": "OBS 伺服器停止失敗。", + "obsGuide": "透過 OBS 瀏覽器來源顯示覆蓋層。", "obsTokenRegenMessage": "是否重新產生工作階段權杖?", - "obsTokenRegenWarning": "權杖變更後,您需要使用新權杖更新 OBS 瀏覽器來源中的 URL。目前連線的用戶端在下次連線時需要新權杖。", "obsTokenRegenConfirm": "重新產生" }, "shortcutSetting": { diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 8b66ad24..3986485d 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -73,21 +73,18 @@ "counterResetButton": "重置", "counterReset": "按键计数器已重置.", "counterResetFailed": "重置按键计数器失败.", - "obsMode": "OBS Mode", - "obsStart": "Start", - "obsStop": "Stop", - "obsRunning": "Running", - "obsStopped": "Stopped", - "obsClients": "{{count}} client(s) connected", - "obsCopyUrl": "Copy URL", - "obsCopied": "OBS URL copied to clipboard.", - "obsStartFailed": "Failed to start OBS server.", - "obsStopFailed": "Failed to stop OBS server.", - "obsGuide": "在 OBS 中添加浏览器源并粘贴上方 URL。将宽度/高度设置为与叠加层大小一致。", - "obsOverlayHidden": "OBS 模式启用期间叠加层已隐藏。", - "obsRegenToken": "重新生成令牌", + "obsMode": "启用 OBS 模式", + "obsStart": "启动", + "obsStop": "停止", + "obsRunning": "运行中", + "obsStopped": "已停止", + "obsClients": "已连接 {{count}} 个客户端", + "obsCopyUrl": "复制 URL", + "obsCopied": "URL 已复制到剪贴板。", + "obsStartFailed": "OBS 服务器启动失败。", + "obsStopFailed": "OBS 服务器停止失败。", + "obsGuide": "通过 OBS 浏览器源显示叠加层。", "obsTokenRegenMessage": "是否重新生成会话令牌?", - "obsTokenRegenWarning": "令牌更改后,您需要使用新令牌更新 OBS 浏览器源中的 URL。当前连接的客户端在下次连接时需要新令牌。", "obsTokenRegenConfirm": "重新生成" }, "shortcutSetting": { diff --git a/src/types/obs.ts b/src/types/obs.ts index 9793a030..1781f7e1 100644 --- a/src/types/obs.ts +++ b/src/types/obs.ts @@ -34,4 +34,5 @@ export interface ObsStatus { port: number; clientCount: number; token?: string; + localIp?: string; }