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')}

-
+
+ {/* 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')} +

+
+ + +
+
+
{/* 기타 설정 */}
@@ -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) => {
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"), }, }, },