diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index cc7a2be9..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. 계획 수립 (필수)
-사용자의 구현 요청이나 설계 논의가 있으면 `/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/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 61f9930e..b5ef17a0 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으로 보완
- 증상 및 기대 동작
- 재현 조건
- 의심 범위 (관련 파일/함수)
@@ -22,7 +25,7 @@ Claude가 증상을 분석하고 원인 가설을 수립한 뒤, Codex가 실제
재현 정보가 부족하면 사용자에게 최소한의 추가 정보만 요청합니다.
### 2. Codex 1차 조사
-Claude의 선분석을 Codex에게 전달하여 가설 검증을 요청합니다 (백그라운드).
+증상, 관련 파일, 재현 조건(사실 정보)을 Codex에게 전달하여 독립적으로 원인을 추적하게 합니다 (백그라운드).
- 관련 파일 읽기, 에러 문자열/함수명 검색
- 재현 절차 수립 및 실행
- 가설별 지지/반증 근거 정리
@@ -44,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
```
@@ -105,10 +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`로 실행합니다. `timeout: 600000`은 Bash 도구의 최대 대기 시간이며, 백그라운드 실행이므로 Codex 작업 자체는 완료까지 계속됩니다.
-- 완료 알림을 받으면 TaskOutput으로 결과를 수집합니다.
+- Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다.
+- 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다.
+- **중요: 원인 확정/수정 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.**
+ - `status: running` → Codex가 조사 중이므로 **완료될 때까지 대기**. TaskOutput 타임아웃은 Codex 실패가 아님 — `status: running`이면 `TaskOutput(block: true)`로 재시도하여 끝까지 기다림. 대기 중에는 선분석 정리만 수행.
+ - `status: completed` → 결과를 수집하여 반영.
+ - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 디버깅). 대기하지 않음.
+ - **fallback 조건은 오직 `status: failed`/에러뿐**. 시간이 오래 걸리는 것은 fallback 사유가 아님.
### 진행 상황 확인
백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다.
@@ -140,6 +155,12 @@ Codex 비중을 높이는 경우:
→ `resume`으로 1회 추가 조사
- 2회 이상 불충분하면 Claude 단독 디버깅으로 전환합니다.
+## 피드백 필터링
+
+Codex 피드백을 반영할 때 Claude(Opus)가 자체 판단으로 필터링합니다:
+- **방어적 코딩 수준의 제안** (에러 핸들링 강화, 옵셔널 체크 추가 등)이 실질적 버그가 아니라 오버엔지니어링에 해당하면 **반영하지 않고 생략**합니다.
+- 프로젝트 컨벤션(예: `let _ = store.update(...)` 패턴)에 부합하는 코드에 대한 지적은 무시합니다.
+
## 출력 형식
### 문제 요약
diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/codex-plan/SKILL.md
similarity index 55%
rename from .claude/skills/plan/SKILL.md
rename to .claude/skills/codex-plan/SKILL.md
index 5fb76057..d00abcb5 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,9 +14,13 @@ 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에게 전달하여 검증/보완을 요청합니다.
+3. 사실 정보(파일 목록, 코드 구조, 영향 범위)를 Codex에게 전달하여 독립 검증을 요청합니다.
4. Codex 피드백을 반영하여 최종 계획을 확정합니다.
### 병렬 모드 (C: 독립 분석)
@@ -26,29 +30,40 @@ Claude가 코드를 분석하고 초안 계획을 작성한 뒤, Codex(GPT 5.4)
- 실패 비용이 큰 리팩토링/마이그레이션일 때
1. Claude 분석과 Codex 분석을 동시에 진행합니다.
- - Claude: Read, Grep, Glob으로 코드 분석
+ - Claude: 그래프 도구(search_graph, trace_call_path 등) 우선, 필요시 Read/Grep 보완
- Codex: `codex exec`로 독립적 분석 (백그라운드)
2. 두 분석 결과를 종합하여 최종 계획을 작성합니다.
## 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
```
@@ -72,10 +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`로 실행합니다. `timeout: 600000`은 Bash 도구의 최대 대기 시간이며, 백그라운드 실행이므로 Codex 작업 자체는 완료까지 계속됩니다.
-- 완료 알림을 받으면 TaskOutput으로 결과를 수집합니다.
+- Bash의 `run_in_background: true`로 실행합니다. 백그라운드이므로 즉시 반환되며, Codex 작업은 완료까지 제한 없이 계속됩니다.
+- 완료 시 시스템이 자동 알림 → TaskOutput으로 결과를 수집합니다.
+- **중요: 결론을 내리기 전에 반드시 `TaskOutput(block: false)`로 Codex 상태를 확인합니다.**
+ - `status: running` → Codex가 작업 중이므로 **완료될 때까지 대기**. TaskOutput 타임아웃은 Codex 실패가 아님 — `status: running`이면 `TaskOutput(block: true)`로 재시도하여 끝까지 기다림. 대기 중에는 Claude 선분석 등 병렬 가능한 작업만 수행.
+ - `status: completed` → 결과를 수집하여 반영.
+ - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 진행). 대기하지 않음.
+ - **fallback 조건은 오직 `status: failed`/에러뿐**. 시간이 오래 걸리는 것은 fallback 사유가 아님.
### 진행 상황 확인
백그라운드 실행 중 TaskOutput으로 중간 출력을 확인합니다.
@@ -98,6 +119,12 @@ codex exec resume --last "추가 질문"
- Codex 응답이 불충분하면 resume으로 한 번 더 질문하되, 2회 이상 실패 시 포기합니다.
- fallback 발생 시 반드시 사용자에게 원인을 명시합니다.
+## 피드백 필터링
+
+Codex 피드백을 반영할 때 Claude(Opus)가 자체 판단으로 필터링합니다:
+- **방어적 코딩 수준의 제안** (에러 핸들링 강화, 옵셔널 체크 추가 등)이 실질적 버그가 아니라 오버엔지니어링에 해당하면 **반영하지 않고 생략**합니다.
+- 프로젝트 컨벤션(예: `let _ = store.update(...)` 패턴)에 부합하는 코드에 대한 지적은 무시합니다.
+
## 출력 형식
### Claude 분석
diff --git a/.claude/skills/codex-review/SKILL.md b/.claude/skills/codex-review/SKILL.md
index 7d746515..9e5822e3 100644
--- a/.claude/skills/codex-review/SKILL.md
+++ b/.claude/skills/codex-review/SKILL.md
@@ -13,8 +13,11 @@ Codex는 `danger-full-access` 권한으로 직접 파일 읽기, 쉘 명령 실
## 절차
1. Claude가 `git diff --stat`으로 변경 범위를 파악합니다.
-2. 핵심 변경 파일을 Read로 확인하고 **diff 요약, 의도 추정, 위험 포인트**를 정리합니다.
-3. Claude의 선분석 결과를 Codex에게 전달하여 검증/심층 리뷰를 요청합니다 (백그라운드).
+2. **codebase-memory-mcp 그래프 도구를 우선** 사용하여 변경의 영향 범위를 분석합니다.
+ - `detect_changes`로 변경된 심볼과 blast radius 확인
+ - `trace_call_path`로 변경 함수의 호출자/피호출자 추적
+ - 핵심 변경 파일은 Read로 확인하고 **diff 요약, 의도 추정, 위험 포인트**를 정리합니다.
+3. 변경 파일 목록과 영향 범위(사실 정보)를 Codex에게 전달하여 독립 리뷰를 요청합니다 (백그라운드).
4. 진행 상황을 주기적으로 확인하여 사용자에게 보고합니다.
5. 필요시 `codex exec resume --last`로 심화 리뷰합니다.
6. Codex 피드백을 종합하여 최종 리뷰 결과를 보고합니다.
@@ -22,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' 필수)
- 불필요한 리렌더링 패턴
@@ -42,6 +46,16 @@ Claude 선분석:
리뷰 초점: (사용자 인자 또는 전반적 리뷰)
+출력 형식 (반드시 준수):
+## 리뷰 요약
+(전체 코드 품질 한 줄 평가)
+## 이슈
+- [Critical] 파일:라인 - 설명
+- [Warning] 파일:라인 - 설명
+- [Suggestion] 파일:라인 - 설명
+## 개선 제안
+(구체적 수정 코드 - before/after)
+
한글로 간결하게 답변해주세요." 2>/dev/null
```
@@ -68,10 +82,16 @@ codex exec resume --last "해당 이슈에 대한 구체적인 수정 코드를
```
### 호출 규칙
+- Claude의 판단/결론은 Codex에 전달하지 않음 — 사실 정보만 전달 (anchoring bias 방지).
- `-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(block: false)`로 Codex 상태를 확인합니다.**
+ - `status: running` → Codex가 작업 중이므로 **완료될 때까지 대기**. TaskOutput 타임아웃은 Codex 실패가 아님 — `status: running`이면 `TaskOutput(block: true)`로 재시도하여 끝까지 기다림. 단독으로 리뷰를 완료하지 않음.
+ - `status: completed` → 결과를 수집하여 반영.
+ - `status: failed` 또는 에러 → 즉시 fallback (Claude 단독 리뷰). 대기하지 않음.
+ - **fallback 조건은 오직 `status: failed`/에러뿐**. 시간이 오래 걸리는 것은 fallback 사유가 아님.
## 실패 처리
@@ -80,6 +100,12 @@ codex exec resume --last "해당 이슈에 대한 구체적인 수정 코드를
→ Claude가 직접 diff를 읽어 리뷰합니다.
- fallback 발생 시 반드시 원인을 사용자에게 명시합니다.
+## 피드백 필터링
+
+Codex 피드백을 보고할 때 Claude(Opus)가 자체 판단으로 필터링합니다:
+- **방어적 코딩 수준의 제안** (에러 핸들링 강화, 옵셔널 체크 추가 등)이 실질적 버그가 아니라 오버엔지니어링에 해당하면 **반영하지 않고 생략**합니다.
+- 프로젝트 컨벤션(예: `let _ = store.update(...)` 패턴)에 부합하는 코드에 대한 지적은 무시합니다.
+
## 출력 형식
### 리뷰 요약
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
diff --git a/AGENTS.md b/AGENTS.md
index a7809763..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 등)를 제외하면 **한글**로 작성
@@ -144,3 +150,4 @@ src-tauri/src/
2. **린트**: `cd src-tauri && cargo clippy`
3. **포맷팅**: `cd src-tauri && cargo fmt`
4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 73006a12..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 등)를 제외하면 **한글**로 작성
@@ -144,3 +150,4 @@ src-tauri/src/
2. **린트**: `cd src-tauri && cargo clippy`
3. **포맷팅**: `cd src-tauri && cargo fmt`
4. **permissions 확인**: 커맨드 추가/삭제 시 빌드 후 `permissions/dmnote-allow-all.json` 자동 갱신 확인
+
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/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/docs/obs-mode-design.md b/docs/obs-mode-design.md
new file mode 100644
index 00000000..ddd82f51
--- /dev/null
+++ b/docs/obs-mode-design.md
@@ -0,0 +1,1019 @@
+# OBS 모드 설계 문서
+
+> 작성일: 2026-03-07
+> 목표: OBS 브라우저 소스로 키뷰어를 표시하여 게임 FPS 영향 완전 제거
+> 상태: **v4 IPC Shim + 프로토콜 통합 완료** (`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 페이지 정적 파일 서빙 ✅
+ │ └─ WS: 키 이벤트 / 설정 / 레이아웃 브로드캐스트
+ └─ [Main window]
+ └─ OBS 모드 설정 UI / 연결 상태 / URL 표시
+```
+
+### 핵심 원칙
+
+1. **AppState가 단일 상태 소스** — 키보드 데몬이 직접 WS로 보내지 않음 ✅
+2. **렌더링 코드 재사용** — useNoteSystem, noteBuffer, WebGLTracksOGL 공유 ✅
+3. **OBS 페이지는 overlay/App.tsx 재사용** — IPC Shim으로 Tauri API 호환 ✅
+
+### 데이터 흐름
+
+```
+입력: Keyboard daemon → AppState → ObsBridgeService → OBS 페이지 ✅
+설정: settings/preset/mode 변경 → AppState emit → ObsBridge 캐시 갱신 ✅ (settings_diff, counter_update, layout snapshot)
+```
+
+---
+
+## 3. WebSocket 프로토콜
+
+### 3.1 공통 Envelope ✅
+
+```json
+{
+ "v": 1,
+ "type": "key_event",
+ "seq": 10241,
+ "ts": 1741339200123,
+ "payload": {}
+}
+```
+
+- `v`: 프로토콜 버전 (하위 호환용)
+- `seq`: 단조 증가 시퀀스 (gap 감지용)
+- `ts`: 서버 타임스탬프 (ms)
+
+### 3.2 메시지 타입
+
+| 방향 | 타입 | 용도 | 빈도 | 상태 |
+|------|------|------|------|------|
+| C→S | `hello` | 최초 접속 핸드셰이크 | 1회 | ✅ |
+| S→C | `hello_ack` | 프로토콜 승인 + deny list | 1회 | ✅ |
+| S→C | `snapshot` | 전체 상태 동기화 | 접속 시 + resync | ✅ |
+| 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` | 상태 재동기화 요청 | 드묾 | ✅ |
+
+### 3.3 핸드셰이크 시퀀스 ✅
+
+```
+1. OBS 페이지 접속 (WS 직접 연결) ← v1: HTTP upgrade 없이 직접 WS
+2. 클라이언트 → hello { client, protocol, appVersion, token }
+3. 서버 → hello_ack { serverVersion, obsMode, denyList }
+4. 서버 → snapshot { 전체 상태 }
+5. 이후 tauri_event (keys:state, settings:changed 등) + invoke_request/invoke_response
+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" }
+ }
+}
+```
+
+#### tauri_event (S→C) ✅
+```json
+{
+ "v": 1,
+ "type": "tauri_event",
+ "seq": 11,
+ "payload": {
+ "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 상태 일관성 ✅
+
+프리셋 로드처럼 여러 Tauri 이벤트가 연속 발생하는 경우:
+- ✅ 서버에서 프리셋 로드 후 `snapshot` 재전송 (refresh_obs_snapshot)
+- ✅ 모든 레이아웃 변경 시 `refresh_obs_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,
+ 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:
+- `start(port: u16)` — WS 서버 bind ✅
+- `stop()` — Shutdown broadcast → 서버 shutdown ✅
+- `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
+tokio-tungstenite = "0.24"
+futures-util = "0.3"
+```
+> tokio는 기존에 이미 포함됨
+
+### 4.4 기존 코드 연동 지점
+
+| 기존 코드 위치 | 호출 | 상태 |
+|----------------|------|------|
+| `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 지점 | ~~`obs_broadcast_counters()`~~ → `register_event_forwarding`이 `keys:counters` 자동 포워딩 (캐시 갱신만 유지) | ✅ (Tier 2 통합) |
+| `commands/keys/keys.rs` 모드 변경 | `refresh_obs_snapshot()` | ✅ |
+| `commands/layout/*` 레이아웃 변경 | `refresh_obs_snapshot()` | ✅ |
+
+---
+
+## 5. 프론트엔드 번들 전략
+
+### 5.1 코드 분리 구조 ✅ (설계 대비 단순화)
+
+```
+src/renderer/
+├── windows/
+│ ├── overlay/App.tsx ✅ 기존 (OBS에서도 동일 코드 재사용)
+│ └── obs/
+│ ├── index.tsx ✅ IPC Shim 초기화 → overlay/App 동적 import
+│ └── index.html ✅ 엔트리
+├── api/
+│ └── ipcShim.ts ✅ 신설 (v4: WS→Tauri IPC 호환 레이어)
+├── components/shared/
+│ └── OverlayScene.tsx ✅ 공용 렌더링 컴포넌트
+├── hooks/overlay/
+│ └── useNoteSystem.ts ✅ 그대로 재사용
+├── stores/signals/
+│ └── noteBuffer.ts ✅ 그대로 재사용
+├── api/modules/
+│ └── obsApi.ts ✅ Tauri 커맨드 래퍼
+└── components/overlay/
+ └── WebGLTracksOGL.tsx ✅ 그대로 재사용
+```
+
+> v4: IPC Shim으로 overlay/App.tsx를 코드 변경 없이 재사용. obs/App.tsx, useObsWebSocket.ts, useOverlayRuntime.ts **삭제됨**.
+
+### 5.2 OverlayScene 추출 ✅
+
+| 책임 | OverlayScene (공용) | overlay/App.tsx (Tauri) | obs/App.tsx (OBS) |
+|------|:---:|:---:|:---:|
+| 키 UI 렌더링 | ✅ | | |
+| 노트 효과 (WebGL) | ✅ | | |
+| bounds/position 계산 | | ✅ | ✅ (공유: computeLayout) |
+| 통계/그래프 표시 | ✅ | | |
+| 플러그인 엘리먼트 | ✅ (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 페이지 서빙 ✅
+
+같은 포트에서 HTTP(정적 파일) + WS(실시간 통신) 통합.
+TCP 스트림을 peek하여 `Upgrade: websocket` 헤더 유무로 분기.
+
+---
+
+## 6. 설정 동기화
+
+### 6.1 동기화 대상 계층
+
+| 계층 | 데이터 | 변경 빈도 | v1 상태 |
+|------|--------|-----------|---------|
+| 글로벌 설정 | 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 동기화 전략
+
+- **최초 접속**: `snapshot` (전체 상태) ✅
+- **이후 변경**: `settings_diff` ✅ / layout 변경 시 `snapshot` 재전송 ✅
+- **대규모 변경** (프리셋 로드): `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 비교로 불필요한 리렌더 방지)
+
+v3 추가:
+- ✅ OBS 모드 시 오버레이 창 자동 숨김/복원
+- ✅ 안내 문구 (OBS 설정 방법 가이드 + 오버레이 숨김 경고)
+- 라디오 버튼 모드 전환은 자동 숨김/복원으로 대체
+
+### 7.2 모드 전환 동작 ✅
+
+1. ✅ OBS 모드 ON → WS 서버 bind → URL 표시
+2. ✅ OBS 클라이언트 접속 → 상태 점등
+3. ✅ 연결 끊김 → 서버 유지, 상태 표시 갱신
+4. ✅ 오버레이 창 자동 숨김/복구 (v3: obs_hide_overlay / obs_restore_overlay)
+5. ✅ 설정 영속화 (v3: obs_port, obs_mode_enabled store 저장, 재시작 시 복원)
+
+### 7.3 포트 충돌 처리 ✅
+
+- 기본값: `34891`
+- 충돌 시: 명시적 실패 + 에러 표시 (자동 fallback 없음)
+
+---
+
+## 8. 리스크 및 제약사항
+
+### 8.1 기술적 리스크
+
+| 리스크 | 심각도 | 대응 | v1 상태 |
+|--------|--------|------|---------|
+| OBS CEF Chromium 버전 차이 | 중 | WebGL 1.0 기준 유지 | ⚠️ 미검증 |
+| 키 이벤트 지연 (WS 전송) | 낮 | localhost <1ms, seq+ts로 모니터링 | ✅ |
+| 상태 일관성 (프리셋 로드 시) | 중 | snapshot 재전송으로 대응 | ✅ |
+| 포트 보안 | 중 | UUID v4 세션 토큰 + WS hello/HTTP 검증 | ✅ (v3) |
+| tokio 런타임 추가 | 낮 | 기존 tokio 재사용 | ✅ |
+
+### 8.2 기능 제약 (v2 기준)
+
+| 기능 | 지원 여부 |
+|------|-----------|
+| 키 UI + 노트 효과 | ✅ 지원 |
+| 통계/그래프 표시 | ✅ 지원 (v3: KPS 로컬 1초 슬라이딩 윈도우 계산) |
+| 키 카운터 | ✅ 지원 |
+| HTTP 정적 서빙 | ✅ 지원 (Tauri 임베딩 에셋, 포터블 exe 호환) |
+| 레이아웃 동기화 | ✅ 지원 (snapshot 재전송) |
+| 커스텀 CSS | ✅ 지원 (v3: settings_diff 경유 실시간 주입) |
+| 배경 미디어 서빙 | ✅ 지원 (v3: /media/ 엔드포인트 + 토큰 검증) |
+| 커스텀 JS (플러그인) | ✅ 지원 (v4: IPC Shim으로 invoke/listen 호환) |
+| 플러그인 엘리먼트 | ✅ 지원 (v4: bridge API → WS RPC 자동 라우팅) |
+
+### 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 snapshot | ✅ |
+| 5 | 메인 UI OBS 설정 | 시작/중지, 포트, 연결 상태, URL 복사 | ✅ |
+| 6 | HTTP 정적 파일 서빙 | 같은 포트에서 OBS 페이지 제공 | ✅ |
+| 7 | 플러그인/커스텀 지원 여부 | bridge 없는 환경 대응 검토 | ❌ v3+ |
+
+---
+
+## 10. 요약
+
+```
+핵심 가치:
+게임 FPS 영향 = 0 (오버레이 창 자체가 없으므로)
+
+구현 키포인트:
+1. ✅ AppState를 단일 상태 허브로 유지
+2. ✅ ObsBridgeService로 WS 브로드캐스트
+3. ✅ useNoteSystem + WebGLTracksOGL 코드 재사용
+4. ✅ OverlayScene 추출로 Tauri/OBS 공용화
+5. ✅ 같은 포트에서 HTTP + WS 서빙
+```
+
+---
+
+## 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 v3 구현 범위
+
+v3는 **OBS 모드의 완성도를 높이고 실사용 편의성을 개선**하는 것이 목표.
+
+#### v3 포함 (P1)
+
+| # | 작업 | 설명 | 상태 |
+|---|------|------|------|
+| 1 | **설정 영속화** | OBS 포트/활성 상태를 useSettingsStore + 백엔드 settings에 저장, 재시작 시 복원 | ✅ |
+| 2 | **오버레이 연동** | OBS 모드 시작 시 오버레이 창 자동 숨김, 중지 시 복원 | ✅ |
+| 3 | **Stats (KPS) 동기화** | KPS 값을 OBS 클라이언트 로컬에서 계산 (1초 슬라이딩 윈도우) | ✅ |
+| 4 | **UI 안내 문구** | 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 | **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 | **플러그인 엘리먼트** | 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 변경 요약
+
+| 영역 | v1 | v2 |
+|------|-----|-----|
+| OBS 접속 방식 | WS URL 직접 연결 (별도 웹 서버 필요) | `http://localhost:PORT` 한 줄 입력 |
+| layout_diff | 서버 API만 구현 | 모드/키/위치/탭 변경 시 자동 broadcast |
+| cached_snapshot | 초기 + 프리셋 로드 시만 갱신 | 모든 diff 시 증분 갱신 |
+| 메인 UI URL 복사 | `ws://localhost:PORT` | `http://localhost:PORT` |
+
+---
+
+## 12. Tauri IPC Shim 호환성 레이어 설계
+
+> 상태: **Tier 1~3 구현 완료**
+> P2 #11 상세 설계
+
+### 12.1 배경 및 목표
+
+현재 overlay/App.tsx와 obs/App.tsx는 동일한 UI를 렌더링하면서 데이터 수신 방식만 다름:
+- overlay: Tauri IPC (`invoke`, `listen`) → `window.api.*` → Zustand 스토어 → OverlayScene
+- obs: WebSocket → `useOverlayRuntime` (중복 로직) → OverlayScene
+
+이 구조의 문제:
+1. **렌더링 로직 중복** — 키 딜레이, KPS 계산, 레이아웃 등이 useOverlayRuntime에 복제됨
+2. **유지보수 부담** — 기능 추가 시 overlay/obs 양쪽 모두 수정 필요
+3. **플러그인 미지원** — OBS에서 커스텀 JS/플러그인이 동작하지 않음
+
+### 12.2 검토한 접근 방식
+
+| 방식 | 설명 | overlay 변경 | 중복 제거 | 비고 |
+|------|------|:---:|:---:|------|
+| A. 분리형 유지 | 현재 구조 유지, computeLayout만 공유 | 없음 | 일부 | 변경 적을 때만 적합 |
+| B. OverlayRuntime 통합 | TauriHost/WebSocketHost → useOverlayRuntime | 대규모 | 완전 | 기존 훅 해체 필요 |
+| **C. Tauri IPC Shim** | `invoke`/`listen` 프리미티브를 WS로 교체 | **없음** | **완전** | **채택** |
+
+### 12.3 최종 결정: C 방식 (Tauri IPC Shim)
+
+Tauri 프론트엔드 API는 모두 두 가지 프리미티브에 의존:
+- `invoke(command, args)` → 요청/응답 (81개 커맨드)
+- `listen(event, callback)` → 이벤트 구독 (25개 이벤트)
+
+이 두 함수는 내부적으로 `window.__TAURI_INTERNALS__`를 호출함.
+**OBS 진입점에서 이 글로벌을 WS 기반 shim으로 교체**하면,
+overlay/App.tsx 및 모든 의존 훅(useAppBootstrap, keyEventBus 등)이 코드 변경 없이 동작.
+
+```
+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를 사용하므로 자동 호환
+
+#### 재사용성 원칙
+
+이 호환성 레이어는 **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
+
+// deny 리스트는 하드코딩 아님 — WS handshake(hello_ack)에서 수신
+let denyList: string[] = [];
+
+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);
+}
+
+// "|"로 끝나면 prefix 매칭, 아니면 exact 매칭
+function isDenied(cmd: string): boolean {
+ return denyList.some(entry =>
+ entry.endsWith('|') ? cmd.startsWith(entry) : cmd === entry
+ );
+}
+```
+
+**커맨드 추가 시 shim 수정 불필요** — 백엔드에 커맨드가 있으면 자동으로 동작.
+
+#### 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 (요청/응답) — 백엔드 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 이벤트 | 비고 |
+|---------------|---------------|------|
+| `tauri_event` | 이벤트명 그대로 | 범용 이벤트 포워딩 — 22개 이벤트 자동 디스패치 |
+| `snapshot` | `keys:changed`, `positions:changed`, `settings:changed` 등 | 다수 이벤트 일괄 디스패치 |
+| `invoke_response` | — | WS RPC 응답 (pendingRpc resolve/reject) |
+
+> v4 Tier 2에서 기존 전용 메시지(`key_event`, `settings_diff`, `counter_update`)를 `tauri_event`로 완전 통합.
+
+#### stats 구독
+
+`keyStatsService`가 `listen('keys:state')` + `invoke('app_bootstrap')`을 사용.
+shim이 설치되면 자동으로 WS 경유 동작 — 별도 처리 불필요.
+
+### 12.6 창 관리 API No-op 스텁
+
+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 처리 — 별도 모듈 모킹 불필요.
+
+`convertFileSrc()`는 `__TAURI_INTERNALS__.convertFileSrc`에 설치되므로 shim에서 직접 제공.
+OBS HTTP 서버의 `/media/?token=...` 경로로 변환:
+
+```typescript
+function shimConvertFileSrc(filePath: string): string {
+ const encoded = btoa(filePath);
+ return `http://${host}:${port}/media/${encoded}?token=${sessionToken}`;
+}
+```
+
+### 12.7 obs/index.tsx 진입점
+
+```tsx
+// src/renderer/windows/obs/index.tsx
+import { initIpcShim, disposeIpcShim } from '@api/ipcShim';
+
+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}`;
+
+ await initIpcShim(wsUrl, token);
+ await import('@api/dmnoteApi');
+ window.__dmn_window_type = 'overlay';
+
+ const { I18nProvider } = await import('@contexts/I18nContext');
+ const { default: App } = await import('@windows/overlay/App');
+ // render
+}
+```
+
+### 12.8 구현 단계
+
+| 단계 | 작업 | 영역 |
+|------|------|------|
+| 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 WS 프로토콜 확장
+
+#### 신규 메시지 타입
+
+| 방향 | 타입 | 용도 |
+|------|------|------|
+| 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" } }
+```
+
+#### 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 검증 |
+| 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`)를 `tauri_event`로 통합 완료.
+
+1. ~~**1단계**: `tauri_event` 포워딩 추가~~ ✅ Tier 1에서 완료
+2. ~~**2단계**: shim의 `onWsMessage`에서 기존 메시지 타입 처리 유지 + `tauri_event` 처리 추가~~ ✅ Tier 1에서 완료
+3. ~~**3단계**: 기존 전용 메시지를 `tauri_event`로 통합, `onWsMessage` 매핑 로직 제거~~ ✅ Tier 2에서 완료
+
+변경 내역:
+- 백엔드: `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`로 자동 포워딩
+
+### 12.13 프론트엔드 shim 최종 구조 ✅
+
+Tier 2 통합 완료 후 ipcShim.ts의 WS 메시지 핸들러는 3가지만 처리:
+
+```typescript
+// ── WS 메시지 수신 (최종) ──
+function onWsMessage(envelope) {
+ switch (envelope.type) {
+ case 'tauri_event': dispatchEvent(envelope.payload.event, envelope.payload.data); break;
+ case 'invoke_response': /* pending RPC resolve/reject */ break;
+ case 'snapshot': /* 다수 이벤트 일괄 디스패치 */ break;
+ }
+}
+```
+
+기존 `key_event`, `settings_diff`, `counter_update` 전용 핸들러 제거 완료.
+모든 이벤트는 백엔드 `register_event_forwarding()`이 `tauri_event`로 통합 포워딩.
+
+---
+
+## 13. 작업 진행 현황 (2026-03-08 기준)
+
+> v4 Tier 1~3 구현 완료.
+
+### Tier 1 — IPC Shim + 백엔드 호환성 레이어 (§12) ✅ 완료
+
+| # | 작업 | 영역 | 상태 |
+|---|------|------|------|
+| 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/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)
+
+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 시 해제)
+
+### Tier 2 — 프로토콜 통합 ✅ 완료
+
+| # | 작업 | 설명 | 상태 |
+|---|------|------|------|
+| 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 — 알려진 이슈 ✅ 해결 / 확인 완료
+
+| # | 이슈 | 증상 | 상태 |
+|---|------|------|------|
+| 10 | **초기 접속 시 빈 화면** | `invoke_request` 필드명 불일치 (`reqId`/`cmd` vs `requestId`/`command`) | ✅ 수정 완료 |
+| 11 | **`input:raw` 이중 전달** | main+overlay 양쪽 `window.emit()` → 리스너 2회 트리거 | ⚠️ 유지 (실사용 시 문제 발생하면 대응) |
+| 12 | **OBS CEF 호환** | OBS 28+ 브라우저 소스에서 WebGL/CSS 동작 검증 | ✅ 사용자 테스트 확인 완료 |
+
+### 완료된 주요 마일스톤
+
+```
+v1: WS 서버 + OBS 페이지 기본 동작
+v2: HTTP+WS 통합 서빙, layout_diff, cached_snapshot 증분 갱신
+v3 P1: 설정 영속화, 오버레이 연동, KPS 로컬 계산, UI 안내
+v3 P2: 커스텀 CSS, 배경 미디어, keyDisplayDelayMs, 키별 노트 효과,
+ 보안 토큰, dev 모드 서빙, 포터블 exe AssetFetcher
+v4 Tier 1: Tauri IPC Shim + 백엔드 호환성 레이어 → 완전한 코드 재사용 ✅
+v4 Tier 2: 프로토콜 통합 — 전용 WS 메시지를 tauri_event로 일원화 ✅
+v4 Tier 3: invoke_request 필드명 수정 + OBS CEF 호환 확인 ✅
+```
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. 다음 시작 시 저장된 포트 우선 사용
+```
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 4663dc3c..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",
]
@@ -935,6 +970,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"
@@ -945,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"
@@ -1069,8 +1141,10 @@ dependencies = [
"base64 0.22.1",
"dirs-next",
"fern",
+ "futures-util",
"gif",
"gif-dispose",
+ "local-ip-address",
"log",
"notify",
"notify-debouncer-mini",
@@ -1096,6 +1170,8 @@ dependencies = [
"tempfile",
"thiserror 1.0.69",
"thread-priority",
+ "tokio",
+ "tokio-tungstenite",
"uuid",
"walkdir",
"webp-animation",
@@ -1636,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"
@@ -2461,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"
@@ -2671,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"
@@ -3462,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"
@@ -4187,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",
@@ -5067,9 +5217,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 +5242,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 +5467,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..298ef760 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -35,7 +35,10 @@ 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"
+local-ip-address = "0.6.10"
[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/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json
index 1d38aec3..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","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/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/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json
index dfbb23d2..a41475d9 100644
--- a/src-tauri/permissions/dmnote-allow-all.json
+++ b/src-tauri/permissions/dmnote-allow-all.json
@@ -67,6 +67,10 @@
"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",
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/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..57c6baaf
--- /dev/null
+++ b/src-tauri/src/commands/app/obs.rs
@@ -0,0 +1,105 @@
+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, state: State<'_, AppState>) -> CmdResult {
+ let port = state.store.with_state(|s| s.obs_port);
+ let token = resolve_and_save_token(&state);
+
+ // 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 {
+ // 프로덕션: 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 서빙");
+ }
+
+ // AppHandle 전달 (invoke_request 디스패치용)
+ state.obs_bridge.set_app_handle(app.clone());
+ // Tauri 이벤트 → OBS WS 포워딩 리스너 등록
+ state.obs_bridge.register_event_forwarding(&app);
+
+ let actual_port = state
+ .obs_bridge
+ .start(port, token)
+ .await
+ .map_err(crate::errors::CommandError::msg)?;
+ // 성공한 포트를 store에 저장 (fallback 시 다음 시작에 재사용)
+ if actual_port != port {
+ let _ = state.store.update(|s| {
+ s.obs_port = actual_port;
+ });
+ }
+ // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨)
+ state.refresh_obs_snapshot();
+ // 오버레이 destroy (이전 상태 보존)
+ state.obs_hide_overlay(&app);
+ let status = state.obs_bridge.status();
+ let _ = app.emit("obs:status", &status);
+ Ok(status)
+}
+
+#[tauri::command]
+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);
+ Ok(status)
+}
+
+#[tauri::command]
+pub fn obs_status(state: State<'_, AppState>) -> 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/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/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 CmdResult {
let updated = state.store.update_positions(positions)?;
app.emit("positions:changed", &updated)?;
+ state.refresh_obs_snapshot();
Ok(updated)
}
@@ -113,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,
@@ -220,6 +224,8 @@ pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult = 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();
@@ -457,6 +467,8 @@ pub fn custom_tabs_delete(
&serde_json::json!({ "mode": &next_selected }),
)?;
app.emit("keys:counters", &state.snapshot_key_counters())?;
+ state.obs_broadcast_counters();
+ state.refresh_obs_snapshot();
Ok(CustomTabDeleteResult {
success: true,
@@ -495,6 +507,7 @@ pub fn custom_tabs_select(
state.transfer_active_keys(&id);
app.emit("keys:mode-changed", &serde_json::json!({ "mode": &id }))?;
+ state.refresh_obs_snapshot();
Ok(CustomTabSelectResult {
success: true,
@@ -530,6 +543,7 @@ pub fn custom_tabs_restore(
"keys:mode-changed",
&serde_json::json!({ "mode": &selected_key_type }),
)?;
+ state.refresh_obs_snapshot();
Ok(())
}
@@ -538,6 +552,7 @@ pub fn keys_reset_counters(state: State<'_, AppState>, 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 +566,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 +581,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 +594,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)
}
@@ -593,6 +611,7 @@ pub fn layer_groups_update(
) -> 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/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/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/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/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/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/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/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs
index 4cefb845..8357a5ba 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,22 +52,23 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, 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,
@@ -97,7 +98,7 @@ pub fn pipe_server_create(name: &str) -> 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 +128,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/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 84f9bfec..473fe0ed 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(
@@ -366,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) as u32;
+ let mapped = MapVirtualKeyW(scan_code_prefixed, MAPVK_VSC_TO_VK_EX);
if mapped != 0 {
vk_norm = mapped;
} else {
@@ -390,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) as u32;
+ 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/main.rs b/src-tauri/src/main.rs
index 722d6b2b..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);
@@ -122,7 +126,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 +135,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();
@@ -152,6 +156,11 @@ 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::app::obs::obs_regenerate_token,
// 에디터 콘텐츠
commands::editor::css::css_get,
commands::editor::css::css_get_use,
@@ -640,7 +649,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/models/mod.rs b/src-tauri/src/models/mod.rs
index 8bdaac2e..7015f2d8 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};
@@ -42,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 {
@@ -310,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 {
@@ -794,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 {
@@ -927,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")]
@@ -972,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,
@@ -1001,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;
@@ -1036,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,
@@ -1045,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;
@@ -1103,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,
@@ -1112,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")]
@@ -1147,6 +1086,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 +1225,15 @@ 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,
+ /// OBS 세션 토큰 (영구 저장, 앱 재시작 시 재사용)
+ #[serde(default)]
+ pub obs_token: Option,
/// 플러그인 데이터 저장소 (plugin_data_* 키로 저장)
#[serde(default, flatten)]
pub plugin_data: HashMap,
@@ -1332,6 +1284,9 @@ impl Default for AppStoreData {
grid_settings: GridSettings::default(),
shortcuts: ShortcutsState::default(),
sound_library: HashMap::new(),
+ obs_mode_enabled: false,
+ obs_port: default_obs_port(),
+ obs_token: None,
plugin_data: HashMap::new(),
}
}
@@ -1551,6 +1506,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)]
@@ -1598,6 +1556,8 @@ pub struct SettingsState {
pub grid_settings: GridSettings,
#[serde(default)]
pub shortcuts: ShortcutsState,
+ #[serde(default)]
+ pub obs_mode_enabled: bool,
}
impl Default for SettingsState {
@@ -1628,12 +1588,14 @@ impl Default for SettingsState {
key_counter_enabled: false,
grid_settings: GridSettings::default(),
shortcuts: ShortcutsState::default(),
+ obs_mode_enabled: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
+#[derive(Default)]
pub struct NoteSettingsPatch {
pub frame_limit: Option,
pub speed: Option,
@@ -1650,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 {
@@ -1699,6 +1641,7 @@ pub struct SettingsPatchInput {
pub key_counter_enabled: Option,
pub grid_settings: Option,
pub shortcuts: Option,
+ pub obs_mode_enabled: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -1754,6 +1697,7 @@ impl SettingsDiff {
p.key_counter_enabled.is_some(),
p.grid_settings.is_some(),
p.shortcuts.is_some(),
+ p.obs_mode_enabled.is_some(),
]
.iter()
.filter(|&&x| x)
@@ -1810,4 +1754,6 @@ 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,
}
diff --git a/src-tauri/src/models/obs.rs b/src-tauri/src/models/obs.rs
new file mode 100644
index 00000000..12e7bd52
--- /dev/null
+++ b/src-tauri/src/models/obs.rs
@@ -0,0 +1,89 @@
+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)]
+#[allow(dead_code)]
+pub struct ObsEnvelope {
+ #[serde(default)]
+ pub v: u32,
+ #[serde(rename = "type")]
+ pub msg_type: String,
+ #[serde(default)]
+ pub payload: Value,
+}
+
+// ── Payload 타입 ──
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+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 request_id: String,
+ pub command: String,
+ #[serde(default)]
+ pub args: Value,
+}
+
+// ── 브로드캐스트 내부 메시지 (tokio::sync::broadcast용) ──
+
+#[derive(Debug, Clone)]
+pub enum ObsBroadcast {
+ Snapshot(Value),
+ /// 범용 Tauri 이벤트 포워딩 (event 이름 + JSON 데이터)
+ TauriEvent {
+ event: String,
+ data: Value,
+ },
+ /// 서버 종료 신호 — 클라이언트 세션 종료용
+ Shutdown,
+}
+
+/// OBS 연결 상태 (프론트엔드 표시용)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+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,
+ /// 로컬 네트워크 IP (같은 네트워크 내 다른 PC 접속용)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub local_ip: Option,
+}
+
+/// 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/services/css_watcher.rs b/src-tauri/src/services/css_watcher.rs
index 5a4752b5..c6f5b135 100644
--- a/src-tauri/src/services/css_watcher.rs
+++ b/src-tauri/src/services/css_watcher.rs
@@ -14,10 +14,10 @@ use std::time::Duration;
use notify::RecommendedWatcher;
use notify_debouncer_mini::{new_debouncer, Debouncer};
use parking_lot::RwLock;
-use tauri::{AppHandle, Emitter};
+use tauri::{AppHandle, Emitter, Manager};
use crate::models::{CustomCss, TabCss};
-use crate::state::AppStore;
+use crate::state::{AppState, AppStore};
/// CSS 워칭 타입
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -247,6 +247,15 @@ fn reload_global_css(store: &AppStore, app: &AppHandle, path: &str) -> 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/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..31cd9af7
--- /dev/null
+++ b/src-tauri/src/services/obs_bridge.rs
@@ -0,0 +1,976 @@
+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 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 crate::models::obs::{
+ make_envelope, HelloAckPayload, InvokeRequestPayload, 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",
+ "obs_regenerate_token",
+ // 파일 대화상자 / 파일 쓰기 (로컬 파일 시스템 접근)
+ "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>;
+
+/// OBS WebSocket 서버
+pub struct ObsBridgeService {
+ running: AtomicBool,
+ port: RwLock,
+ client_count: AtomicU32,
+ cached_snapshot: RwLock,
+ broadcast_tx: broadcast::Sender,
+ shutdown_tx: RwLock>>,
+ /// 서버 루프 태스크 핸들 (stop→start 경쟁 조건 방지)
+ server_handle: tokio::sync::Mutex >>,
+ /// Tauri 임베딩 에셋 조회 (포터블 exe용)
+ asset_fetcher: RwLock >,
+ /// dev 모드 Vite dev server URL (예: "http://localhost:3400")
+ dev_url: RwLock >,
+ server_version: String,
+ /// 세션 보안 토큰 (서버 시작 시 랜덤 생성)
+ session_token: RwLock,
+ /// Tauri AppHandle (invoke_request 디스패치용)
+ app_handle: RwLock>>,
+ /// 이벤트 포워딩용 리스너 ID (stop 시 해제)
+ event_listener_ids: RwLock>,
+}
+
+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),
+ server_handle: tokio::sync::Mutex::new(None),
+ asset_fetcher: RwLock::new(None),
+ dev_url: RwLock::new(None),
+ server_version: version.to_string(),
+ session_token: RwLock::new(String::new()),
+ app_handle: RwLock::new(None),
+ event_listener_ids: RwLock::new(Vec::new()),
+ }
+ }
+
+ pub fn set_asset_fetcher(&self, fetcher: AssetFetcher) {
+ *self.asset_fetcher.write() = Some(fetcher);
+ }
+
+ pub fn set_dev_url(&self, url: String) {
+ *self.dev_url.write() = Some(url);
+ }
+
+ pub fn set_app_handle(&self, handle: AppHandle) {
+ *self.app_handle.write() = Some(handle);
+ }
+
+ /// Tauri 이벤트를 OBS WS 클라이언트에 포워딩하는 리스너 등록
+ pub fn register_event_forwarding(&self, app: &AppHandle) {
+ // 기존 리스너 해제 (중복 호출 시 누적 방지)
+ 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",
+ ];
+
+ // 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_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(),
+ 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)
+ }
+
+ pub fn client_count(&self) -> u32 {
+ self.client_count.load(Ordering::Relaxed)
+ }
+
+ pub fn status(&self) -> ObsStatus {
+ let token = {
+ let t = self.session_token.read();
+ if t.is_empty() {
+ None
+ } else {
+ 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,
+ }
+ }
+
+ pub fn update_snapshot(&self, snapshot: Value) {
+ *self.cached_snapshot.write() = snapshot;
+ }
+
+ /// 범용 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();
+ let _ = self.broadcast_tx.send(ObsBroadcast::Snapshot(snapshot));
+ }
+
+ /// WS 서버 시작 (토큰은 호출자가 전달, 포트 자동 fallback)
+ pub async fn start(self: &Arc, port: u16, token: String) -> Result {
+ // 원자적 check-and-set
+ if self
+ .running
+ .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
+ .is_err()
+ {
+ 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;
+ }
+ }
+
+ // 세션 토큰 설정 (호출자가 생성/재사용 결정)
+ *self.session_token.write() = token;
+
+ // 포트 자동 fallback: 기본 포트 → +1 ~ +9 순차 시도
+ let mut listener = None;
+ let mut last_err = String::new();
+ for offset in 0u16..10 {
+ let try_port = port.saturating_add(offset);
+ let addr = SocketAddr::from(([0, 0, 0, 0], try_port));
+ match TcpListener::bind(addr).await {
+ Ok(l) => {
+ if offset > 0 {
+ log::info!("[ObsBridge] 포트 {port} 사용 불가, {try_port}로 fallback");
+ }
+ listener = Some(l);
+ break;
+ }
+ Err(e) => {
+ last_err = format!("포트 {try_port} 바인드 실패: {e}");
+ }
+ }
+ }
+ let listener = match listener {
+ Some(l) => l,
+ None => {
+ self.running.store(false, Ordering::Relaxed);
+ return Err(last_err);
+ }
+ };
+
+ // 실제 바인딩된 포트 저장 (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() = actual_port;
+
+ let bridge = Arc::clone(self);
+ let handle = tokio::spawn(async move {
+ bridge.server_loop(listener, shutdown_rx).await;
+ });
+ *self.server_handle.lock().await = Some(handle);
+
+ log::info!("[ObsBridge] 서버 시작: http://0.0.0.0:{actual_port}");
+ Ok(actual_port)
+ }
+
+ /// 서버 종료
+ pub fn stop(&self) {
+ 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() {
+ let _ = tx.send(());
+ }
+ self.running.store(false, Ordering::Relaxed);
+ // 토큰은 유지 (재시작 시 동일 토큰 재사용)
+ log::info!("[ObsBridge] 서버 종료");
+ }
+
+ /// 세션 토큰 교체 (실행 중 호출 가능)
+ pub fn set_token(&self, token: String) {
+ *self.session_token.write() = token;
+ }
+
+ 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;
+ }
+ }
+ }
+ // running 플래그는 stop()에서 관리 (server_loop에서 해제하면 restart 시 경쟁 조건 발생)
+ }
+
+ 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;
+
+ // GET 경로 파싱
+ let path = request
+ .lines()
+ .next()
+ .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"
+ let normalized = if path == "/" || path.is_empty() {
+ "obs/index.html"
+ } else {
+ path.trim_start_matches('/')
+ };
+
+ // 경로 탐색 공격 방지 (.., 절대경로, 드라이브 경로 거부)
+ 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",
+ )
+ .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;
+ }
+
+ // SPA fallback: 확장자 없는 경로 → obs/index.html
+ let has_extension = normalized
+ .rsplit('/')
+ .next()
+ .is_some_and(|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;
+ }
+ }
+
+ 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(
+ 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;
+ }
+ };
+
+ // 보안 토큰 검증
+ 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 전송 (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);
+ if ws_tx
+ .send(Message::Text(ack_msg.to_string()))
+ .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()))
+ .await
+ .is_err()
+ {
+ self.client_count.fetch_sub(1, Ordering::Relaxed);
+ return;
+ }
+
+ // RPC 응답 채널 (invoke_request → invoke_response)
+ let (rpc_tx, mut rpc_rx) =
+ tokio::sync::mpsc::unbounded_channel::<(String, Result)>();
+
+ // 메인 루프: broadcast 수신 + 클라이언트 메시지 수신 + RPC 응답
+ let mut ping_interval = tokio::time::interval(Duration::from_secs(30));
+
+ loop {
+ tokio::select! {
+ // 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())).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())).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())).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())).await.is_err() {
+ break;
+ }
+ }
+ "invoke_request" => {
+ self.handle_invoke_request(
+ &envelope.payload,
+ &addr,
+ rpc_tx.clone(),
+ );
+ }
+ _ => {}
+ }
+ }
+ }
+ Some(Ok(Message::Close(_))) | None => break,
+ Some(Ok(Message::Ping(data))) => {
+ let _ = ws_tx.send(Message::Pong(data)).await;
+ }
+ _ => {}
+ }
+ }
+ // RPC 응답 전송 (invoke_request → invoke_response)
+ Some((request_id, result)) = rpc_rx.recv() => {
+ let payload = match result {
+ Ok(data) => serde_json::json!({ "requestId": request_id, "result": data }),
+ Err(err) => serde_json::json!({ "requestId": request_id, "error": err }),
+ };
+ let msg = make_envelope("invoke_response", next_seq(), payload);
+ 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);
+ if ws_tx.send(Message::Text(ping_msg.to_string())).await.is_err() {
+ break;
+ }
+ }
+ }
+ }
+
+ self.client_count.fetch_sub(1, Ordering::Relaxed);
+ log::info!(
+ "[ObsBridge] 클라이언트 연결 종료: {addr} (남은 {})",
+ self.client_count()
+ );
+ }
+
+ /// invoke_request 처리: webview.on_message()로 Tauri 커맨드 파이프라인에 주입
+ fn handle_invoke_request(
+ &self,
+ payload: &Value,
+ addr: &SocketAddr,
+ rpc_tx: tokio::sync::mpsc::UnboundedSender<(String, Result)>,
+ ) {
+ let req: InvokeRequestPayload = match serde_json::from_value(payload.clone()) {
+ Ok(r) => r,
+ Err(e) => {
+ log::warn!("[ObsBridge] {addr}: invoke_request 파싱 실패: {e}");
+ // requestId를 추출 시도하여 에러 응답 전송 (파싱 실패여도 클라이언트 대기 방지)
+ if let Some(request_id) = payload.get("requestId").and_then(|v| v.as_str()) {
+ let _ = rpc_tx.send((
+ request_id.to_string(),
+ Err(serde_json::json!(format!("Invalid invoke_request: {e}"))),
+ ));
+ }
+ return;
+ }
+ };
+
+ // deny 체크 (이중 안전망 — 프론트엔드에서도 차단하지만 백엔드에서 한번 더)
+ if is_denied(&req.command) {
+ log::debug!("[ObsBridge] {addr}: denied cmd={}", req.command);
+ let _ = rpc_tx.send((
+ req.request_id,
+ Err(serde_json::json!(format!(
+ "Command denied: {}",
+ req.command
+ ))),
+ ));
+ 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.request_id,
+ Err(serde_json::json!("AppHandle not available")),
+ ));
+ return;
+ }
+ };
+
+ // OBS 모드에서 오버레이가 destroy된 상태일 수 있으므로 main window로 fallback
+ let webview_window = match app_handle
+ .get_webview_window("overlay")
+ .or_else(|| app_handle.get_webview_window("main"))
+ {
+ Some(w) => w,
+ None => {
+ log::warn!("[ObsBridge] {addr}: webview 없음 (overlay/main 모두)");
+ let _ = rpc_tx.send((
+ req.request_id,
+ Err(serde_json::json!("No webview window available")),
+ ));
+ return;
+ }
+ };
+
+ // InvokeRequest 구성
+ // 플랫폼별 로컬 URL (Windows: http://tauri.localhost, macOS/Linux: tauri://localhost)
+ let local_url = if cfg!(windows) || cfg!(target_os = "android") {
+ tauri::Url::parse("http://tauri.localhost").unwrap()
+ } else {
+ tauri::Url::parse("tauri://localhost").unwrap()
+ };
+ let invoke_key = app_handle.invoke_key().to_string();
+ let request = InvokeRequest {
+ cmd: req.command.clone(),
+ callback: CallbackFn(0),
+ error: CallbackFn(1),
+ url: local_url,
+ body: InvokeBody::Json(req.args),
+ headers: Default::default(),
+ invoke_key,
+ };
+
+ let request_id = req.request_id;
+ let cmd = req.command.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((request_id, result));
+ });
+
+ log::debug!("[ObsBridge] {addr_clone}: invoke cmd={cmd}");
+ webview_window.on_message(request, responder);
+ }
+
+ /// /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("")
+ .to_ascii_lowercase()
+ .as_str()
+ {
+ "html" | "htm" => "text/html; charset=utf-8",
+ "js" | "mjs" => "application/javascript; charset=utf-8",
+ "css" => "text/css; charset=utf-8",
+ "json" => "application/json; charset=utf-8",
+ "png" => "image/png",
+ "jpg" | "jpeg" => "image/jpeg",
+ "gif" => "image/gif",
+ "webp" => "image/webp",
+ "svg" => "image/svg+xml",
+ "mp4" => "video/mp4",
+ "webm" => "video/webm",
+ "ogg" => "video/ogg",
+ "woff2" => "font/woff2",
+ "woff" => "font/woff",
+ "ttf" => "font/ttf",
+ "otf" => "font/otf",
+ "wasm" => "application/wasm",
+ _ => "application/octet-stream",
+ }
+}
+
+/// 간단한 percent-decoding (%XX → 바이트)
+fn percent_decode(input: &str) -> String {
+ let mut result = Vec::with_capacity(input.len());
+ let bytes = input.as_bytes();
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
+ if let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16) {
+ result.push(byte);
+ i += 3;
+ continue;
+ }
+ }
+ result.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&result).into_owned()
+}
+
+/// ObsBroadcast → JSON envelope 변환
+fn broadcast_to_envelope(broadcast: &ObsBroadcast, seq: u64) -> Value {
+ match broadcast {
+ ObsBroadcast::Snapshot(snapshot) => make_envelope("snapshot", seq, snapshot.clone()),
+ ObsBroadcast::TauriEvent { event, data } => make_envelope(
+ "tauri_event",
+ seq,
+ serde_json::json!({ "event": event, "data": data }),
+ ),
+ ObsBroadcast::Shutdown => unreachable!("Shutdown은 직접 처리됨"),
+ }
+}
diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs
index 92aa78a6..104322d5 100644
--- a/src-tauri/src/services/settings.rs
+++ b/src-tauri/src/services/settings.rs
@@ -49,6 +49,7 @@ 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;
})?;
Ok(SettingsDiff {
@@ -177,6 +178,9 @@ 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);
+ }
normalized
}
@@ -244,6 +248,9 @@ 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;
+ }
current
}
diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs
index e02e95b1..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,
};
@@ -35,7 +35,7 @@ use crate::{
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 +63,10 @@ pub struct AppState {
key_sound: Arc,
/// CSS 파일 핫리로딩 워처
css_watcher: RwLock>,
+ /// OBS WebSocket 브릿지
+ pub obs_bridge: Arc,
+ /// OBS 모드 시작 전 오버레이 가시성 상태 (복원용)
+ obs_previous_overlay_visible: Arc>>,
}
impl AppState {
@@ -78,6 +82,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,25 +98,34 @@ impl AppState {
raw_input_subscribers: Arc::new(std::sync::atomic::AtomicU32::new(0)),
key_sound,
css_watcher: RwLock::new(None),
+ obs_bridge,
+ obs_previous_overlay_visible: Arc::new(RwLock::new(None)),
})
}
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();
+ 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())?;
// CSS 핫리로딩 워처 초기화
self.initialize_css_watcher(app);
+ // OBS 모드 자동 복원
+ if snapshot.obs_mode_enabled {
+ self.auto_start_obs(app);
+ }
Ok(())
}
@@ -158,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) {
@@ -223,6 +251,7 @@ 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,
},
keys: state.keys.clone(),
positions: state.key_positions.clone(),
@@ -237,6 +266,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(),
}
}
@@ -258,6 +290,13 @@ impl AppState {
if let Some(value) = diff.changed.key_counter_enabled {
self.key_counter_enabled.store(value, Ordering::SeqCst);
}
+ // OBS 브릿지 캐시 갱신 (이벤트는 register_event_forwarding이 자동 포워딩)
+ if self.obs_bridge.is_running() {
+ let bp = self.bootstrap_payload();
+ if let Ok(snap) = serde_json::to_value(&bp) {
+ self.obs_bridge.update_snapshot(snap);
+ }
+ }
// 전체 설정 페이로드 전송 방지 (임베디드 폰트 등 대용량 데이터 제외)
let mut payload = diff.clone();
payload.full = None;
@@ -265,6 +304,171 @@ 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, 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로 리다이렉트
+ if cfg!(debug_assertions) {
+ let dev_url = "http://localhost:3400".to_string();
+ log::info!("[ObsBridge] dev 모드: Vite dev server로 리다이렉트 ({dev_url})");
+ bridge.set_dev_url(dev_url);
+ } else {
+ // 프로덕션: Tauri 임베딩 에셋으로 서빙
+ let handle = app_handle.clone();
+ let fetcher = std::sync::Arc::new(move |path: &str| {
+ let resolver = handle.asset_resolver();
+ resolver.get(path.into()).map(|asset| {
+ let mime = asset.mime_type.clone();
+ (asset.bytes.to_vec(), mime)
+ })
+ });
+ bridge.set_asset_fetcher(fetcher);
+ }
+
+ // AppHandle 전달 (invoke_request 디스패치용)
+ bridge.set_app_handle(app.clone());
+ // Tauri 이벤트 → OBS WS 포워딩 리스너 등록
+ bridge.register_event_forwarding(app);
+
+ // 부팅 시에는 오버레이를 생성하지 않았으므로 상태만 저장
+ // (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, token).await {
+ Ok(actual_port) => {
+ log::info!("[ObsBridge] auto-start 성공 (port={})", actual_port);
+ // fallback 포트가 사용된 경우 store에 저장
+ if actual_port != port {
+ let _ = store.update(|s| {
+ s.obs_port = actual_port;
+ });
+ }
+ let state = app_handle.state::();
+ // 초기 스냅샷 캐싱 (신규 클라이언트에 전송됨)
+ state.refresh_obs_snapshot();
+ let _ = app_handle.emit("obs:status", &state.obs_bridge.status());
+ }
+ Err(e) => {
+ log::error!(
+ "[ObsBridge] auto-start 실패: {} — obs_mode_enabled를 false로 복구",
+ e
+ );
+ let _ = store.update(|state| {
+ state.obs_mode_enabled = false;
+ });
+ // 실패 시 오버레이 복원 (윈도우 재생성 포함)
+ let state = app_handle.state::();
+ state.obs_restore_overlay(&app_handle);
+ let _ = app_handle.emit("obs:status", &state.obs_bridge.status());
+ }
+ }
+ });
+ }
+
+ /// OBS 시작 시 오버레이 윈도우 destroy (이전 상태 보존)
+ pub fn obs_hide_overlay(&self, app: &AppHandle) {
+ let was_visible = *self.overlay_visible.read();
+ *self.obs_previous_overlay_visible.write() = Some(was_visible);
+ // destroy()는 CloseRequested 이벤트 없이 즉시 윈도우를 파괴
+ if let Some(window) = app.get_webview_window(OVERLAY_LABEL) {
+ if let Err(e) = window.destroy() {
+ log::warn!("[ObsBridge] 오버레이 destroy 실패: {}", e);
+ // destroy 실패 시 hide로 fallback
+ if was_visible {
+ if let Err(e) = self.set_overlay_visibility(app, false) {
+ log::warn!("[ObsBridge] 오버레이 hide fallback 실패: {}", e);
+ }
+ }
+ return;
+ }
+ }
+ // destroy 성공(또는 윈도우 부재) 후 런타임 플래그만 갱신
+ // store.overlay_visible은 변경하지 않음 — ensure_overlay_window가 재생성 시
+ // 이 값을 기준으로 show/hide를 결정하므로, 원래 값을 유지해야 함
+ *self.overlay_visible.write() = false;
+ let _ = app.emit("overlay:visibility", &json!({ "visible": false }));
+ }
+
+ /// OBS 중지 시 오버레이 재생성 + 복원
+ pub fn obs_restore_overlay(&self, app: &AppHandle) {
+ let prev = self.obs_previous_overlay_visible.write().take();
+ match prev {
+ Some(true) => {
+ // set_overlay_visibility(true) 내부에서 ensure_overlay_window + show + store 갱신 + emit 처리
+ if let Err(e) = self.set_overlay_visibility(app, true) {
+ log::warn!("[ObsBridge] 오버레이 복원 실패: {}", e);
+ }
+ }
+ Some(false) => {
+ // 이전 상태가 hidden이었더라도 윈도우는 재생성 필요
+ // (이후 sync 커맨드에서 WebView2 빌드 시 메시지 루프 블로킹 방지)
+ if let Err(e) = self.ensure_overlay_window(app) {
+ log::warn!("[ObsBridge] 오버레이 윈도우 재생성 실패: {}", e);
+ }
+ }
+ None => {}
+ }
+ }
+
+ /// OBS 모드 활성화 여부
+ pub fn is_obs_mode_active(&self) -> bool {
+ self.obs_bridge.is_running()
+ }
+
+ /// OBS 브릿지용 전체 스냅샷 빌드 + 캐시 갱신 + 연결된 클라이언트에 broadcast
+ pub fn refresh_obs_snapshot(&self) {
+ if !self.obs_bridge.is_running() {
+ return;
+ }
+ let payload = self.bootstrap_payload();
+ if let Ok(snapshot) = serde_json::to_value(&payload) {
+ self.obs_bridge.update_snapshot(snapshot);
+ self.obs_bridge.broadcast_snapshot();
+ }
+ }
+
+ /// OBS 브릿지 캐시 스냅샷 갱신 (이벤트는 register_event_forwarding이 자동 포워딩)
+ /// CSS 등 개별 설정 변경이 OBS 런타임 상태(키 시그널, KPS)를 리셋하지 않도록 사용
+ pub fn notify_obs_settings_diff(&self, _diff: serde_json::Value) {
+ if !self.obs_bridge.is_running() {
+ return;
+ }
+ let bp = self.bootstrap_payload();
+ if let Ok(snap) = serde_json::to_value(&bp) {
+ self.obs_bridge.update_snapshot(snap);
+ }
+ }
+
+ /// OBS 브릿지 캐시 스냅샷 갱신 (카운터 이벤트는 register_event_forwarding이 자동 포워딩)
+ pub fn obs_broadcast_counters(&self) {
+ if !self.obs_bridge.is_running() {
+ return;
+ }
+ let bp = self.bootstrap_payload();
+ if let Ok(snap) = serde_json::to_value(&bp) {
+ self.obs_bridge.update_snapshot(snap);
+ }
+ }
+
pub fn set_overlay_visibility(&self, app: &AppHandle, visible: bool) -> Result<()> {
log::debug!("[IPC] set_overlay_visibility: visible={}", visible);
@@ -355,6 +559,7 @@ impl AppState {
Ok(updated.overlay_resize_anchor.as_str().to_string())
}
+ #[allow(clippy::too_many_arguments)]
pub fn resize_overlay(
&self,
app: &AppHandle,
@@ -443,8 +648,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,
}
@@ -591,10 +795,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");
@@ -678,8 +886,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(""));
@@ -1054,6 +1261,7 @@ impl AppState {
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 {
api.prevent_close();
@@ -1147,8 +1355,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 {
@@ -1193,10 +1400,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();
}
}
}
@@ -1681,7 +1888,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::{
@@ -1720,7 +1926,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)
@@ -1884,13 +2090,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| {
diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs
index 8af3d5cc..4634b1ab 100644
--- a/src-tauri/src/state/store.rs
+++ b/src-tauri/src/state/store.rs
@@ -295,15 +295,18 @@ 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,
}
}
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)
}
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
diff --git a/src/renderer/api/ipcShim.ts b/src/renderer/api/ipcShim.ts
new file mode 100644
index 00000000..f9362058
--- /dev/null
+++ b/src/renderer/api/ipcShim.ts
@@ -0,0 +1,430 @@
+/**
+ * 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)
+ */
+
+import { OBS_PROTOCOL_VERSION } from '@src/types/obs';
+import type { ObsEnvelope, HelloAckPayload } from '@src/types/obs';
+
+// ── 내부 상태 ──
+
+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)
+let denyList: string[] = [];
+
+// 콜백 레지스트리 (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 }
+>();
+
+// snapshot 수신 여부 (initIpcShim에서 연결 준비 확인용)
+let _snapshotReceived = false;
+
+// ── 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;
+ }
+
+ case 'snapshot': {
+ // 재연결 시 snapshot 수신 — 연결 준비 신호로만 사용
+ _snapshotReceived = true;
+ 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. 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) {
+ _snapshotReceived = true;
+ 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 = [];
+ _snapshotReceived = false;
+}
diff --git a/src/renderer/api/modules/obsApi.ts b/src/renderer/api/modules/obsApi.ts
new file mode 100644
index 00000000..4507dd4b
--- /dev/null
+++ b/src/renderer/api/modules/obsApi.ts
@@ -0,0 +1,13 @@
+import { invoke } from '@tauri-apps/api/core';
+import { subscribe } from './shared';
+
+import type { ObsStatus } from '@src/types/obs';
+
+export const obsApi = {
+ start: () => invoke('obs_start'),
+ 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/Settings.tsx b/src/renderer/components/main/Settings.tsx
index 4009b36d..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,6 +6,7 @@ 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 ShortcutSettingsModal from '@components/main/Modal/content/settings/ShortcutSettingsModal';
@@ -26,6 +27,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 +128,14 @@ 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 obsTogglingRef = useRef(false);
+
// Lenis smooth scroll 적용 (전역 설정 사용)
const { scrollContainerRef } = useLenis();
@@ -154,6 +166,40 @@ const Settings = ({
}
}, [isMacOS, angleMode, setAngleMode]);
+ // OBS 상태 이벤트 구독 + clientCount 폴링
+ useEffect(() => {
+ let mounted = true;
+ 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) {
+ setObsStatus((prev) =>
+ prev.clientCount === status.clientCount ? prev : status,
+ );
+ }
+ } catch {}
+ }, 5000);
+
+ return () => {
+ mounted = false;
+ unsubscribe();
+ clearInterval(interval);
+ };
+ }, []);
+
const LANGUAGE_OPTIONS: { value: string; label: string }[] = [
{ value: 'ko', label: '한국어' },
{ value: 'en', label: 'English' },
@@ -523,6 +569,54 @@ const Settings = ({
}
};
+ const handleObsToggle = async (): Promise => {
+ const next = !obsStatus.running;
+ setObsStatus((prev) => ({ ...prev, running: next }));
+ if (obsTogglingRef.current) return;
+ obsTogglingRef.current = true;
+ try {
+ 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?.(
+ next ? t('settings.obsStartFailed') : t('settings.obsStopFailed'),
+ );
+ } finally {
+ obsTogglingRef.current = false;
+ }
+ };
+
+ const handleObsCopyUrl = async (): Promise => {
+ const tokenParam = obsStatus.token ? `?token=${obsStatus.token}` : '';
+ const host = obsStatus.localIp || 'localhost';
+ const url = `http://${host}:${obsStatus.port}${tokenParam}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ showAlert?.(t('settings.obsCopied'));
+ } catch {
+ showAlert?.(url);
+ }
+ };
+
+ 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 => {
const next: boolean = !developerModeEnabled;
setDeveloperModeEnabled(next);
@@ -799,22 +893,31 @@ const Settings = ({
>
{t('settings.pluginManageLabel')}
-
+
- {t('settings.reloadPlugins')}
+
+ {/* OBS 모드 */}
+ setHoveredKey('obsMode')}
+ onMouseLeave={() => setHoveredKey(null)}
+ >
+
+
+ {t('settings.obsMode')}
+
+
+
+
+
+ {obsStatus.running
+ ? obsStatus.clientCount > 0
+ ? `${t('settings.obsRunning')} · ${t(
+ 'settings.obsClients',
+ { count: obsStatus.clientCount },
+ )}`
+ : t('settings.obsRunning')
+ : t('settings.obsStopped')}
+
+
+
+
+
+
+ {t('settings.obsCopyUrl')}
+
+
+
+
{/* 기타 설정 */}
@@ -933,6 +1100,14 @@ const Settings = ({
+ ) : hoveredKey === 'obsMode' ? (
+
+
+
+ {t('settings.obsGuide')}
+
+
+
) : (
)}
diff --git a/src/renderer/components/main/Tool/SettingTool.tsx b/src/renderer/components/main/Tool/SettingTool.tsx
index 61641a92..7d71e1e2 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) => {
: }
- onClick={toggleOverlay}
+ onClick={isObsModeActive ? undefined : toggleOverlay}
+ disabled={isObsModeActive}
/>
@@ -335,19 +354,30 @@ SettingToolProps) => {
interface ButtonProps {
icon: React.ReactNode;
isSelected?: boolean;
+ disabled?: boolean;
onClick?: () => void;
}
-const Button = ({ icon, isSelected = false, onClick }: ButtonProps) => {
+const Button = ({
+ icon,
+ isSelected = false,
+ disabled = false,
+ onClick,
+}: ButtonProps) => {
return (
{icon}
diff --git a/src/renderer/components/shared/OverlayScene.tsx b/src/renderer/components/shared/OverlayScene.tsx
new file mode 100644
index 00000000..fe154d21
--- /dev/null
+++ b/src/renderer/components/shared/OverlayScene.tsx
@@ -0,0 +1,254 @@
+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/defaults.ts b/src/renderer/defaults.ts
index 76d011a9..06b4efd0 100644
--- a/src/renderer/defaults.ts
+++ b/src/renderer/defaults.ts
@@ -210,5 +210,6 @@ function FALLBACK_SETTINGS_STATE(): SettingsState {
keyCounterEnabled: false,
gridSettings: FALLBACK_GRID_SETTINGS(),
shortcuts: FALLBACK_SHORTCUTS(),
+ obsModeEnabled: false,
};
}
diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts
index 719e5c3f..11f52866 100644
--- a/src/renderer/hooks/app/useAppBootstrap.ts
+++ b/src/renderer/hooks/app/useAppBootstrap.ts
@@ -207,6 +207,7 @@ export function useAppBootstrap() {
gridSettings:
bootstrap.settings.gridSettings ?? getDefaultGridSettings(),
shortcuts: bootstrap.settings.shortcuts ?? getDefaultShortcuts(),
+ obsModeEnabled: bootstrap.settings.obsModeEnabled ?? false,
});
useFontStore.setState({
customFonts: bootstrap.settings.fontSettings.customFonts.map(
diff --git a/src/renderer/hooks/shared/useLayoutComputation.ts b/src/renderer/hooks/shared/useLayoutComputation.ts
new file mode 100644
index 00000000..50ee53bf
--- /dev/null
+++ b/src/renderer/hooks/shared/useLayoutComputation.ts
@@ -0,0 +1,217 @@
+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/locales/en.json b/src/renderer/locales/en.json
index c3c9bab4..ebfd6313 100644
--- a/src/renderer/locales/en.json
+++ b/src/renderer/locales/en.json
@@ -72,7 +72,20 @@
"keyCounterDesc": "Track and display the number of presses for each key.",
"counterResetButton": "Reset",
"counterReset": "Key counters have been reset.",
- "counterResetFailed": "Failed to reset key counters."
+ "counterResetFailed": "Failed to reset key counters.",
+ "obsMode": "Enable OBS Mode",
+ "obsStart": "Start",
+ "obsStop": "Stop",
+ "obsRunning": "Running",
+ "obsStopped": "Stopped",
+ "obsClients": "{{count}} client(s) connected",
+ "obsCopyUrl": "Copy URL",
+ "obsCopied": "URL copied to clipboard.",
+ "obsStartFailed": "Failed to start OBS server.",
+ "obsStopFailed": "Failed to stop OBS server.",
+ "obsGuide": "Displays the overlay via a browser source in OBS.",
+ "obsTokenRegenMessage": "Regenerate the session token?",
+ "obsTokenRegenConfirm": "Regenerate"
},
"shortcutSetting": {
"title": "Shortcut Settings",
@@ -174,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 37667799..ed1e310b 100644
--- a/src/renderer/locales/ko.json
+++ b/src/renderer/locales/ko.json
@@ -72,7 +72,20 @@
"keyCounterDesc": "각 키 입력 횟수를 기록하고 표시합니다.",
"counterResetButton": "초기화",
"counterReset": "키 카운터가 초기화되었습니다.",
- "counterResetFailed": "키 카운터 초기화에 실패했습니다."
+ "counterResetFailed": "키 카운터 초기화에 실패했습니다.",
+ "obsMode": "OBS 모드 활성화",
+ "obsStart": "시작",
+ "obsStop": "중지",
+ "obsRunning": "실행 중",
+ "obsStopped": "중지됨",
+ "obsClients": "클라이언트 {{count}}개 연결됨",
+ "obsCopyUrl": "URL 복사",
+ "obsCopied": "URL이 클립보드에 복사되었습니다.",
+ "obsStartFailed": "OBS 서버 시작에 실패했습니다.",
+ "obsStopFailed": "OBS 서버 중지에 실패했습니다.",
+ "obsGuide": "OBS에서 브라우저 소스로 오버레이를 표시합니다.",
+ "obsTokenRegenMessage": "세션 토큰을 재생성 하시겠습니까?",
+ "obsTokenRegenConfirm": "재생성"
},
"shortcutSetting": {
"title": "단축키 설정",
@@ -174,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 23f36e8f..5c1db637 100644
--- a/src/renderer/locales/ru.json
+++ b/src/renderer/locales/ru.json
@@ -72,7 +72,20 @@
"keyCounterDesc": "Учитывать и показывать число нажатий клавиш.",
"counterResetButton": "Сброс",
"counterReset": "Счётчики сброшены.",
- "counterResetFailed": "Ошибка сброса счётчиков."
+ "counterResetFailed": "Ошибка сброса счётчиков.",
+ "obsMode": "Включить режим OBS",
+ "obsStart": "Запуск",
+ "obsStop": "Остановка",
+ "obsRunning": "Работает",
+ "obsStopped": "Остановлен",
+ "obsClients": "Подключено клиентов: {{count}}",
+ "obsCopyUrl": "Копировать URL",
+ "obsCopied": "URL скопирован в буфер обмена.",
+ "obsStartFailed": "Не удалось запустить сервер OBS.",
+ "obsStopFailed": "Не удалось остановить сервер OBS.",
+ "obsGuide": "Отображает оверлей через источник «Браузер» в OBS.",
+ "obsTokenRegenMessage": "Сгенерировать новый токен сессии?",
+ "obsTokenRegenConfirm": "Сгенерировать"
},
"shortcutSetting": {
"title": "Горячие клавиши",
@@ -174,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 58d1a74d..56257894 100644
--- a/src/renderer/locales/zh-Hant.json
+++ b/src/renderer/locales/zh-Hant.json
@@ -72,7 +72,20 @@
"keyCounterDesc": "追蹤並顯示每個按鍵的按下次數.",
"counterResetButton": "重置",
"counterReset": "按鍵計數器已重置.",
- "counterResetFailed": "重置按鍵計數器失敗."
+ "counterResetFailed": "重置按鍵計數器失敗.",
+ "obsMode": "啟用 OBS 模式",
+ "obsStart": "啟動",
+ "obsStop": "停止",
+ "obsRunning": "執行中",
+ "obsStopped": "已停止",
+ "obsClients": "已連線 {{count}} 個用戶端",
+ "obsCopyUrl": "複製 URL",
+ "obsCopied": "URL 已複製到剪貼簿。",
+ "obsStartFailed": "OBS 伺服器啟動失敗。",
+ "obsStopFailed": "OBS 伺服器停止失敗。",
+ "obsGuide": "透過 OBS 瀏覽器來源顯示覆蓋層。",
+ "obsTokenRegenMessage": "是否重新產生工作階段權杖?",
+ "obsTokenRegenConfirm": "重新產生"
},
"shortcutSetting": {
"title": "快捷鍵設定",
@@ -174,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 7aa9443b..3986485d 100644
--- a/src/renderer/locales/zh-cn.json
+++ b/src/renderer/locales/zh-cn.json
@@ -72,7 +72,20 @@
"keyCounterDesc": "跟踪并显示每个按键的按下次数.",
"counterResetButton": "重置",
"counterReset": "按键计数器已重置.",
- "counterResetFailed": "重置按键计数器失败."
+ "counterResetFailed": "重置按键计数器失败.",
+ "obsMode": "启用 OBS 模式",
+ "obsStart": "启动",
+ "obsStop": "停止",
+ "obsRunning": "运行中",
+ "obsStopped": "已停止",
+ "obsClients": "已连接 {{count}} 个客户端",
+ "obsCopyUrl": "复制 URL",
+ "obsCopied": "URL 已复制到剪贴板。",
+ "obsStartFailed": "OBS 服务器启动失败。",
+ "obsStopFailed": "OBS 服务器停止失败。",
+ "obsGuide": "通过 OBS 浏览器源显示叠加层。",
+ "obsTokenRegenMessage": "是否重新生成会话令牌?",
+ "obsTokenRegenConfirm": "重新生成"
},
"shortcutSetting": {
"title": "快捷键设置",
@@ -174,6 +187,7 @@
"importExport": "导入/导出",
"overlayClose": "关闭悬浮窗",
"overlayOpen": "打开悬浮窗",
+ "overlayObsDisabled": "OBS 模式使用中",
"back": "返回",
"settings": "设置",
"etcSettings": "其他设置",
diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts
index d114d241..a982b37b 100644
--- a/src/renderer/stores/useSettingsStore.ts
+++ b/src/renderer/stores/useSettingsStore.ts
@@ -46,6 +46,7 @@ interface SettingsState {
keyCounterEnabled: boolean;
gridSettings: GridSettings;
shortcuts: ShortcutsState;
+ obsModeEnabled: boolean;
setAll: (payload: SettingsStateSnapshot) => void;
merge: (payload: Partial) => void;
setLaboratoryEnabled: (value: boolean) => void;
@@ -71,6 +72,7 @@ interface SettingsState {
setKeyCounterEnabled: (value: boolean) => void;
setGridSettings: (value: GridSettings) => void;
setShortcuts: (value: ShortcutsState) => void;
+ setObsModeEnabled: (value: boolean) => void;
}
export type SettingsStateSnapshot = Omit<
@@ -100,6 +102,7 @@ export type SettingsStateSnapshot = Omit<
| 'setDeveloperModeEnabled'
| 'setGridSettings'
| 'setShortcuts'
+ | 'setObsModeEnabled'
>;
const initialState: SettingsStateSnapshot = {
@@ -126,6 +129,7 @@ const initialState: SettingsStateSnapshot = {
keyCounterEnabled: false,
gridSettings: getDefaultGridSettings(),
shortcuts: getDefaultShortcuts(),
+ obsModeEnabled: false,
};
function mergeSnapshot(
@@ -204,4 +208,5 @@ 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 }),
}));
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;
}
}
diff --git a/src/renderer/windows/obs/index.html b/src/renderer/windows/obs/index.html
new file mode 100644
index 00000000..87e41bf0
--- /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..b33510ac
--- /dev/null
+++ b/src/renderer/windows/obs/index.tsx
@@ -0,0 +1,47 @@
+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 {
+ // 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(
+
+
+ ,
+ );
+ } catch (error) {
+ const err = error as Error;
+ 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}`;
+ document.body.replaceChildren(pre);
+ }
+}
+
+bootstrap();
diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx
index cd564c85..1d30b670 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,13 +6,8 @@ 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,
- 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';
@@ -28,87 +23,15 @@ 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 from '@components/shared/OverlayScene';
+import { computeLayout } from '@hooks/shared/useLayoutComputation';
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 +41,6 @@ export default function App() {
useBuiltinStatsSubscription();
useBlockBrowserShortcuts();
const { t } = useTranslation();
- const macOS = isMac();
const developerModeEnabled = useSettingsStore(
(state) => state.developerModeEnabled,
);
@@ -562,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);
@@ -836,109 +584,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}
+ />
);
}
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;
}
diff --git a/src/types/obs.ts b/src/types/obs.ts
new file mode 100644
index 00000000..1781f7e1
--- /dev/null
+++ b/src/types/obs.ts
@@ -0,0 +1,38 @@
+// 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;
+ token?: string;
+}
+
+// ── 서버 → 클라이언트 ──
+
+export interface HelloAckPayload {
+ serverVersion: string;
+ obsMode: boolean;
+ denyList?: string[];
+}
+
+export interface ObsStatus {
+ running: boolean;
+ port: number;
+ clientCount: number;
+ token?: string;
+ localIp?: string;
+}
diff --git a/src/types/settings/settings.ts b/src/types/settings/settings.ts
index 9ee3abe6..02771be4 100644
--- a/src/types/settings/settings.ts
+++ b/src/types/settings/settings.ts
@@ -55,6 +55,7 @@ export interface SettingsState {
keyCounterEnabled: boolean;
gridSettings: GridSettings;
shortcuts: ShortcutsState;
+ obsModeEnabled: boolean;
}
/** @deprecated Use getDefaultSettingsState() from @src/renderer/defaults */
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"),
},
},
},