diff --git a/docs/content/en/api-reference/css-js/page.mdx b/docs/content/en/api-reference/css-js/page.mdx index dc1fa6b7..312b42c3 100644 --- a/docs/content/en/api-reference/css-js/page.mdx +++ b/docs/content/en/api-reference/css-js/page.mdx @@ -9,101 +9,246 @@ Manage custom CSS and JavaScript plugins. ## dmn.css -### `dmn.css.get(): Promise` +Global custom CSS state is `{ path: string | null, content: string }`. -Returns current custom CSS code. +### `dmn.css.get(): Promise` + +Returns the current custom CSS (source file path and content). ```javascript -const css = await dmn.css.get(); -console.log(css); +const { path, content } = await dmn.css.get(); ``` -### `dmn.css.set(css: string): Promise` +### `dmn.css.getUse(): Promise` -Sets custom CSS code. +Returns whether custom CSS is enabled. -```javascript -await dmn.css.set(` - .key { - background: linear-gradient(45deg, #ff6b6b, #feca57); - border-radius: 8px; - } -`); +### `dmn.css.toggle(enabled: boolean): Promise<{ enabled: boolean }>` + +Enables or disables custom CSS. + +### `dmn.css.load(): Promise` + +Opens a file dialog and imports the selected CSS file. The file must be a +regular `.css` file, UTF-8 encoded, and at most 1 MiB. Successfully imported +files are added to the history (up to 10 entries). + +```typescript +type CssLoadResult = { + success: boolean; + error?: string; // error code on failure (see table below) + content?: string; + path?: string; +}; ``` -### `dmn.css.onChanged(callback): Unsubscribe` +If the user cancels the dialog, `success` is `false` and `error` is omitted. + +### `dmn.css.setContent(content: string): Promise` + +Replaces the CSS content directly (the source path is unchanged). Content is +limited to 1 MiB. + +### `dmn.css.reset(): Promise` -Subscribes to CSS changes. +Disables custom CSS and clears the content. The history is preserved. + +### `dmn.css.onUse(callback): Unsubscribe` + +Subscribes to enable/disable changes. The callback receives +`{ enabled: boolean }`. + +### `dmn.css.onContent(callback): Unsubscribe` + +Subscribes to content changes (imports, hot reload, direct edits). The +callback receives `CustomCss`. ```javascript -const unsub = dmn.css.onChanged((css) => { - console.log("CSS updated:", css); +const unsub = dmn.css.onContent(({ path, content }) => { + console.log("CSS updated:", path); }); ``` ---- +## dmn.css history -## dmn.js +The app keeps up to 10 previously imported CSS files. Only paths in this +history can be re-activated; arbitrary paths are rejected with +`PATH_NOT_AUTHORIZED`. + +### `dmn.css.historyGet(): Promise` -### `dmn.js.get(): Promise` +```typescript +type CustomCssHistoryItem = { + path: string; + lastUsedAt: number; // unix millis + status: "available" | "missing" | "invalid" | "tooLarge"; +}; +``` -Returns current JavaScript plugin code. +`status` is advisory: it reflects a quick file check at query time, and the +authoritative validation happens on activation. -```javascript -const js = await dmn.js.get(); -console.log(js); +### `dmn.css.historyActivate(path: string): Promise` + +Switches the global custom CSS to a history entry without a file dialog. + +```typescript +type CssActivateResult = { + success: boolean; + code?: CssHistoryErrorCode; // on failure + content?: string; + path?: string; +}; ``` -### `dmn.js.set(js: string): Promise` +### `dmn.css.historyRemove(path: string): Promise` -Sets JavaScript plugin code. +Removes an entry from the history (the file itself is not touched) and +returns the updated list. -```javascript -await dmn.js.set(` - // @id my-plugin - dmn.plugin.defineElement({ - name: "My Plugin", - template: (state, settings, { html }) => html\`
Hello
\`, - }); -`); +### Error codes + +| Code | Meaning | +| --- | --- | +| `PATH_NOT_AUTHORIZED` | The path is not in the history | +| `NOT_FOUND` | File does not exist | +| `NOT_REGULAR_FILE` | Not a regular file | +| `INVALID_EXTENSION` | Not a `.css` file | +| `TOO_LARGE` | Over 1 MiB | +| `INVALID_UTF8` | Not valid UTF-8 | +| `IO_ERROR` | Other I/O failure | + +## dmn.css.tab + +Per-tab CSS overrides the global CSS for a single key tab. A tab override is +`{ path: string | null, content: string, enabled: boolean }`. Tab CSS is +rendered only while the global custom CSS toggle is on. + +### `dmn.css.tab.getAll(): Promise` + +Returns all overrides as a `Record`. + +### `dmn.css.tab.get(tabId: string): Promise` + +Returns `{ tabId, css: TabCss | null }`. + +### `dmn.css.tab.load(tabId: string): Promise` + +Opens a file dialog and imports a CSS file for the tab. The same constraints +as `dmn.css.load()` apply; on failure `error` carries an error code string. + +### `dmn.css.tab.activateHistory(tabId: string, path: string): Promise` + +Applies a global history entry to the tab. The path must be in the history +(`PATH_NOT_AUTHORIZED` otherwise). + +```typescript +type TabCssActivateResult = { + success: boolean; + code?: CssHistoryErrorCode; + tabId: string; + css?: TabCss; +}; ``` -### `dmn.js.onChanged(callback): Unsubscribe` +### `dmn.css.tab.export(tabId: string): Promise` -Subscribes to JS changes. +Opens a save dialog and writes the tab's registered CSS content to a `.css` +file. -```javascript -const unsub = dmn.js.onChanged((js) => { - console.log("JS updated:", js); -}); +```typescript +type TabCssExportResult = { + success: boolean; + code?: "NO_TAB_CSS" | "IO_ERROR"; + error?: string; // details for IO_ERROR + path?: string; // saved location on success +}; ``` ---- +If the user cancels the dialog, `success` is `false` and `code` is omitted. -## Example Usage +### `dmn.css.tab.set(tabId: string, css: TabCss | null): Promise` + +Sets or removes (with `null`) the tab override directly. Only paths +previously authorized through a file dialog or history activation are +accepted: an unauthorized path is dropped and the override is stored as +content-only (`path: null` in the stored state and response). + +### `dmn.css.tab.toggle(tabId: string, enabled: boolean): Promise` + +Enables or disables the tab override without removing it. + +### `dmn.css.tab.clear(tabId: string): Promise` + +Removes the tab override, falling back to the global CSS. + +### `dmn.css.tab.onChanged(callback): Unsubscribe` + +Subscribes to tab override changes. The callback receives +`{ tabId, css: TabCss | null }`. + +## dmn.js + +JavaScript plugin state is `{ plugins: JsPlugin[] }` where each plugin is +`{ id, name, path, content, enabled }`. + +### `dmn.js.get(): Promise` + +Returns the current plugin list. + +### `dmn.js.getUse(): Promise` + +Returns whether JS plugins are enabled. + +### `dmn.js.toggle(enabled: boolean): Promise<{ enabled: boolean }>` + +Enables or disables JS plugins globally. + +### `dmn.js.load(): Promise` + +Opens a file dialog and adds the selected plugin files. + +```typescript +type JsLoadResult = { + success: boolean; + added: JsPlugin[]; + errors?: { path: string; error: string }[]; +}; +``` + +### `dmn.js.reload(): Promise` + +Re-reads all plugin files from disk and returns `{ updated, errors? }`. + +### `dmn.js.remove(id: string): Promise` + +Removes a plugin by id. + +### `dmn.js.setPluginEnabled(id: string, enabled: boolean): Promise` + +Enables or disables a single plugin. + +### `dmn.js.setContent(content: string): Promise` + +Replaces the inline JS content. + +### `dmn.js.reset(): Promise` + +Disables JS plugins and clears the plugin list. + +### `dmn.js.onUse(callback): Unsubscribe` + +Subscribes to enable/disable changes (`{ enabled: boolean }`). + +### `dmn.js.onState(callback): Unsubscribe` + +Subscribes to plugin list changes. The callback receives `CustomJs`. ```javascript -// CSS editor (main window) -if (dmn.window.type === "main") { - const cssEditor = document.createElement("textarea"); - cssEditor.style.cssText = - "width: 100%; height: 200px; font-family: monospace;"; - - // Load current CSS - dmn.css.get().then((css) => { - cssEditor.value = css; - }); - - // Watch changes - dmn.css.onChanged((css) => { - cssEditor.value = css; - }); - - // Save button - const saveBtn = document.createElement("button"); - saveBtn.textContent = "Save"; - saveBtn.onclick = () => { - dmn.css.set(cssEditor.value); - }; -} +const unsub = dmn.js.onState(({ plugins }) => { + console.log( + "plugins:", + plugins.map((p) => `${p.name}(${p.enabled ? "on" : "off"})`), + ); +}); ``` diff --git a/docs/content/en/custom-css/page.mdx b/docs/content/en/custom-css/page.mdx index ec90f103..d721fada 100644 --- a/docs/content/en/custom-css/page.mdx +++ b/docs/content/en/custom-css/page.mdx @@ -48,9 +48,20 @@ For counters: ## Applying CSS Files 1. Create a CSS file with your desired styles. -2. Go to the **Settings** tab. -3. Click **Select CSS File** in the **Custom CSS** section. -4. Select your CSS file. +2. Go to the **Settings** tab and click **Manage CSS**. +3. Turn on the **Enable** toggle, then click **Import CSS File**. +4. Select your CSS file (`.css`, up to 1 MiB). Previously imported files stay in the panel list, ready to re-apply with one click. + +## Per-Tab CSS + +Each key tab can override the global CSS with its own file. Right-click the grid and choose the tab CSS menu to open the popover: + +- **Import**: pick a CSS file that applies to the current tab only. +- **History list**: files already imported in the custom CSS panel appear below the file actions; click **Apply** on an entry to use it for this tab. +- **Export**: save the CSS currently registered on this tab back to a `.css` file. This is handy when the original file was moved or deleted, since the tab keeps its own copy of the content. +- The toggle at the top enables or disables the tab override without removing it. + +Tab CSS is applied only while the global **Custom CSS** toggle is on. ## Quick Start Example diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index b04091d3..1c2175e7 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -11,35 +11,39 @@ This page lists all CSS variables available for styling in DM Note. CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[data-state="active"]` selectors. -| Variable | Description | Type | Example | -| ------------------ | ------------------------ | ---------- | ------------------------ | -| `--key-radius` | Corner roundness | `` | `8px`, `50%` | -| `--key-bg` | Background color | `` | `#ff2b80` | -| `--key-bg-image` | Background image (gradient) | `` | `linear-gradient(90deg, #f0f, #0ff)` | -| `--key-border` | Border style | `` | `2px solid #fff`, `none` | -| `--key-text-color` | Text color | `` | `#ffffff` | -| `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | -| `--key-offset-y` | Y offset in active state | `` | `4px` | +| Variable | Description | Type | Example | +| --------------------- | -------------------------------------- | ---------- | ------------------------------------ | +| `--key-radius` | Corner roundness | `` | `8px`, `50%` | +| `--key-bg` | Background color and saved-image reset | `` | `#ff2b80` | +| `--key-bg-image` | Background image | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-border` | Border and saved border-ring reset | `` | `2px solid #fff`, `none` | +| `--key-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-padding` | Inner padding | `` | `0px`, `3px` | +| `--key-text-color` | Text color | `` | `#ffffff` | +| `--key-shadow` | Idle and shared fallback shadow | `` | `0 4px 10px rgba(0,0,0,.28)`, `none` | +| `--key-active-shadow` | Active shadow | `` | `0 3px 8px rgba(0,0,0,.32)`, `none` | +| `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | +| `--key-offset-y` | Y offset in active state | `` | `4px` | - `--key-bg` is applied as `background-color`. To use gradients, specify - `background` or `background-image` directly and set `--key-bg: transparent;` - if needed. When a key color is set to a gradient in the app, it renders as - `background-image: var(--key-bg-image, ...)`, so you can override it with - `--key-bg-image` (solid keys never get an inline `background-image`). + With **Inline Styles Priority** disabled, property-panel colors, gradients, + borders, typography, and shadows are low-priority defaults only. Setting + `--key-bg` automatically removes a saved background gradient. Setting + `--key-border` removes the saved gradient border ring and its spacing. Use + `--key-bg-image` or the standard `background` property for a custom gradient. ### Usage Example ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 8px; --key-bg: #2a2a2a; --key-border: 2px solid #444; --key-text-color: #888; } -[data-state="active"] { +[data-state='active'] { --key-radius: 8px; --key-bg: #ff2b80; --key-border: 2px solid #ff2b80; @@ -53,22 +57,36 @@ CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[d CSS variables applied to counter elements. Use with `.counter[data-counter-state="..."]` selectors. -| Variable | Description | Type | Example | -| ------------------------ | ---------------------- | ---------- | ------------------------ | -| `--counter-color` | Counter text color | `` | `#ffffff` | -| `--counter-stroke-color` | Text outline color | `` | `#000000`, `transparent` | -| `--counter-stroke-width` | Text outline thickness | `` | `1px`, `2px` | +| Variable | Description | Type | Example | +| --------------------------- | ------------------------------- | ---------- | ------------------------------------ | +| `--counter-color` | Text color and saved-fill reset | `` | `#ffffff` | +| `--counter-fill-image` | Text fill image | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--counter-fill-clip` | Fill clipping area | `` | `text`, `border-box` | +| `--counter-text-fill-color` | WebKit text fill color | `` | `transparent`, `currentcolor` | +| `--counter-stroke-color` | Text outline color | `` | `#000000`, `transparent` | +| `--counter-stroke-width` | Text outline thickness | `` | `1px`, `2px` | + +Setting only `--counter-color` also disables a counter fill gradient saved in +the app. To create a CSS fill gradient, set all three fill variables: + +```css +.counter { + --counter-fill-image: linear-gradient(90deg, #f0f, #0ff); + --counter-fill-clip: text; + --counter-text-fill-color: transparent; +} +``` ### Usage Example ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #666; --counter-stroke-color: transparent; --counter-stroke-width: 0px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #ff2b80; --counter-stroke-color: #fff; --counter-stroke-width: 1px; @@ -80,12 +98,14 @@ CSS variables applied to counter elements. Use with `.counter[data-counter-state CSS variables available for graph elements. Disable **Inline Styles Priority** on the graph to control these via CSS. -| Variable | Description | Type | Example | -| ---------------- | ---------------------- | ---------- | ------------------------ | -| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | Graph border | `` | `1px solid #7dd3fc` | -| `--graph-radius` | Graph corner radius | `` | `10px` | -| `--graph-color` | Line/bar drawing color | `` | `#86efac` | +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | Border and saved border-ring reset | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | Graph inner padding | `` | `0px`, `3px` | +| `--graph-radius` | Graph corner radius | `` | `10px` | +| `--graph-color` | Line/bar drawing color | `` | `#86efac` | ### Usage Example @@ -98,6 +118,31 @@ Disable **Inline Styles Priority** on the graph to control these via CSS. } ``` +## Knob Style Variables + +Disable **Inline Styles Priority** on a knob to use these variables. The custom +class is on the position wrapper, so target `.knob-class [data-knob-element]` +when applying standard properties directly to the visible surface. + +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--knob-bg` | Knob background | `` | `#222` | +| `--knob-border` | Border and saved border-ring reset | `` | `2px solid #fff`, `none` | +| `--knob-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--knob-padding` | Knob inner padding | `` | `0px`, `3px` | +| `--knob-radius` | Corner radius | `` | `50%`, `8px` | +| `--knob-shadow` | Knob shadow | `` | `0 4px 10px rgba(0,0,0,.3)` | +| `--knob-active-shadow` | Turning-state shadow | `` | `0 3px 8px rgba(0,0,0,.32)` | +| `--knob-indicator` | Center rotation indicator color | `` | `#fff` | + +```css +.volume-knob { + --knob-bg: #17171b; + --knob-border: none; + --knob-indicator: #ff2b80; +} +``` + ## Selector Reference ### Key Selectors @@ -120,21 +165,33 @@ Disable **Inline Styles Priority** on the graph to control these via CSS. ### Graph Selectors -| Selector | Description | -| ------------------------- | ------------------------------------------------ | -| `.graphClassName` | Style a specific graph container | -| `.graphClassName svg` | Style line graph SVG (stroke/fill) | -| `.graphClassName > div` | Style the inner plot area (line/bar shared) | -| `.graphClassName > div > div` | Style individual bars in bar mode | +| Selector | Description | +| ----------------------------- | ------------------------------------------- | +| `.graphClassName` | Style a specific graph container | +| `.graphClassName svg` | Style line graph SVG (stroke/fill) | +| `.graphClassName > div` | Style the inner plot area (line/bar shared) | +| `.graphClassName > div > div` | Style individual bars in bar mode | + +### Knob Selectors + +| Selector | Description | +| -------------------------------- | ------------------------------- | +| `[data-knob-element]` | Every visible knob surface | +| `[data-knob-state="inactive"]` | Idle knob surfaces | +| `[data-knob-state="active"]` | Active knob surfaces | +| `.knobClass [data-knob-element]` | A specific class's knob surface | +| `[data-knob-indicator]` | Center rotation indicator | ## Standard CSS Properties -In addition to CSS variables, you can freely use the following standard CSS properties: +With **Inline Styles Priority** disabled, you can directly use standard visual +properties such as the following. Element positioning remains app-managed; use +`--key-offset-x`/`--key-offset-y` to move keys, graphs, and knobs. ### Commonly Used Properties ```css -[data-state="active"] { +[data-state='active'] { /* Shadow effect */ box-shadow: 0px 0px 8px #ff2b80; @@ -147,10 +204,7 @@ In addition to CSS variables, you can freely use the following standard CSS prop /* Font style */ font-size: 24px; font-weight: 700; - font-family: "Roboto", sans-serif; - - /* Transform */ - transform: scale(1.05); + font-family: 'Roboto', sans-serif; /* Filter */ filter: brightness(1.2); @@ -161,18 +215,22 @@ In addition to CSS variables, you can freely use the following standard CSS prop ### Using !important -Use `!important` if some styles are not being applied: +Normal declarations lose to inline values only when **Inline Styles Priority** +is enabled. In that mode, override the rendered CSS property itself with +`!important`; adding it to a fallback custom property cannot beat an inline +property declaration: ```css -[data-state="active"] { - --key-bg: #ff2b80 !important; +[data-state='active'] { + background: #ff2b80 !important; box-shadow: 0px 0px 8px #ff2b80 !important; } ``` ### Browser Compatibility -DM Note runs on Chromium-based WebView2, supporting most modern CSS features: +DM Note uses WebView2 on Windows and WKWebView on macOS. You can use modern CSS +features supported by the WebView on the current operating system: - CSS Variables (Custom Properties) ✅ - CSS Grid / Flexbox ✅ @@ -193,17 +251,17 @@ DM Note runs on Chromium-based WebView2, supporting most modern CSS features: ```css /* Priority: 1 (lowest) */ -[data-state="active"] { +[data-state='active'] { --key-bg: red; } /* Priority: 2 */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: blue; } /* Priority: 3 (highest) */ -.blue.special[data-state="active"] { +.blue.special[data-state='active'] { --key-bg: purple; } ``` diff --git a/docs/content/en/guide/settings/page.mdx b/docs/content/en/guide/settings/page.mdx index 7caab6c0..4543913c 100644 --- a/docs/content/en/guide/settings/page.mdx +++ b/docs/content/en/guide/settings/page.mdx @@ -70,9 +70,16 @@ Select the reference point when resizing the overlay window: Load a custom CSS file to customize key and counter styles. -1. Click the **Select CSS File** button. -2. Select your CSS file. -3. Styles are applied immediately. +1. Click the **Manage CSS** button to open the custom CSS panel. +2. Turn on the **Enable** toggle at the top of the panel. +3. Click **Import CSS File** and select your CSS file (`.css`, up to 1 MiB). +4. Styles are applied immediately. + +The list in the panel keeps up to 10 previously imported CSS files. + +- Click the **Apply** button on an entry to switch to that file instantly, without a file dialog. +- Right-click an entry and choose **Remove from list** (the file itself is not deleted). +- Entries that can no longer be applied show a badge: **Missing** (moved or deleted), **Unusable** (not a regular `.css` file anymore), or **Too large** (over 1 MiB). For detailed CSS styling information, see the [Custom CSS @@ -83,9 +90,9 @@ Load a custom CSS file to customize key and counter styles. Load JavaScript plugins to extend program functionality. -1. Turn on the **Enable JS Plugins** toggle. -2. Click the **Manage Plugins** button. -3. Click **Add JS Plugin** to select a file. +1. Click the **Manage Plugins** button to open the panel. +2. Turn on the **Enable** toggle at the top of the panel. +3. Click **Add Plugins** to select a file. For detailed plugin development information, see [Getting diff --git a/docs/content/ko/api-reference/css-js/page.mdx b/docs/content/ko/api-reference/css-js/page.mdx index de8cbd8d..1ec09f12 100644 --- a/docs/content/ko/api-reference/css-js/page.mdx +++ b/docs/content/ko/api-reference/css-js/page.mdx @@ -3,218 +3,250 @@ title: CSS/JS 파일 description: 커스텀 CSS/JS 파일 관리 API --- -# CSS/JS 파일 +# CSS / JS API + +커스텀 CSS와 JavaScript 플러그인을 관리합니다. ## dmn.css -커스텀 CSS 파일 관리 API입니다. +전역 커스텀 CSS 상태는 `{ path: string | null, content: string }`입니다. -### get() +### `dmn.css.get(): Promise` -현재 CSS 설정을 조회합니다. +현재 커스텀 CSS(원본 파일 경로와 콘텐츠)를 반환합니다. ```javascript -const css = await dmn.css.get(); -console.log("내용:", css.content); -console.log("경로:", css.path); +const { path, content } = await dmn.css.get(); ``` -### getUse() +### `dmn.css.getUse(): Promise` -CSS 사용 여부만 조회합니다. (설정의 `useCustomCSS` 값) +커스텀 CSS 활성화 여부를 반환합니다. -```javascript -const enabled = await dmn.css.getUse(); -``` +### `dmn.css.toggle(enabled: boolean): Promise<{ enabled: boolean }>` -### toggle(enabled) +커스텀 CSS를 켜거나 끕니다. -CSS 사용 여부를 토글합니다. +### `dmn.css.load(): Promise` -```javascript -await dmn.css.toggle(true); // CSS 활성화 -await dmn.css.toggle(false); // CSS 비활성화 +파일 선택 창을 열어 CSS 파일을 불러옵니다. 파일은 일반 `.css` 파일이고 +UTF-8 인코딩이며 1MiB 이하여야 합니다. 성공적으로 불러온 파일은 +히스토리(최대 10개)에 추가됩니다. + +```typescript +type CssLoadResult = { + success: boolean; + error?: string; // 실패 시 오류 코드 (아래 표 참조) + content?: string; + path?: string; +}; ``` -### load() +파일 선택을 취소하면 `success`는 `false`이고 `error`는 생략됩니다. -외부 CSS 파일을 불러옵니다. 파일 선택 다이얼로그가 열립니다. +### `dmn.css.setContent(content: string): Promise` -```javascript -const result = await dmn.css.load(); -if (result.success) { - console.log("불러온 경로:", result.path); - console.log("내용:", result.content); -} -``` +CSS 콘텐츠를 직접 교체합니다 (원본 경로는 유지). 콘텐츠는 1MiB로 +제한됩니다. - - 커스텀 CSS가 활성화(`useCustomCSS = true`)된 상태에서 파일 경로가 설정돼 있으면, 파일 변경을 자동 감지하여 다시 적용합니다. - +### `dmn.css.reset(): Promise` -### setContent(content) +커스텀 CSS를 끄고 콘텐츠를 초기화합니다. 히스토리는 보존됩니다. -CSS 내용을 직접 설정합니다. +### `dmn.css.onUse(callback): Unsubscribe` + +활성화 상태 변경을 구독합니다. 콜백은 `{ enabled: boolean }`을 받습니다. + +### `dmn.css.onContent(callback): Unsubscribe` + +콘텐츠 변경(불러오기, 핫리로드, 직접 수정)을 구독합니다. 콜백은 +`CustomCss`를 받습니다. ```javascript -await dmn.css.setContent(` - [data-state="active"] { - --key-bg: #ff2b80; - --key-border: 2px solid #fff; - } -`); +const unsub = dmn.css.onContent(({ path, content }) => { + console.log("CSS 갱신:", path); +}); ``` -### reset() +## dmn.css 히스토리 -CSS를 기본값으로 초기화합니다. +앱은 이전에 불러온 CSS 파일을 최대 10개까지 기억합니다. 히스토리에 있는 +경로만 재활성화할 수 있으며, 임의 경로는 `PATH_NOT_AUTHORIZED`로 +거부됩니다. -```javascript -await dmn.css.reset(); +### `dmn.css.historyGet(): Promise` + +```typescript +type CustomCssHistoryItem = { + path: string; + lastUsedAt: number; // unix millis + status: "available" | "missing" | "invalid" | "tooLarge"; +}; ``` -### 이벤트 구독 +`status`는 참고용입니다. 조회 시점의 간단한 파일 검사 결과이며, 최종 +검증은 활성화 시점에 수행됩니다. -```javascript -// CSS 활성화 상태 변경 -const unsub1 = dmn.css.onUse(({ enabled }) => { - console.log("CSS 활성화:", enabled); -}); +### `dmn.css.historyActivate(path: string): Promise` -// CSS 내용 변경 -const unsub2 = dmn.css.onContent((css) => { - console.log("CSS 변경됨:", css.content); -}); +파일 선택 창 없이 히스토리 항목으로 전역 커스텀 CSS를 전환합니다. + +```typescript +type CssActivateResult = { + success: boolean; + code?: CssHistoryErrorCode; // 실패 시 + content?: string; + path?: string; +}; ``` -### 탭별 CSS (css.tab) +### `dmn.css.historyRemove(path: string): Promise` -모드/탭별로 개별 CSS를 적용할 수 있습니다. +히스토리에서 항목을 제거하고 (파일 자체는 건드리지 않음) 갱신된 목록을 +반환합니다. -```javascript -// 모든 탭 CSS 조회 -const allTabCss = await dmn.css.tab.getAll(); +### 오류 코드 -// 특정 탭 CSS 조회 -const tab4key = await dmn.css.tab.get("4key"); +| 코드 | 의미 | +| --- | --- | +| `PATH_NOT_AUTHORIZED` | 히스토리에 없는 경로 | +| `NOT_FOUND` | 파일이 존재하지 않음 | +| `NOT_REGULAR_FILE` | 일반 파일이 아님 | +| `INVALID_EXTENSION` | `.css` 파일이 아님 | +| `TOO_LARGE` | 1MiB 초과 | +| `INVALID_UTF8` | 유효한 UTF-8이 아님 | +| `IO_ERROR` | 기타 I/O 실패 | -// 탭 CSS 파일 불러오기 -const result = await dmn.css.tab.load("4key"); +## dmn.css.tab -// 탭 CSS 설정 -await dmn.css.tab.set("4key", { - use: true, - content: "[data-state='active'] { --key-bg: red; }", -}); +탭별 CSS는 특정 키 탭에서 전역 CSS를 덮어씁니다. 탭 오버라이드는 +`{ path: string | null, content: string, enabled: boolean }`입니다. 탭 +CSS는 전역 커스텀 CSS 토글이 켜져 있을 때만 렌더링됩니다. -// 탭 CSS 토글 -await dmn.css.tab.toggle("4key", true); +### `dmn.css.tab.getAll(): Promise` -// 탭 CSS 초기화 -await dmn.css.tab.clear("4key"); +모든 오버라이드를 `Record`로 반환합니다. -// 변경 이벤트 -const unsub = dmn.css.tab.onChanged(({ tabId, css }) => { - console.log(`${tabId} CSS 변경:`, css); -}); -``` +### `dmn.css.tab.get(tabId: string): Promise` -## dmn.js +`{ tabId, css: TabCss | null }`을 반환합니다. -커스텀 JS(플러그인) 파일 관리 API입니다. +### `dmn.css.tab.load(tabId: string): Promise` -### get() +파일 선택 창을 열어 해당 탭용 CSS 파일을 불러옵니다. `dmn.css.load()`와 +같은 제약이 적용되며, 실패 시 `error`에 오류 코드 문자열이 담깁니다. -현재 JS 플러그인 상태를 조회합니다. +### `dmn.css.tab.activateHistory(tabId: string, path: string): Promise` -```javascript -const js = await dmn.js.get(); -console.log("플러그인 목록:", js.plugins); +전역 히스토리 항목을 탭에 적용합니다. 경로는 히스토리에 있어야 하며, +없으면 `PATH_NOT_AUTHORIZED`로 거부됩니다. + +```typescript +type TabCssActivateResult = { + success: boolean; + code?: CssHistoryErrorCode; + tabId: string; + css?: TabCss; +}; ``` -### getUse() +### `dmn.css.tab.export(tabId: string): Promise` -JS 플러그인 사용 여부만 조회합니다. (설정의 `useCustomJS` 값) +저장 창을 열어 탭에 등록된 CSS 콘텐츠를 `.css` 파일로 저장합니다. -```javascript -const enabled = await dmn.js.getUse(); +```typescript +type TabCssExportResult = { + success: boolean; + code?: "NO_TAB_CSS" | "IO_ERROR"; + error?: string; // IO_ERROR 상세 + path?: string; // 성공 시 저장 위치 +}; ``` -### toggle(enabled) +저장 창을 취소하면 `success`는 `false`이고 `code`는 생략됩니다. -JS 플러그인 사용 여부를 토글합니다. +### `dmn.css.tab.set(tabId: string, css: TabCss | null): Promise` -```javascript -await dmn.js.toggle(true); // 플러그인 활성화 -await dmn.js.toggle(false); // 플러그인 비활성화 -``` +탭 오버라이드를 직접 설정하거나 (`null`로) 제거합니다. 파일 선택 창이나 +히스토리 활성화로 이전에 인가된 경로만 허용됩니다. 인가되지 않은 경로는 +제거되어 content만 저장되며, 저장 상태와 응답 모두 `path: null`이 됩니다. -### load() +### `dmn.css.tab.toggle(tabId: string, enabled: boolean): Promise` -외부 JS 파일을 불러옵니다. 파일 선택 다이얼로그가 열립니다. +탭 오버라이드를 제거하지 않고 켜거나 끕니다. -```javascript -const result = await dmn.js.load(); -if (result.success) { - console.log("추가된 플러그인:", result.added); -} -``` +### `dmn.css.tab.clear(tabId: string): Promise` -### reload() +탭 오버라이드를 제거하고 전역 CSS로 되돌립니다. -모든 플러그인을 다시 로드합니다. +### `dmn.css.tab.onChanged(callback): Unsubscribe` -```javascript -const result = await dmn.js.reload(); -console.log("업데이트된 플러그인:", result.updated); -``` +탭 오버라이드 변경을 구독합니다. 콜백은 `{ tabId, css: TabCss | null }`을 +받습니다. -### remove(id) +## dmn.js -특정 플러그인을 제거합니다. +JavaScript 플러그인 상태는 `{ plugins: JsPlugin[] }`이며 각 플러그인은 +`{ id, name, path, content, enabled }`입니다. -```javascript -await dmn.js.remove("my-plugin-id"); -``` +### `dmn.js.get(): Promise` -### setPluginEnabled(id, enabled) +현재 플러그인 목록을 반환합니다. -특정 플러그인의 활성화 상태를 변경합니다. +### `dmn.js.getUse(): Promise` -```javascript -await dmn.js.setPluginEnabled("kps", true); // 활성화 -await dmn.js.setPluginEnabled("kps", false); // 비활성화 -``` +JS 플러그인 활성화 여부를 반환합니다. -### setContent(content) +### `dmn.js.toggle(enabled: boolean): Promise<{ enabled: boolean }>` -JS 파일 내용을 직접 설정합니다. +JS 플러그인 전체를 켜거나 끕니다. -```javascript -await dmn.js.setContent(`// @id: inline-plugin +### `dmn.js.load(): Promise` + +파일 선택 창을 열어 선택한 플러그인 파일들을 추가합니다. -console.log("hello from inline plugin"); -`); +```typescript +type JsLoadResult = { + success: boolean; + added: JsPlugin[]; + errors?: { path: string; error: string }[]; +}; ``` -### reset() +### `dmn.js.reload(): Promise` -JS 설정(경로/내용/플러그인 목록)을 기본값으로 초기화합니다. +모든 플러그인 파일을 디스크에서 다시 읽고 `{ updated, errors? }`를 +반환합니다. -```javascript -await dmn.js.reset(); -``` +### `dmn.js.remove(id: string): Promise` -### 이벤트 구독 +id로 플러그인을 제거합니다. -```javascript -// JS 플러그인 활성화 상태 변경 -const unsub1 = dmn.js.onUse(({ enabled }) => { - console.log("JS 플러그인 활성화:", enabled); -}); +### `dmn.js.setPluginEnabled(id: string, enabled: boolean): Promise` + +개별 플러그인을 켜거나 끕니다. + +### `dmn.js.setContent(content: string): Promise` + +인라인 JS 콘텐츠를 교체합니다. + +### `dmn.js.reset(): Promise` -// JS 플러그인 상태 변경 -const unsub2 = dmn.js.onState((js) => { - console.log("플러그인 목록:", js.plugins); +JS 플러그인을 끄고 플러그인 목록을 초기화합니다. + +### `dmn.js.onUse(callback): Unsubscribe` + +활성화 상태 변경을 구독합니다 (`{ enabled: boolean }`). + +### `dmn.js.onState(callback): Unsubscribe` + +플러그인 목록 변경을 구독합니다. 콜백은 `CustomJs`를 받습니다. + +```javascript +const unsub = dmn.js.onState(({ plugins }) => { + console.log( + "plugins:", + plugins.map((p) => `${p.name}(${p.enabled ? "on" : "off"})`), + ); }); ``` diff --git a/docs/content/ko/custom-css/page.mdx b/docs/content/ko/custom-css/page.mdx index e4d51f3e..1eb6d12e 100644 --- a/docs/content/ko/custom-css/page.mdx +++ b/docs/content/ko/custom-css/page.mdx @@ -47,9 +47,20 @@ DM Note는 CSS 변수(Custom Properties)를 사용하여 스타일을 제어합 ## CSS 파일 적용하기 1. 원하는 스타일로 CSS 파일을 작성합니다. -2. **Settings** 탭으로 이동합니다. -3. **커스텀 CSS** 섹션에서 **CSS 파일 선택**을 클릭합니다. -4. 작성한 CSS 파일을 선택합니다. +2. **Settings** 탭으로 이동해 **CSS 관리** 버튼을 클릭합니다. +3. **활성화** 토글을 켜고 **CSS 파일 불러오기**를 클릭합니다. +4. 작성한 CSS 파일을 선택합니다 (`.css`, 최대 1MiB). 이전에 불러온 파일은 패널 목록에 남아 한 번의 클릭으로 다시 적용할 수 있습니다. + +## 탭별 CSS + +키 탭마다 전역 CSS를 덮어쓰는 전용 파일을 둘 수 있습니다. 그리드를 우클릭하고 탭 CSS 메뉴를 선택하면 팝오버가 열립니다: + +- **불러오기**: 현재 탭에만 적용할 CSS 파일을 선택합니다. +- **히스토리 목록**: 커스텀 CSS 패널에서 불러왔던 파일이 파일 액션 아래에 표시되며, 항목의 **적용** 버튼으로 이 탭에 바로 적용합니다. +- **내보내기**: 현재 탭에 등록된 CSS를 `.css` 파일로 저장합니다. 탭이 콘텐츠 사본을 보관하므로 원본 파일이 이동·삭제된 경우에도 복구할 수 있습니다. +- 상단 토글로 탭 오버라이드를 제거하지 않고 켜고 끌 수 있습니다. + +탭 CSS는 전역 **커스텀 CSS** 토글이 켜져 있을 때만 적용됩니다. ## 빠른 시작 예제 diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx index 2bc30022..bc6d8129 100644 --- a/docs/content/ko/custom-css/variables/page.mdx +++ b/docs/content/ko/custom-css/variables/page.mdx @@ -11,35 +11,39 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 키 요소에 적용되는 CSS 변수입니다. `[data-state="inactive"]` 또는 `[data-state="active"]` 선택자와 함께 사용합니다. -| 변수 | 설명 | 타입 | 예시 | -| ------------------ | ------------------------ | ---------- | ------------------------ | -| `--key-radius` | 키 모서리의 둥근 정도 | `` | `8px`, `50%` | -| `--key-bg` | 키의 배경색 | `` | `#ff2b80` | -| `--key-bg-image` | 키의 배경 이미지(그라데이션) | `` | `linear-gradient(90deg, #f0f, #0ff)` | -| `--key-border` | 키의 테두리 스타일 | `` | `2px solid #fff`, `none` | -| `--key-text-color` | 키 텍스트 색상 | `` | `#ffffff` | -| `--key-offset-x` | 활성 상태에서 X축 이동량 | `` | `0px`, `2px` | -| `--key-offset-y` | 활성 상태에서 Y축 이동량 | `` | `4px` | +| 변수 | 설명 | 타입 | 예시 | +| --------------------- | ----------------------------------- | ---------- | ------------------------------------ | +| `--key-radius` | 키 모서리의 둥근 정도 | `` | `8px`, `50%` | +| `--key-bg` | 키 배경색과 저장된 배경 이미지 해제 | `` | `#ff2b80` | +| `--key-bg-image` | 키의 배경 이미지 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-border` | 키 테두리와 저장된 보더 링 해제 | `` | `2px solid #fff`, `none` | +| `--key-border-image` | 저장된 보더 링의 이미지 교체 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-padding` | 키 내부 여백 | `` | `0px`, `3px` | +| `--key-text-color` | 키 텍스트 색상 | `` | `#ffffff` | +| `--key-shadow` | 대기 및 공통 폴백 그림자 | `` | `0 4px 10px rgba(0,0,0,.28)`, `none` | +| `--key-active-shadow` | 입력 상태 그림자 | `` | `0 3px 8px rgba(0,0,0,.32)`, `none` | +| `--key-offset-x` | 활성 상태에서 X축 이동량 | `` | `0px`, `2px` | +| `--key-offset-y` | 활성 상태에서 Y축 이동량 | `` | `4px` | - `--key-bg`는 `background-color`로 적용됩니다. 그라데이션을 사용하려면 - `background` 또는 `background-image`를 직접 지정하고, 필요하면 `--key-bg: - transparent;`로 설정하세요. 앱에서 키 색상을 그라데이션으로 설정한 경우에는 - `background-image: var(--key-bg-image, ...)`로 렌더되므로 `--key-bg-image`로 - 덮어쓸 수 있습니다 (단색 키에는 인라인 `background-image`가 붙지 않습니다). + **인라인 스타일 우선**이 꺼져 있으면 속성 패널의 색·그라데이션·보더·글꼴· + 그림자는 낮은 우선순위의 기본값으로만 적용됩니다. `--key-bg`를 지정하면 저장된 + 배경 그라데이션이 자동으로 사라지고, `--key-border`를 지정하면 저장된 + 그라데이션 보더 링과 링 여백이 함께 사라집니다. 사용자 그라데이션은 + `--key-bg-image`나 표준 `background` 속성으로 지정할 수 있습니다. ### 사용 예시 ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 8px; --key-bg: #2a2a2a; --key-border: 2px solid #444; --key-text-color: #888; } -[data-state="active"] { +[data-state='active'] { --key-radius: 8px; --key-bg: #ff2b80; --key-border: 2px solid #ff2b80; @@ -53,22 +57,36 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 카운터 요소에 적용되는 CSS 변수입니다. `.counter[data-counter-state="..."]` 선택자와 함께 사용합니다. -| 변수 | 설명 | 타입 | 예시 | -| ------------------------ | ------------------ | ---------- | ------------------------ | -| `--counter-color` | 카운터 텍스트 색상 | `` | `#ffffff` | -| `--counter-stroke-color` | 텍스트 외곽선 색상 | `` | `#000000`, `transparent` | -| `--counter-stroke-width` | 텍스트 외곽선 두께 | `` | `1px`, `2px` | +| 변수 | 설명 | 타입 | 예시 | +| --------------------------- | ------------------------------ | ---------- | ------------------------------------ | +| `--counter-color` | 텍스트 색상과 저장된 fill 해제 | `` | `#ffffff` | +| `--counter-fill-image` | 텍스트 fill 이미지 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--counter-fill-clip` | fill 클립 영역 | `` | `text`, `border-box` | +| `--counter-text-fill-color` | WebKit 텍스트 채움색 | `` | `transparent`, `currentcolor` | +| `--counter-stroke-color` | 텍스트 외곽선 색상 | `` | `#000000`, `transparent` | +| `--counter-stroke-width` | 텍스트 외곽선 두께 | `` | `1px`, `2px` | + +`--counter-color`만 지정해도 앱에 저장된 카운터 fill 그라데이션은 자동으로 +해제됩니다. CSS로 새 fill 그라데이션을 만들 때는 다음 세 변수를 함께 사용합니다. + +```css +.counter { + --counter-fill-image: linear-gradient(90deg, #f0f, #0ff); + --counter-fill-clip: text; + --counter-text-fill-color: transparent; +} +``` ### 사용 예시 ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #666; --counter-stroke-color: transparent; --counter-stroke-width: 0px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #ff2b80; --counter-stroke-color: #fff; --counter-stroke-width: 1px; @@ -80,12 +98,14 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 그래프 요소에 적용되는 CSS 변수입니다. 그래프의 **인라인 스타일 우선**을 끄면 아래 변수를 통해 외형을 제어할 수 있습니다. -| 변수 | 설명 | 타입 | 예시 | -| ---------------- | ------------- | ---------- | ------------------------ | -| `--graph-bg` | 그래프 배경 | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | 그래프 테두리 | `` | `1px solid #7dd3fc` | -| `--graph-radius` | 그래프 라운딩 | `` | `10px` | -| `--graph-color` | 라인/바 색상 | `` | `#86efac` | +| 변수 | 설명 | 타입 | 예시 | +| ---------------------- | ----------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | 그래프 배경 | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | 그래프 테두리와 저장된 보더 링 해제 | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | 저장된 보더 링의 이미지 교체 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | 그래프 내부 여백 | `` | `0px`, `3px` | +| `--graph-radius` | 그래프 라운딩 | `` | `10px` | +| `--graph-color` | 라인/바 색상 | `` | `#86efac` | ### 사용 예시 @@ -98,6 +118,31 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 } ``` +## 노브 스타일 변수 + +노브의 **인라인 스타일 우선**을 끄면 아래 변수를 사용할 수 있습니다. 노브에 +지정한 클래스는 위치 래퍼에 있으므로 일반 속성을 직접 지정할 때는 +`.노브클래스 [data-knob-element]` 선택자를 사용하세요. + +| 변수 | 설명 | 타입 | 예시 | +| ---------------------- | --------------------------------- | ---------- | ------------------------------------ | +| `--knob-bg` | 노브 배경 | `` | `#222` | +| `--knob-border` | 노브 테두리와 저장된 보더 링 해제 | `` | `2px solid #fff`, `none` | +| `--knob-border-image` | 저장된 보더 링의 이미지 교체 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--knob-padding` | 노브 내부 여백 | `` | `0px`, `3px` | +| `--knob-radius` | 노브 모서리 | `` | `50%`, `8px` | +| `--knob-shadow` | 노브 그림자 | `` | `0 4px 10px rgba(0,0,0,.3)` | +| `--knob-active-shadow` | 회전 중 그림자 | `` | `0 3px 8px rgba(0,0,0,.32)` | +| `--knob-indicator` | 중앙 회전 표시 색상 | `` | `#fff` | + +```css +.volume-knob { + --knob-bg: #17171b; + --knob-border: none; + --knob-indicator: #ff2b80; +} +``` + ## 선택자 레퍼런스 ### 키 선택자 @@ -120,21 +165,33 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 ### 그래프 선택자 -| 선택자 | 설명 | -| ----------------------- | ------------------------------------- | -| `.그래프클래스명` | 특정 그래프 컨테이너 스타일링 | -| `.그래프클래스명 svg` | 라인 그래프 SVG(선/채움) 스타일링 | -| `.그래프클래스명 > div` | 그래프 내부 플롯 영역(라인/바 공통) | +| 선택자 | 설명 | +| ----------------------------- | --------------------------------------- | +| `.그래프클래스명` | 특정 그래프 컨테이너 스타일링 | +| `.그래프클래스명 svg` | 라인 그래프 SVG(선/채움) 스타일링 | +| `.그래프클래스명 > div` | 그래프 내부 플롯 영역(라인/바 공통) | | `.그래프클래스명 > div > div` | 바 그래프 막대 개별 스타일링 (bar 모드) | +### 노브 선택자 + +| 선택자 | 설명 | +| --------------------------------- | ------------------------ | +| `[data-knob-element]` | 모든 노브 표면 | +| `[data-knob-state="inactive"]` | 입력 대기 중인 노브 표면 | +| `[data-knob-state="active"]` | 입력 중인 노브 표면 | +| `.노브클래스 [data-knob-element]` | 특정 클래스의 노브 표면 | +| `[data-knob-indicator]` | 노브 중앙 회전 표시 | + ## 표준 CSS 속성 -CSS 변수 외에도 다음 표준 CSS 속성들을 자유롭게 사용할 수 있습니다: +**인라인 스타일 우선**이 꺼진 요소에는 다음과 같은 표준 외형 속성을 직접 사용할 +수 있습니다. 위치는 앱이 관리하므로 키·그래프·노브 이동에는 +`--key-offset-x`/`--key-offset-y`를 사용하세요. ### 자주 사용되는 속성 ```css -[data-state="active"] { +[data-state='active'] { /* 그림자 효과 */ box-shadow: 0px 0px 8px #ff2b80; @@ -147,10 +204,7 @@ CSS 변수 외에도 다음 표준 CSS 속성들을 자유롭게 사용할 수 /* 폰트 스타일 */ font-size: 24px; font-weight: 700; - font-family: "Roboto", sans-serif; - - /* 변형 */ - transform: scale(1.05); + font-family: 'Roboto', sans-serif; /* 필터 */ filter: brightness(1.2); @@ -161,18 +215,21 @@ CSS 변수 외에도 다음 표준 CSS 속성들을 자유롭게 사용할 수 ### !important 사용 -일부 스타일이 적용되지 않는 경우 `!important`를 사용할 수 있습니다: +요소의 **인라인 스타일 우선**을 켠 경우에만 일반 선언보다 인라인 값이 우선합니다. +이 모드까지 강제로 덮어쓸 때는 실제 렌더 CSS 속성 자체에 `!important`를 사용해야 +합니다. 폴백 커스텀 속성에 붙여도 인라인 속성 선언보다 우선할 수 없습니다: ```css -[data-state="active"] { - --key-bg: #ff2b80 !important; +[data-state='active'] { + background: #ff2b80 !important; box-shadow: 0px 0px 8px #ff2b80 !important; } ``` ### 브라우저 호환성 -DM Note는 Chromium 기반 WebView2에서 동작하므로 최신 CSS 기능을 대부분 지원합니다: +DM Note는 Windows의 WebView2와 macOS의 WKWebView에서 동작합니다. 운영체제별 +WebView가 지원하는 최신 CSS 기능을 사용할 수 있습니다: - CSS Variables (Custom Properties) ✅ - CSS Grid / Flexbox ✅ @@ -182,8 +239,8 @@ DM Note는 Chromium 기반 WebView2에서 동작하므로 최신 CSS 기능을 - mix-blend-mode ✅ - 단, 오버레이의 배경이 투명(`transparent`) 상태일 때는 `backdrop-filter`가 동작하지 - 않습니다. + 단, 오버레이의 배경이 투명(`transparent`) 상태일 때는 `backdrop-filter`가 + 동작하지 않습니다. ### 상속과 우선순위 @@ -194,17 +251,17 @@ DM Note는 Chromium 기반 WebView2에서 동작하므로 최신 CSS 기능을 ```css /* 우선순위: 1 (가장 낮음) */ -[data-state="active"] { +[data-state='active'] { --key-bg: red; } /* 우선순위: 2 */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: blue; } /* 우선순위: 3 (가장 높음) */ -.blue.special[data-state="active"] { +.blue.special[data-state='active'] { --key-bg: purple; } ``` diff --git a/docs/content/ko/guide/settings/page.mdx b/docs/content/ko/guide/settings/page.mdx index 8ac8142c..324100a6 100644 --- a/docs/content/ko/guide/settings/page.mdx +++ b/docs/content/ko/guide/settings/page.mdx @@ -69,9 +69,16 @@ DM Note의 다양한 설정 옵션을 안내합니다. 사용자 정의 CSS 파일을 로드하여 키와 카운터의 스타일을 커스터마이징합니다. -1. **CSS 파일 선택** 버튼을 클릭합니다. -2. CSS 파일을 선택합니다. -3. 스타일이 즉시 적용됩니다. +1. **CSS 관리** 버튼을 클릭해 커스텀 CSS 관리 패널을 엽니다. +2. 패널 상단의 **활성화** 토글을 켭니다. +3. **CSS 파일 불러오기** 버튼으로 CSS 파일을 선택합니다 (`.css`, 최대 1MiB). +4. 스타일이 즉시 적용됩니다. + +관리 패널의 목록에는 이전에 불러온 CSS 파일이 최대 10개까지 남습니다. + +- 항목의 **적용** 버튼을 누르면 파일 선택 창 없이 해당 파일로 바로 전환됩니다. +- 항목을 우클릭해 **목록에서 제거**를 선택할 수 있습니다 (파일 자체는 삭제되지 않습니다). +- 적용할 수 없게 된 항목에는 배지가 붙습니다: **파일 없음**(이동·삭제됨), **사용 불가**(더 이상 일반 `.css` 파일이 아님), **용량 초과**(1MiB 초과). CSS 스타일링에 대한 자세한 내용은 [커스텀 CSS 가이드](/docs/custom-css)를 참조하세요. @@ -81,9 +88,9 @@ DM Note의 다양한 설정 옵션을 안내합니다. JavaScript 플러그인을 로드하여 프로그램 기능을 확장합니다. -1. **JS 플러그인 활성화** 토글을 켭니다. -2. **플러그인 관리** 버튼을 클릭합니다. -3. **JS 플러그인 추가** 버튼으로 파일을 선택합니다. +1. **플러그인 관리** 버튼을 클릭해 관리 패널을 엽니다. +2. 패널 상단의 **활성화** 토글을 켭니다. +3. **플러그인 추가** 버튼으로 파일을 선택합니다. 플러그인 개발에 대한 자세한 내용은 [시작하기](/docs/getting-started)를 참조하세요. diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 8196b09e..8bbcbf56 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_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","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","editor_commit","editor_get","editor_history_restore","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_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","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","keys_update_with_positions","knob_positions_get","knob_positions_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_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","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_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_get","css_get_use","css_history_activate","css_history_get","css_history_remove","css_load","css_reset","css_set_content","css_tab_activate_history","css_tab_clear","css_tab_export","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","editor_commit","editor_get","editor_history_restore","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_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","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","keys_update_with_positions","knob_positions_get","knob_positions_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_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index 4c062d65..6c70500e 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -19,10 +19,15 @@ "counter_animation_update", "css_get", "css_get_use", + "css_history_activate", + "css_history_get", + "css_history_remove", "css_load", "css_reset", "css_set_content", + "css_tab_activate_history", "css_tab_clear", + "css_tab_export", "css_tab_get", "css_tab_get_all", "css_tab_load", diff --git a/src-tauri/src/commands/editor/css.rs b/src-tauri/src/commands/editor/css.rs index 2b90ad74..fd770087 100644 --- a/src-tauri/src/commands/editor/css.rs +++ b/src-tauri/src/commands/editor/css.rs @@ -1,23 +1,30 @@ -use std::fs; +use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; use rfd::FileDialog; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use crate::{ + commands::editor::state::emit_best_effort, + custom_css::{ + custom_css_settings_diff, history_paths_match, inspect_css_history_status, + normalize_custom_css_history, record_custom_css_load, touch_custom_css_history, + validate_css_path, CssHistoryErrorCode, CssPathError, CustomCssHistoryStatus, + ValidatedCssFile, MAX_CUSTOM_CSS_BYTES, + }, + defaults::default_keys, errors::CmdResult, - models::{CustomCss, TabCss, TabCssOverrides}, - state::AppState, + models::{AppStoreData, CustomCss, CustomCssHistoryEntry, TabCss, TabCssOverrides}, + state::{atomic_file::atomic_replace, 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); + state.notify_obs_settings_diff(custom_css_settings_diff(&snap)); } #[derive(Serialize)] @@ -43,6 +50,106 @@ pub struct CssLoadResponse { pub path: Option, } +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CustomCssHistoryItem { + pub path: String, + pub last_used_at: i64, + pub status: CustomCssHistoryStatus, +} + +#[derive(Serialize)] +pub struct CssActivateResponse { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +fn history_items(entries: &[CustomCssHistoryEntry]) -> Vec { + entries + .iter() + .map(|entry| CustomCssHistoryItem { + path: entry.path.clone(), + last_used_at: entry.last_used_at, + status: inspect_css_history_status(Path::new(&entry.path)), + }) + .collect() +} + +fn has_history_path(entries: &[CustomCssHistoryEntry], path: &str) -> bool { + entries + .iter() + .any(|entry| history_paths_match(&entry.path, path)) +} + +fn replace_tab_css_override(store: &mut AppStoreData, tab_id: &str, css: Option) { + if let Some(css) = css { + store.tab_css_overrides.insert(tab_id.to_string(), css); + } else { + store.tab_css_overrides.remove(tab_id); + } +} + +fn current_unix_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + .unwrap_or(0) +} + +fn log_css_rejection(operation: &str, path: &str, error: &CssPathError) { + log::warn!( + "[{operation}] Rejected CSS path code={} path={} detail={}", + error.code.as_str(), + path, + error.detail + ); +} + +fn activate_failure(code: CssHistoryErrorCode, path: String) -> CssActivateResponse { + CssActivateResponse { + success: false, + code: Some(code), + content: None, + path: Some(path), + } +} + +// touch_history: 새 파일 불러오기만 히스토리 맨 앞에 추가. +// 히스토리 항목 적용은 순서를 바꾸지 않음 (목록 순서 고정) +fn commit_loaded_css( + state: &AppState, + loaded: &ValidatedCssFile, + touch_history: bool, +) -> CmdResult<(CustomCss, bool)> { + let css = CustomCss { + path: Some(loaded.canonical_path.clone()), + content: loaded.content.clone(), + }; + let timestamp = current_unix_millis(); + let updated = state.store.update(|store| { + store.custom_css = css.clone(); + if touch_history { + record_custom_css_load( + &mut store.custom_css_history, + loaded.canonical_path.clone(), + timestamp, + ); + } else { + touch_custom_css_history( + &mut store.custom_css_history, + &loaded.canonical_path, + timestamp, + ); + } + })?; + Ok((css, updated.use_custom_css)) +} + // ========== 탭별 CSS 응답 타입 ========== #[derive(Serialize, Clone)] @@ -87,6 +194,36 @@ pub struct TabCssSetResponse { pub css: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TabCssActivateResponse { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + pub tab_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub css: Option, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TabCssExportErrorCode { + NoTabCss, + IoError, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TabCssExportResponse { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + #[tauri::command] pub fn css_get(state: State<'_, AppState>) -> CmdResult { Ok(state.store.snapshot().custom_css) @@ -103,43 +240,41 @@ pub fn css_toggle( app: AppHandle, enabled: bool, ) -> CmdResult { + let _operation_guard = state.lock_css_operation(); state.store.update(|store| { store.use_custom_css = enabled; })?; - - app.emit("css:use", &CssToggleResponse { enabled })?; - + let css = state.store.snapshot().custom_css; if enabled { - let css = state.store.snapshot().custom_css; - app.emit("css:content", &css)?; - - // CSS 핫리로딩: 활성화 시 파일 워칭 시작 + state.unwatch_global_css(); if let Some(path) = &css.path { if let Err(err) = state.watch_global_css(path) { log::warn!("[css_toggle] Failed to start watching: {}", err); } } } else { - // CSS 핫리로딩: 비활성화 시 워칭 중지 state.unwatch_global_css(); } + emit_best_effort(&app, "css:use", &CssToggleResponse { enabled }); + if enabled { + emit_best_effort(&app, "css:content", &css); + } notify_obs_css(&state); Ok(CssToggleResponse { enabled }) } #[tauri::command] pub fn css_reset(state: State<'_, AppState>, app: AppHandle) -> CmdResult<()> { - // CSS 핫리로딩: 전역 CSS 워칭 중지 - state.unwatch_global_css(); - + let _operation_guard = state.lock_css_operation(); state.store.update(|store| { store.use_custom_css = false; store.custom_css = CustomCss::default(); })?; + state.unwatch_global_css(); - app.emit("css:use", &CssToggleResponse { enabled: false })?; - app.emit("css:content", &CustomCss::default())?; + emit_best_effort(&app, "css:use", &CssToggleResponse { enabled: false }); + emit_best_effort(&app, "css:content", &CustomCss::default()); notify_obs_css(&state); Ok(()) @@ -151,14 +286,21 @@ pub fn css_set_content( app: AppHandle, content: String, ) -> CmdResult { + let _operation_guard = state.lock_css_operation(); + if content.len() as u64 > MAX_CUSTOM_CSS_BYTES { + return Ok(CssSetContentResponse { + success: false, + error: Some(CssHistoryErrorCode::TooLarge.as_str().to_string()), + }); + } let mut current = state.store.snapshot().custom_css; - current.content = content.clone(); + current.content = content; state.store.update(|store| { store.custom_css = current.clone(); })?; - app.emit("css:content", ¤t)?; + emit_best_effort(&app, "css:content", ¤t); notify_obs_css(&state); Ok(CssSetContentResponse { @@ -180,44 +322,121 @@ pub fn css_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult { - // 이전 파일 워칭 중지 - state.unwatch_global_css(); + let selected_path = path.to_string_lossy().to_string(); + let _operation_guard = state.lock_css_operation(); + let loaded = match validate_css_path(&path) { + Ok(loaded) => loaded, + Err(error) => { + log_css_rejection("css_load", &selected_path, &error); + return Ok(CssLoadResponse { + success: false, + error: Some(error.code.as_str().to_string()), + content: None, + path: Some(selected_path), + }); + } + }; + let (css, use_custom_css) = commit_loaded_css(&state, &loaded, true)?; + state.authorize_css_path(&loaded.canonical_path); + state.unwatch_global_css(); + if use_custom_css { + if let Err(error) = state.watch_global_css(&loaded.canonical_path) { + log::warn!("[css_load] Failed to start watching: {error}"); + } + } + emit_best_effort(&app, "css:content", &css); + notify_obs_css(&state); - let css = CustomCss { - path: Some(path_string.clone()), - content: content.clone(), - }; - state.store.update(|store| { - store.custom_css = css.clone(); - })?; + Ok(CssLoadResponse { + success: true, + error: None, + content: Some(loaded.content), + path: Some(loaded.canonical_path), + }) +} - app.emit("css:content", &css)?; +#[tauri::command] +pub fn css_history_get(state: State<'_, AppState>) -> CmdResult> { + Ok(history_items(&state.store.snapshot().custom_css_history)) +} - // CSS 핫리로딩: 새 파일 워칭 시작 (use_custom_css가 활성화된 경우에만) - if state.store.snapshot().use_custom_css { - if let Err(err) = state.watch_global_css(&path_string) { - log::warn!("[css_load] Failed to start watching: {}", err); - } - } +#[tauri::command] +pub fn css_history_activate( + state: State<'_, AppState>, + app: AppHandle, + path: String, +) -> CmdResult { + let _operation_guard = state.lock_css_operation(); + let authorized = state + .store + .with_state(|store| has_history_path(&store.custom_css_history, &path)); + if !authorized { + log::warn!( + "[css_history_activate] Rejected CSS path code={} path={}", + CssHistoryErrorCode::PathNotAuthorized.as_str(), + path + ); + return Ok(activate_failure( + CssHistoryErrorCode::PathNotAuthorized, + path, + )); + } - notify_obs_css(&state); - Ok(CssLoadResponse { - success: true, - error: None, - content: Some(content), - path: Some(path_string), - }) + let loaded = match validate_css_path(Path::new(&path)) { + Ok(loaded) => loaded, + Err(error) => { + log_css_rejection("css_history_activate", &path, &error); + return Ok(activate_failure(error.code, path)); + } + }; + let canonical_authorized = state + .store + .with_state(|store| has_history_path(&store.custom_css_history, &loaded.canonical_path)); + if !canonical_authorized { + log::warn!( + "[css_history_activate] Rejected canonical CSS path code={} path={}", + CssHistoryErrorCode::PathNotAuthorized.as_str(), + loaded.canonical_path + ); + return Ok(activate_failure( + CssHistoryErrorCode::PathNotAuthorized, + path, + )); + } + + let (css, use_custom_css) = commit_loaded_css(&state, &loaded, false)?; + state.authorize_css_path(&loaded.canonical_path); + state.unwatch_global_css(); + if use_custom_css { + if let Err(error) = state.watch_global_css(&loaded.canonical_path) { + log::warn!("[css_history_activate] Failed to start watching: {error}"); } - Err(err) => Ok(CssLoadResponse { - success: false, - error: Some(err.to_string()), - content: None, - path: Some(path_string), - }), } + emit_best_effort(&app, "css:content", &css); + notify_obs_css(&state); + + Ok(CssActivateResponse { + success: true, + code: None, + content: Some(loaded.content), + path: Some(loaded.canonical_path), + }) +} + +#[tauri::command] +pub fn css_history_remove( + state: State<'_, AppState>, + path: String, +) -> CmdResult> { + let operation_guard = state.lock_css_operation(); + let updated = state.store.update(|store| { + store + .custom_css_history + .retain(|entry| !history_paths_match(&entry.path, &path)); + normalize_custom_css_history(&mut store.custom_css_history); + })?; + drop(operation_guard); + Ok(history_items(&updated.custom_css_history)) } // ========== 탭별 CSS 커맨드 ========== @@ -254,53 +473,48 @@ pub fn css_tab_load( }); }; - let path_string = path.to_string_lossy().to_string(); - match fs::read_to_string(&path) { - Ok(content) => { - // 이전 탭 CSS 워칭 중지 - state.unwatch_tab_css(&tab_id); - - let tab_css = TabCss { - path: Some(path_string.clone()), - content: content.clone(), - enabled: true, - }; - - state.store.update(|store| { - store - .tab_css_overrides - .insert(tab_id.clone(), tab_css.clone()); - })?; - - let response = TabCssResponse { - tab_id: tab_id.clone(), - css: Some(tab_css.clone()), - }; - app.emit("tabCss:changed", &response)?; - - // CSS 핫리로딩: 새 탭 CSS 파일 워칭 시작 - if let Err(err) = state.watch_tab_css(&path_string, &tab_id) { - log::warn!( - "[css_tab_load] Failed to start watching tab {}: {}", - tab_id, - err - ); - } - - Ok(TabCssLoadResponse { - success: true, - error: None, + let selected_path = path.to_string_lossy().to_string(); + let _operation_guard = state.lock_css_operation(); + let loaded = match validate_css_path(&path) { + Ok(loaded) => loaded, + Err(error) => { + log_css_rejection("css_tab_load", &selected_path, &error); + return Ok(TabCssLoadResponse { + success: false, + error: Some(error.code.as_str().to_string()), tab_id, - css: Some(tab_css), - }) + css: None, + }); } - Err(err) => Ok(TabCssLoadResponse { - success: false, - error: Some(err.to_string()), - tab_id, - css: None, - }), + }; + let tab_css = TabCss { + path: Some(loaded.canonical_path.clone()), + content: loaded.content, + enabled: true, + }; + state.store.update(|store| { + replace_tab_css_override(store, &tab_id, Some(tab_css.clone())); + })?; + state.authorize_css_path(&loaded.canonical_path); + state.unwatch_tab_css(&tab_id); + if let Err(error) = state.watch_tab_css(&loaded.canonical_path, &tab_id) { + log::warn!("[css_tab_load] Failed to watch tab {tab_id}: {error}"); } + emit_best_effort( + &app, + "tabCss:changed", + &TabCssResponse { + tab_id: tab_id.clone(), + css: Some(tab_css.clone()), + }, + ); + + Ok(TabCssLoadResponse { + success: true, + error: None, + tab_id, + css: Some(tab_css), + }) } /// 특정 탭의 CSS 제거 (전역 CSS로 폴백) @@ -310,18 +524,20 @@ pub fn css_tab_clear( app: AppHandle, tab_id: String, ) -> CmdResult { - // CSS 핫리로딩: 탭 CSS 워칭 중지 - state.unwatch_tab_css(&tab_id); - + let _operation_guard = state.lock_css_operation(); state.store.update(|store| { - store.tab_css_overrides.remove(&tab_id); + replace_tab_css_override(store, &tab_id, None); })?; + state.unwatch_tab_css(&tab_id); - let response = TabCssResponse { - tab_id: tab_id.clone(), - css: None, - }; - app.emit("tabCss:changed", &response)?; + emit_best_effort( + &app, + "tabCss:changed", + &TabCssResponse { + tab_id: tab_id.clone(), + css: None, + }, + ); Ok(TabCssClearResponse { success: true, @@ -337,39 +553,30 @@ pub fn css_tab_set( tab_id: String, css: Option, ) -> CmdResult { - // 이전 탭 CSS 워칭 중지 + let _operation_guard = state.lock_css_operation(); + let css = css.map(|tab_css| prepare_tab_css_for_set(&state, tab_css)); + state.store.update(|store| { + replace_tab_css_override(store, &tab_id, css.clone()); + })?; state.unwatch_tab_css(&tab_id); - - if let Some(ref tab_css) = css { - state.store.update(|store| { - store - .tab_css_overrides - .insert(tab_id.clone(), tab_css.clone()); - })?; - - // CSS 핫리로딩: 파일이 있고 enabled인 경우 워칭 시작 - if tab_css.enabled { - if let Some(path) = &tab_css.path { - if let Err(err) = state.watch_tab_css(path, &tab_id) { - log::warn!( - "[css_tab_set] Failed to start watching tab {}: {}", - tab_id, - err - ); - } - } + if let Some(path) = css + .as_ref() + .filter(|tab_css| tab_css.enabled) + .and_then(|tab_css| tab_css.path.as_deref()) + { + if let Err(error) = state.watch_tab_css(path, &tab_id) { + log::warn!("[css_tab_set] Failed to watch tab {tab_id}: {error}"); } - } else { - state.store.update(|store| { - store.tab_css_overrides.remove(&tab_id); - })?; } - let response = TabCssResponse { - tab_id: tab_id.clone(), - css: css.clone(), - }; - app.emit("tabCss:changed", &response)?; + emit_best_effort( + &app, + "tabCss:changed", + &TabCssResponse { + tab_id: tab_id.clone(), + css: css.clone(), + }, + ); Ok(TabCssSetResponse { success: true, @@ -386,6 +593,7 @@ pub fn css_tab_toggle( tab_id: String, enabled: bool, ) -> CmdResult { + let _operation_guard = state.lock_css_operation(); let mut updated_css: Option = None; state.store.update(|store| { @@ -406,7 +614,7 @@ pub fn css_tab_toggle( } })?; - // CSS 핫리로딩: 활성화/비활성화에 따른 워칭 관리 + state.unwatch_tab_css(&tab_id); if let Some(ref css) = updated_css { if enabled { if let Some(path) = &css.path { @@ -418,16 +626,17 @@ pub fn css_tab_toggle( ); } } - } else { - state.unwatch_tab_css(&tab_id); } } - let response = TabCssResponse { - tab_id: tab_id.clone(), - css: updated_css, - }; - app.emit("tabCss:changed", &response)?; + emit_best_effort( + &app, + "tabCss:changed", + &TabCssResponse { + tab_id: tab_id.clone(), + css: updated_css, + }, + ); Ok(TabCssToggleResponse { success: true, @@ -435,3 +644,338 @@ pub fn css_tab_toggle( enabled, }) } + +fn prepare_tab_css_for_set(state: &AppState, css: TabCss) -> TabCss { + prepare_tab_css_for_set_with(css, |path| state.is_css_path_authorized(path)) +} + +fn prepare_tab_css_for_set_with( + mut css: TabCss, + is_authorized: impl FnOnce(&str) -> bool, +) -> TabCss { + let Some(path) = css.path.clone() else { + return css; + }; + if !is_authorized(&path) { + log::warn!( + "[css_tab_set] Dropped unauthorized CSS path path={} code={}", + path, + CssHistoryErrorCode::PathNotAuthorized.as_str() + ); + css.path = None; + return css; + } + + match validate_css_path(Path::new(&path)) { + Ok(loaded) => css.path = Some(loaded.canonical_path), + Err(error) => { + log_css_rejection("css_tab_set", &path, &error); + css.path = None; + } + } + css +} + +fn is_valid_tab_id(state: &AppState, tab_id: &str) -> bool { + default_keys().contains_key(tab_id) + || state + .store + .with_state(|store| store.custom_tabs.iter().any(|tab| tab.id == tab_id)) +} + +fn tab_activate_failure( + tab_id: String, + code: Option, +) -> TabCssActivateResponse { + TabCssActivateResponse { + success: false, + code, + tab_id, + css: None, + } +} + +#[tauri::command] +pub fn css_tab_activate_history( + state: State<'_, AppState>, + app: AppHandle, + tab_id: String, + path: String, +) -> CmdResult { + let _operation_guard = state.lock_css_operation(); + let authorized = state + .store + .with_state(|store| has_history_path(&store.custom_css_history, &path)); + if !authorized { + log::warn!( + "[css_tab_activate_history] Rejected CSS path code={} path={}", + CssHistoryErrorCode::PathNotAuthorized.as_str(), + path + ); + return Ok(tab_activate_failure( + tab_id, + Some(CssHistoryErrorCode::PathNotAuthorized), + )); + } + + let loaded = match validate_css_path(Path::new(&path)) { + Ok(loaded) => loaded, + Err(error) => { + log_css_rejection("css_tab_activate_history", &path, &error); + return Ok(tab_activate_failure(tab_id, Some(error.code))); + } + }; + let canonical_authorized = state + .store + .with_state(|store| has_history_path(&store.custom_css_history, &loaded.canonical_path)); + if !canonical_authorized { + log::warn!( + "[css_tab_activate_history] Rejected canonical CSS path code={} path={}", + CssHistoryErrorCode::PathNotAuthorized.as_str(), + loaded.canonical_path + ); + return Ok(tab_activate_failure( + tab_id, + Some(CssHistoryErrorCode::PathNotAuthorized), + )); + } + if !is_valid_tab_id(&state, &tab_id) { + log::warn!("[css_tab_activate_history] Rejected unknown tab id={tab_id}"); + return Ok(tab_activate_failure(tab_id, None)); + } + + let tab_css = TabCss { + path: Some(loaded.canonical_path.clone()), + content: loaded.content, + enabled: true, + }; + let timestamp = current_unix_millis(); + state.store.update(|store| { + replace_tab_css_override(store, &tab_id, Some(tab_css.clone())); + touch_custom_css_history( + &mut store.custom_css_history, + &loaded.canonical_path, + timestamp, + ); + })?; + state.authorize_css_path(&loaded.canonical_path); + state.unwatch_tab_css(&tab_id); + if let Err(error) = state.watch_tab_css(&loaded.canonical_path, &tab_id) { + log::warn!("[css_tab_activate_history] Failed to watch tab {tab_id}: {error}"); + } + emit_best_effort( + &app, + "tabCss:changed", + &TabCssResponse { + tab_id: tab_id.clone(), + css: Some(tab_css.clone()), + }, + ); + + Ok(TabCssActivateResponse { + success: true, + code: None, + tab_id, + css: Some(tab_css), + }) +} + +fn no_tab_css_export() -> TabCssExportResponse { + TabCssExportResponse { + success: false, + code: Some(TabCssExportErrorCode::NoTabCss), + error: None, + path: None, + } +} + +fn ensure_css_extension(mut path: PathBuf) -> PathBuf { + let has_css_extension = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("css")); + if !has_css_extension { + path.set_extension("css"); + } + path +} + +fn write_tab_css_export(path: &Path, content: &str) -> anyhow::Result<()> { + atomic_replace(path, content.as_bytes(), "tab-css-export") +} + +#[tauri::command] +pub fn css_tab_export( + state: State<'_, AppState>, + tab_id: String, +) -> CmdResult { + let initial_css = state + .store + .with_state(|store| store.tab_css_overrides.get(&tab_id).cloned()); + let Some(initial_css) = initial_css.filter(|css| !css.content.is_empty()) else { + return Ok(no_tab_css_export()); + }; + let default_file_name = initial_css + .path + .as_deref() + .and_then(|path| Path::new(path).file_name()) + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("{tab_id}.css")); + let selected_path = FileDialog::new() + .set_file_name(&default_file_name) + .add_filter("CSS", &["css"]) + .save_file(); + let Some(selected_path) = selected_path else { + return Ok(TabCssExportResponse { + success: false, + code: None, + error: None, + path: None, + }); + }; + let export_path = ensure_css_extension(selected_path); + let current_content = state.store.with_state(|store| { + store + .tab_css_overrides + .get(&tab_id) + .map(|css| css.content.clone()) + }); + let Some(current_content) = current_content.filter(|content| !content.is_empty()) else { + return Ok(no_tab_css_export()); + }; + + if let Err(error) = write_tab_css_export(&export_path, ¤t_content) { + return Ok(TabCssExportResponse { + success: false, + code: Some(TabCssExportErrorCode::IoError), + error: Some(format!("{error:#}")), + path: None, + }); + } + + Ok(TabCssExportResponse { + success: true, + code: None, + error: None, + path: Some(export_path.to_string_lossy().to_string()), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + ensure_css_extension, prepare_tab_css_for_set_with, replace_tab_css_override, + write_tab_css_export, + }; + use crate::models::{AppStoreData, TabCss}; + use parking_lot::Mutex; + use std::{ + fs, + path::Path, + sync::{mpsc, Arc}, + thread, + }; + + fn test_directory(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "dmnote-css-command-{label}-{}", + uuid::Uuid::new_v4() + )) + } + + #[test] + fn unauthorized_tab_set_preserves_content_without_persisting_path() { + let css = TabCss { + path: Some("/tmp/not-authorized.css".to_string()), + content: "preserved".to_string(), + enabled: true, + }; + + let prepared = prepare_tab_css_for_set_with(css, |_| false); + + assert_eq!(prepared.path, None); + assert_eq!(prepared.content, "preserved"); + assert!(prepared.enabled); + } + + #[test] + fn authorized_tab_set_persists_the_canonical_path() { + let root = test_directory("authorized"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("theme.css"); + fs::write(&path, "body {}").unwrap(); + let css = TabCss { + path: Some(path.to_string_lossy().to_string()), + content: "preserved".to_string(), + enabled: true, + }; + + let prepared = prepare_tab_css_for_set_with(css, |_| true); + + let canonical = fs::canonicalize(&path) + .unwrap() + .to_string_lossy() + .to_string(); + assert_eq!(prepared.path.as_deref(), Some(canonical.as_str())); + assert_eq!(prepared.content, "preserved"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn export_corrects_extension_and_atomically_replaces_existing_file() { + let root = test_directory("export"); + fs::create_dir_all(&root).unwrap(); + let selected = root.join("theme.txt"); + let export = ensure_css_extension(selected); + fs::write(&export, "old").unwrap(); + + write_tab_css_export(&export, "new").unwrap(); + + assert_eq!( + export.extension().and_then(|value| value.to_str()), + Some("css") + ); + assert_eq!(fs::read_to_string(&export).unwrap(), "new"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(Path::new(&export).is_file()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn clear_waiting_on_activation_lock_wins_with_the_last_commit() { + let operation_lock = Arc::new(Mutex::new(())); + let store = Arc::new(Mutex::new(AppStoreData::default())); + let (locked_tx, locked_rx) = mpsc::channel(); + let (continue_tx, continue_rx) = mpsc::channel(); + + let activation_lock = operation_lock.clone(); + let activation_store = store.clone(); + let activate = thread::spawn(move || { + let _guard = activation_lock.lock(); + locked_tx.send(()).unwrap(); + continue_rx.recv().unwrap(); + replace_tab_css_override( + &mut activation_store.lock(), + "4key", + Some(TabCss { + path: Some("/tmp/theme.css".to_string()), + content: "active".to_string(), + enabled: true, + }), + ); + }); + locked_rx.recv().unwrap(); + + let clear_lock = operation_lock.clone(); + let clear_store = store.clone(); + let clear = thread::spawn(move || { + let _guard = clear_lock.lock(); + replace_tab_css_override(&mut clear_store.lock(), "4key", None); + }); + continue_tx.send(()).unwrap(); + activate.join().unwrap(); + clear.join().unwrap(); + + assert!(!store.lock().tab_css_overrides.contains_key("4key")); + } +} diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 5c79fb86..ad628ab1 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -413,6 +413,8 @@ pub fn keys_reset_all(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult = Vec::new(); let tab_note_overrides = crate::models::TabNoteOverrides::new(); - for tab_id in &cleared_tab_css_ids { - state.unwatch_tab_css(tab_id); - } if let Err(error) = state.emit_settings_changed(&settings_diff, &app) { log::error!("[Keys] failed to publish reset settings: {error:#}"); } diff --git a/src-tauri/src/commands/layout/settings.rs b/src-tauri/src/commands/layout/settings.rs index 060cf403..c01c27f4 100644 --- a/src-tauri/src/commands/layout/settings.rs +++ b/src-tauri/src/commands/layout/settings.rs @@ -17,7 +17,22 @@ pub fn settings_update( app: AppHandle, patch: SettingsPatchInput, ) -> CmdResult { + let css_changed = patch.use_custom_css.is_some() || patch.custom_css.is_some(); + let operation_guard = if css_changed { + Some(state.lock_css_operation()) + } else { + None + }; + let previous = if css_changed { + Some(state.store.snapshot()) + } else { + None + }; let diff = state.settings.apply_patch(patch)?; + if let Some(previous) = previous.as_ref() { + state.resync_global_css_watcher(previous, &state.store.snapshot()); + } + drop(operation_guard); state.emit_settings_changed(&diff, &app)?; Ok(diff.full.unwrap_or_else(|| state.settings.snapshot())) } diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 8024e58f..ca5478ef 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -14,14 +14,15 @@ use crate::{ css::TabCssResponse, state::{emit_best_effort, publish_editor_change}, }, + custom_css::validate_css_path, defaults::{default_keys, default_positions}, errors::{CmdResult, CommandError}, models::{ AppStoreData, CommittedEditorChange, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, EditorCommitOrigin, EditorField, FontSettings, FontType, GradientSpec, GraphPositions, KeyMappings, KeyPosition, KeyPositions, KnobPositions, LayerGroups, NoteSettings, - NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCssOverrides, TabNoteSettings, - SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCss, TabCssOverrides, + TabNoteSettings, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, }, services::settings::apply_patch_to_store, state::AppState, @@ -252,7 +253,9 @@ pub fn preset_load(state: State<'_, AppState>, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult, app: AppHandle) -> CmdResult css.path = Some(loaded.canonical_path), + Err(error) => { + log::warn!( + "[{operation}] Dropped invalid global CSS path code={} path={} detail={}", + error.code.as_str(), + path, + error.detail + ); + css.path = None; + } + } +} + +fn normalize_imported_tab_css(css: &mut TabCss, operation: &str) { + let Some(path) = css.path.clone() else { + return; + }; + match validate_css_path(Path::new(&path)) { + Ok(loaded) => css.path = Some(loaded.canonical_path), + Err(error) => { + log::warn!( + "[{operation}] Dropped invalid tab CSS path code={} path={} detail={}", + error.code.as_str(), + path, + error.detail + ); + css.path = None; + } + } +} + +fn normalize_imported_tab_css_overrides( + mut overrides: TabCssOverrides, + operation: &str, +) -> TabCssOverrides { + for css in overrides.values_mut() { + normalize_imported_tab_css(css, operation); + } + overrides +} + fn choose_tab_preset_source_tab( keys: &KeyMappings, selected_key_type: Option<&str>, @@ -1409,9 +1463,43 @@ mod tests { use super::*; use crate::{ defaults::{default_keys, default_positions}, - models::{CustomFont, JsPlugin, KnobPosition}, + models::{CustomCssHistoryEntry, CustomFont, JsPlugin, KnobPosition}, }; + #[test] + fn full_preset_settings_patch_preserves_custom_css_history() { + let history = vec![CustomCssHistoryEntry { + path: "/tmp/preserved.css".to_string(), + loaded_at: 123, + last_used_at: 123, + }]; + let mut store = AppStoreData { + custom_css_history: history.clone(), + ..AppStoreData::default() + }; + let mut preset = PresetFile { + use_custom_css: Some(true), + custom_css: Some(CustomCss { + path: Some("/tmp/preset.css".to_string()), + content: "body {}".to_string(), + }), + ..PresetFile::default() + }; + let resolved = resolve_full_preset_settings(&mut preset, &store); + let patch = SettingsPatchInput { + use_custom_css: Some(resolved.use_custom_css), + custom_css: Some(CustomCssPatch { + path: Some(resolved.custom_css.path), + content: Some(resolved.custom_css.content), + }), + ..SettingsPatchInput::default() + }; + + apply_patch_to_store(&mut store, &patch); + + assert_eq!(store.custom_css_history, history); + } + #[test] fn preset_source_bytes_remain_unchanged_on_success_and_parse_failure() { let temp_dir = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index 60f0281c..858ce86b 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -274,6 +274,13 @@ mod tests { assert!(preset.tab_css_overrides.is_none()); } + #[test] + fn preset_wire_schema_excludes_custom_css_history() { + let serialized = serde_json::to_value(PresetFile::default()).unwrap(); + + assert!(serialized.get("customCssHistory").is_none()); + } + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PreFeaturePresetPosition { diff --git a/src-tauri/src/custom_css.rs b/src-tauri/src/custom_css.rs new file mode 100644 index 00000000..5cfcc992 --- /dev/null +++ b/src-tauri/src/custom_css.rs @@ -0,0 +1,461 @@ +use std::{ + collections::HashSet, + fs::{self, File}, + io::Read, + path::{Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{ + models::{AppStoreData, CustomCssHistoryEntry}, + state::local_asset_path::path_identity_key, +}; + +pub(crate) const MAX_CUSTOM_CSS_BYTES: u64 = 1024 * 1024; +pub(crate) const MAX_CUSTOM_CSS_HISTORY_ENTRIES: usize = 10; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CssHistoryErrorCode { + PathNotAuthorized, + NotFound, + NotRegularFile, + InvalidExtension, + TooLarge, + InvalidUtf8, + IoError, +} + +impl CssHistoryErrorCode { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::PathNotAuthorized => "PATH_NOT_AUTHORIZED", + Self::NotFound => "NOT_FOUND", + Self::NotRegularFile => "NOT_REGULAR_FILE", + Self::InvalidExtension => "INVALID_EXTENSION", + Self::TooLarge => "TOO_LARGE", + Self::InvalidUtf8 => "INVALID_UTF8", + Self::IoError => "IO_ERROR", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum CustomCssHistoryStatus { + Available, + Missing, + Invalid, + TooLarge, +} + +#[derive(Debug)] +pub(crate) struct CssPathError { + pub(crate) code: CssHistoryErrorCode, + pub(crate) detail: String, +} + +impl CssPathError { + fn new(code: CssHistoryErrorCode, detail: impl Into) -> Self { + Self { + code, + detail: detail.into(), + } + } +} + +#[derive(Debug)] +pub(crate) struct ValidatedCssFile { + pub(crate) canonical_path: String, + pub(crate) content: String, +} + +pub(crate) fn validate_css_path(path: &Path) -> Result { + let canonical = canonicalize_css_path(path)?; + let metadata = fs::metadata(&canonical).map_err(map_io_error)?; + validate_css_metadata(&canonical, &metadata)?; + + let file = File::open(&canonical).map_err(map_io_error)?; + let open_metadata = file.metadata().map_err(map_io_error)?; + validate_css_metadata(&canonical, &open_metadata)?; + + let mut bytes = + Vec::with_capacity((open_metadata.len().min(MAX_CUSTOM_CSS_BYTES) + 1) as usize); + file.take(MAX_CUSTOM_CSS_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(map_io_error)?; + if bytes.len() as u64 > MAX_CUSTOM_CSS_BYTES { + return Err(CssPathError::new( + CssHistoryErrorCode::TooLarge, + format!("CSS file exceeds {MAX_CUSTOM_CSS_BYTES} bytes"), + )); + } + + let content = String::from_utf8(bytes) + .map_err(|error| CssPathError::new(CssHistoryErrorCode::InvalidUtf8, error.to_string()))?; + let canonical_path = canonical.into_os_string().into_string().map_err(|_| { + CssPathError::new(CssHistoryErrorCode::IoError, "CSS path is not valid UTF-8") + })?; + + Ok(ValidatedCssFile { + canonical_path, + content, + }) +} + +pub(crate) fn inspect_css_history_status(path: &Path) -> CustomCssHistoryStatus { + let canonical = match canonicalize_css_path(path) { + Ok(path) => path, + Err(error) if error.code == CssHistoryErrorCode::NotFound => { + return CustomCssHistoryStatus::Missing; + } + Err(_) => return CustomCssHistoryStatus::Invalid, + }; + let metadata = match fs::metadata(&canonical) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return CustomCssHistoryStatus::Missing; + } + Err(_) => return CustomCssHistoryStatus::Invalid, + }; + if !metadata.is_file() || !has_css_extension(&canonical) { + return CustomCssHistoryStatus::Invalid; + } + if metadata.len() > MAX_CUSTOM_CSS_BYTES { + return CustomCssHistoryStatus::TooLarge; + } + CustomCssHistoryStatus::Available +} + +pub(crate) fn normalize_custom_css_history(history: &mut Vec) -> bool { + let original = history.clone(); + history.retain(|entry| Path::new(&entry.path).is_absolute()); + history.sort_by_key(|entry| std::cmp::Reverse(entry.loaded_at)); + + let mut seen = HashSet::with_capacity(history.len()); + history.retain(|entry| seen.insert(path_identity_key(Path::new(&entry.path)))); + while history.len() > MAX_CUSTOM_CSS_HISTORY_ENTRIES { + let eviction_index = history + .iter() + .enumerate() + .min_by_key(|(_, entry)| (entry.last_used_at, entry.loaded_at)) + .map(|(index, _)| index) + .expect("CSS history must contain an eviction candidate"); + history.remove(eviction_index); + } + history.sort_by_key(|entry| std::cmp::Reverse(entry.loaded_at)); + + *history != original +} + +pub(crate) fn record_custom_css_load( + history: &mut Vec, + path: String, + timestamp: i64, +) { + let identity = path_identity_key(Path::new(&path)); + history.retain(|entry| path_identity_key(Path::new(&entry.path)) != identity); + history.push(CustomCssHistoryEntry { + path, + loaded_at: timestamp, + last_used_at: timestamp, + }); + normalize_custom_css_history(history); +} + +pub(crate) fn touch_custom_css_history( + history: &mut [CustomCssHistoryEntry], + path: &str, + timestamp: i64, +) -> bool { + let identity = path_identity_key(Path::new(path)); + let Some(entry) = history + .iter_mut() + .find(|entry| path_identity_key(Path::new(&entry.path)) == identity) + else { + return false; + }; + entry.last_used_at = timestamp; + true +} + +pub(crate) fn migrate_custom_css_history_at_load( + history: &mut Vec, + active_path: Option<&str>, + timestamp: i64, +) -> bool { + let original = history.clone(); + for entry in history.iter_mut().filter(|entry| entry.loaded_at == 0) { + entry.loaded_at = entry.last_used_at; + } + + if let Some(path) = active_path.filter(|path| { + Path::new(path).is_absolute() + && !history + .iter() + .any(|entry| history_paths_match(&entry.path, path)) + }) { + history.push(CustomCssHistoryEntry { + path: path.to_string(), + loaded_at: timestamp, + last_used_at: timestamp, + }); + } + + normalize_custom_css_history(history); + *history != original +} + +pub(crate) fn history_paths_match(left: &str, right: &str) -> bool { + path_identity_key(Path::new(left)) == path_identity_key(Path::new(right)) +} + +pub(crate) fn custom_css_settings_diff(state: &AppStoreData) -> serde_json::Value { + serde_json::json!({ + "useCustomCSS": state.use_custom_css, + "customCSS": state.custom_css, + }) +} + +fn canonicalize_css_path(path: &Path) -> Result { + let canonical = fs::canonicalize(path).map_err(map_io_error)?; + if !canonical.is_absolute() { + return Err(CssPathError::new( + CssHistoryErrorCode::IoError, + "CSS path is not absolute after canonicalization", + )); + } + Ok(canonical) +} + +fn validate_css_metadata(path: &Path, metadata: &fs::Metadata) -> Result<(), CssPathError> { + if !metadata.is_file() { + return Err(CssPathError::new( + CssHistoryErrorCode::NotRegularFile, + "CSS path is not a regular file", + )); + } + if !has_css_extension(path) { + return Err(CssPathError::new( + CssHistoryErrorCode::InvalidExtension, + "canonical CSS path does not have a .css extension", + )); + } + if metadata.len() > MAX_CUSTOM_CSS_BYTES { + return Err(CssPathError::new( + CssHistoryErrorCode::TooLarge, + format!("CSS file exceeds {MAX_CUSTOM_CSS_BYTES} bytes"), + )); + } + Ok(()) +} + +fn has_css_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("css")) +} + +fn map_io_error(error: std::io::Error) -> CssPathError { + let code = if error.kind() == std::io::ErrorKind::NotFound { + CssHistoryErrorCode::NotFound + } else { + CssHistoryErrorCode::IoError + }; + CssPathError::new(code, error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_directory(label: &str) -> PathBuf { + std::env::temp_dir().join(format!("dmnote-css-{label}-{}", uuid::Uuid::new_v4())) + } + + #[test] + fn history_normalization_filters_deduplicates_sorts_and_caps() { + let root = test_directory("history-normalize"); + let duplicate = root.join("duplicate.css").to_string_lossy().to_string(); + let mut history = vec![ + CustomCssHistoryEntry { + path: "relative.css".to_string(), + loaded_at: 100, + last_used_at: 100, + }, + CustomCssHistoryEntry { + path: duplicate.clone(), + loaded_at: 1, + last_used_at: 1, + }, + CustomCssHistoryEntry { + path: duplicate.clone(), + loaded_at: 50, + last_used_at: 50, + }, + ]; + for index in 0..12 { + history.push(CustomCssHistoryEntry { + path: root + .join(format!("entry-{index}.css")) + .to_string_lossy() + .to_string(), + loaded_at: index, + last_used_at: index, + }); + } + + assert!(normalize_custom_css_history(&mut history)); + assert_eq!(history.len(), MAX_CUSTOM_CSS_HISTORY_ENTRIES); + assert!(history + .windows(2) + .all(|pair| pair[0].loaded_at >= pair[1].loaded_at)); + assert!(history + .iter() + .all(|entry| Path::new(&entry.path).is_absolute())); + assert_eq!( + history + .iter() + .filter(|entry| entry.path == duplicate) + .count(), + 1 + ); + assert_eq!( + history + .iter() + .find(|entry| entry.path == duplicate) + .unwrap() + .last_used_at, + 50 + ); + assert!(!normalize_custom_css_history(&mut history)); + } + + #[test] + fn activation_updates_lru_without_changing_display_order() { + let root = test_directory("activation-order"); + let first = root.join("first.css").to_string_lossy().to_string(); + let second = root.join("second.css").to_string_lossy().to_string(); + let mut history = vec![ + CustomCssHistoryEntry { + path: first.clone(), + loaded_at: 20, + last_used_at: 20, + }, + CustomCssHistoryEntry { + path: second, + loaded_at: 10, + last_used_at: 10, + }, + ]; + + assert!(touch_custom_css_history(&mut history, &first, 30)); + normalize_custom_css_history(&mut history); + + assert_eq!(history[0].path, first); + assert_eq!(history[0].loaded_at, 20); + assert_eq!(history[0].last_used_at, 30); + } + + #[test] + fn eviction_uses_last_used_at_without_reordering_loaded_at() { + let root = test_directory("lru-eviction"); + let mut history = (0..=MAX_CUSTOM_CSS_HISTORY_ENTRIES) + .map(|index| CustomCssHistoryEntry { + path: root + .join(format!("entry-{index}.css")) + .to_string_lossy() + .to_string(), + loaded_at: index as i64, + last_used_at: index as i64, + }) + .collect::>(); + history[0].last_used_at = 100; + + normalize_custom_css_history(&mut history); + + assert_eq!(history.len(), MAX_CUSTOM_CSS_HISTORY_ENTRIES); + assert!(history.iter().any(|entry| entry.loaded_at == 0)); + assert!(!history.iter().any(|entry| entry.loaded_at == 1)); + assert!(history + .windows(2) + .all(|pair| pair[0].loaded_at >= pair[1].loaded_at)); + } + + #[test] + fn validation_accepts_case_insensitive_css_extension() { + let root = test_directory("valid"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("theme.CSS"); + fs::write(&path, b"body { color: white; }").unwrap(); + + let validated = validate_css_path(&path).unwrap(); + + assert_eq!(validated.content, "body { color: white; }"); + assert!(Path::new(&validated.canonical_path).is_absolute()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn css_history_wire_values_match_the_api_contract() { + let codes = [ + ( + CssHistoryErrorCode::PathNotAuthorized, + "PATH_NOT_AUTHORIZED", + ), + (CssHistoryErrorCode::NotFound, "NOT_FOUND"), + (CssHistoryErrorCode::NotRegularFile, "NOT_REGULAR_FILE"), + (CssHistoryErrorCode::InvalidExtension, "INVALID_EXTENSION"), + (CssHistoryErrorCode::TooLarge, "TOO_LARGE"), + (CssHistoryErrorCode::InvalidUtf8, "INVALID_UTF8"), + (CssHistoryErrorCode::IoError, "IO_ERROR"), + ]; + for (code, expected) in codes { + assert_eq!(serde_json::to_value(code).unwrap(), expected); + } + assert_eq!( + serde_json::to_value(CustomCssHistoryStatus::TooLarge).unwrap(), + "tooLarge" + ); + } + + #[test] + fn validation_rejects_oversized_and_non_utf8_files() { + let root = test_directory("invalid-content"); + fs::create_dir_all(&root).unwrap(); + let oversized = root.join("oversized.css"); + fs::write(&oversized, vec![b'a'; MAX_CUSTOM_CSS_BYTES as usize + 1]).unwrap(); + let non_utf8 = root.join("non-utf8.css"); + fs::write(&non_utf8, [0xFF]).unwrap(); + + assert_eq!( + validate_css_path(&oversized).unwrap_err().code, + CssHistoryErrorCode::TooLarge + ); + assert_eq!( + validate_css_path(&non_utf8).unwrap_err().code, + CssHistoryErrorCode::InvalidUtf8 + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn validation_checks_the_canonical_target_extension() { + use std::os::unix::fs::symlink; + + let root = test_directory("symlink-extension"); + fs::create_dir_all(&root).unwrap(); + let target = root.join("secret.txt"); + let alias = root.join("theme.css"); + fs::write(&target, b"not css").unwrap(); + symlink(&target, &alias).unwrap(); + + assert_eq!( + validate_css_path(&alias).unwrap_err().code, + CssHistoryErrorCode::InvalidExtension + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06319edd..b1124983 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ pub mod audio; pub mod commands; pub mod cursor; +pub mod custom_css; pub mod defaults; pub mod errors; pub mod ipc; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 07c404ce..98a3c3da 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,6 +3,7 @@ mod audio; mod commands; mod cursor; +mod custom_css; mod defaults; mod errors; mod ipc; @@ -178,12 +179,17 @@ fn main() { commands::editor::css::css_reset, commands::editor::css::css_set_content, commands::editor::css::css_load, + commands::editor::css::css_history_get, + commands::editor::css::css_history_activate, + commands::editor::css::css_history_remove, commands::editor::css::css_tab_get_all, commands::editor::css::css_tab_get, commands::editor::css::css_tab_load, commands::editor::css::css_tab_clear, commands::editor::css::css_tab_set, commands::editor::css::css_tab_toggle, + commands::editor::css::css_tab_activate_history, + commands::editor::css::css_tab_export, commands::editor::js::js_get, commands::editor::js::js_get_use, commands::editor::js::js_toggle, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 968c9c76..a3b103ce 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1441,6 +1441,15 @@ pub struct CustomCss { pub content: String, } +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CustomCssHistoryEntry { + pub path: String, + #[serde(default)] + pub loaded_at: i64, + pub last_used_at: i64, +} + /// 탭별 CSS 설정 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -1737,6 +1746,8 @@ pub struct AppStoreData { #[serde(default)] pub custom_css: CustomCss, #[serde(default)] + pub custom_css_history: Vec, + #[serde(default)] pub font_settings: FontSettings, #[serde(default)] pub counter_animation_presets: Vec, @@ -1818,6 +1829,7 @@ impl Default for AppStoreData { background_color: "transparent".to_string(), use_custom_css: false, custom_css: CustomCss::default(), + custom_css_history: Vec::new(), font_settings: FontSettings::default(), counter_animation_presets: Vec::new(), tab_css_overrides: TabCssOverrides::new(), diff --git a/src-tauri/src/services/css_watcher.rs b/src-tauri/src/services/css_watcher.rs index c6f5b135..d973e09b 100644 --- a/src-tauri/src/services/css_watcher.rs +++ b/src-tauri/src/services/css_watcher.rs @@ -6,7 +6,6 @@ //! - 디바운싱으로 연속 변경 시 한 번만 리로드 use std::collections::HashMap; -use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -14,10 +13,15 @@ use std::time::Duration; use notify::RecommendedWatcher; use notify_debouncer_mini::{new_debouncer, Debouncer}; use parking_lot::RwLock; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Manager}; -use crate::models::{CustomCss, TabCss}; +use crate::commands::editor::{css::TabCssResponse, state::emit_best_effort}; use crate::state::{AppState, AppStore}; +use crate::{ + custom_css::{custom_css_settings_diff, validate_css_path, ValidatedCssFile}, + models::{AppStoreData, CustomCss, TabCss}, + state::local_asset_path::path_identity_key, +}; /// CSS 워칭 타입 #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -73,17 +77,17 @@ impl CssWatcher { /// 특정 경로에 대한 워칭 시작 fn watch_path(&self, path: &str, target: CssWatchTarget) -> Result<(), String> { - let path_buf = PathBuf::from(path); - - // 파일이 존재하는지 확인 - if !path_buf.exists() { - return Err(format!("File not found: {}", path)); - } + let path_buf = std::fs::canonicalize(path) + .map_err(|error| format!("Failed to resolve CSS path {path}: {error}"))?; + let identity = path_identity_key(&path_buf); let mut watchers = self.watchers.write(); - // 이미 같은 경로를 워칭 중인 경우 - if let Some(entry) = watchers.get_mut(&path_buf) { + if let Some(entry) = watchers + .iter_mut() + .find(|(watched_path, _)| path_identity_key(watched_path) == identity) + .map(|(_, entry)| entry) + { // 같은 타겟이 이미 등록되어 있으면 무시 if !entry.targets.contains(&target) { entry.targets.push(target); @@ -211,120 +215,257 @@ fn handle_css_change(store: &AppStore, app: &AppHandle, changed_path: &Path) -> log::debug!("[CssWatcher] File changed: {}", changed_path_str); - // 전역 CSS 체크 - if let Some(global_path) = &snapshot.custom_css.path { - if paths_match(global_path, &changed_path_str) && snapshot.use_custom_css { - return reload_global_css(store, app, global_path); - } - } - - // 탭별 CSS 체크 - for (tab_id, tab_css) in &snapshot.tab_css_overrides { - if let Some(tab_path) = &tab_css.path { - if paths_match(tab_path, &changed_path_str) && tab_css.enabled { - return reload_tab_css(store, app, tab_id, tab_path); - } - } + let global_matches = snapshot.use_custom_css + && snapshot + .custom_css + .path + .as_deref() + .is_some_and(|path| paths_match(path, &changed_path_str)); + let tab_matches = snapshot.tab_css_overrides.values().any(|css| { + css.enabled + && css + .path + .as_deref() + .is_some_and(|path| paths_match(path, &changed_path_str)) + }); + if !global_matches && !tab_matches { + return Ok(()); } - Ok(()) + reload_css_consumers(store, app, &changed_path_str) } -/// 전역 CSS 리로드 -fn reload_global_css(store: &AppStore, app: &AppHandle, path: &str) -> Result<(), String> { - let content = fs::read_to_string(path).map_err(|e| e.to_string())?; - - let css = CustomCss { - path: Some(path.to_string()), - content: content.clone(), - }; - +fn reload_css_consumers(store: &AppStore, app: &AppHandle, path: &str) -> Result<(), String> { + let app_state = app.state::(); + let _operation_guard = app_state.lock_css_operation(); + let loaded = validate_css_path(Path::new(path)).map_err(|error| { + format!( + "CSS reload rejected code={} path={} detail={}", + error.code.as_str(), + path, + error.detail + ) + })?; + let mut committed_global = None; + let mut committed_tabs = Vec::new(); store - .update(|s| { - s.custom_css = css.clone(); + .update(|state| { + (committed_global, committed_tabs) = apply_reload_if_current(state, path, &loaded); }) - .map_err(|e| e.to_string())?; - - app.emit("css:content", &css).map_err(|e| e.to_string())?; + .map_err(|error| error.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); + if let Some(css) = committed_global.as_ref() { + emit_best_effort(app, "css:content", css); + app_state.notify_obs_settings_diff(custom_css_settings_diff(&store.snapshot())); + } + for (tab_id, css) in &committed_tabs { + emit_best_effort( + app, + "tabCss:changed", + &TabCssResponse { + tab_id: tab_id.clone(), + css: Some(css.clone()), + }, + ); + } - log::info!("[CssWatcher] Reloaded global CSS from: {}", path); + if committed_global.is_none() && committed_tabs.is_empty() { + log::debug!("[CssWatcher] Discarded stale CSS reload for: {path}"); + } else { + log::info!( + "[CssWatcher] Reloaded CSS from {} global={} tabs={}", + path, + committed_global.is_some(), + committed_tabs.len() + ); + } Ok(()) } -/// 탭별 CSS 리로드 -fn reload_tab_css( - store: &AppStore, - app: &AppHandle, - tab_id: &str, +fn apply_reload_if_current( + state: &mut AppStoreData, path: &str, -) -> Result<(), String> { - let content = fs::read_to_string(path).map_err(|e| e.to_string())?; - - let tab_css = TabCss { - path: Some(path.to_string()), - content: content.clone(), - enabled: true, + loaded: &ValidatedCssFile, +) -> (Option, Vec<(String, TabCss)>) { + let global_css = if state.use_custom_css + && state + .custom_css + .path + .as_deref() + .is_some_and(|current| paths_match(current, path)) + { + let css = CustomCss { + path: Some(loaded.canonical_path.clone()), + content: loaded.content.clone(), + }; + state.custom_css = css.clone(); + Some(css) + } else { + None }; - store - .update(|s| { - s.tab_css_overrides - .insert(tab_id.to_string(), tab_css.clone()); - }) - .map_err(|e| e.to_string())?; + let mut tabs = Vec::new(); + for (tab_id, css) in &mut state.tab_css_overrides { + if !css.enabled { + continue; + } + let Some(current_path) = css.path.as_deref() else { + continue; + }; + if !paths_match(current_path, path) { + continue; + } - #[derive(serde::Serialize, Clone)] - #[serde(rename_all = "camelCase")] - struct TabCssResponse { - tab_id: String, - css: Option, + css.path = Some(loaded.canonical_path.clone()); + css.content = loaded.content.clone(); + tabs.push((tab_id.clone(), css.clone())); } - let response = TabCssResponse { - tab_id: tab_id.to_string(), - css: Some(tab_css), - }; - - app.emit("tabCss:changed", &response) - .map_err(|e| e.to_string())?; - - log::info!("[CssWatcher] Reloaded tab CSS {} from: {}", tab_id, path); - Ok(()) + (global_css, tabs) } -/// 경로 비교 (플랫폼별 차이 무시) fn paths_match(path1: &str, path2: &str) -> bool { let p1 = PathBuf::from(path1); let p2 = PathBuf::from(path2); + if path_identity_key(&p1) == path_identity_key(&p2) { + return true; + } - // 먼저 파일명이 같은지 빠르게 확인 - match (p1.file_name(), p2.file_name()) { - (Some(name1), Some(name2)) if name1 == name2 => { - // 파일명이 같으면 전체 경로 비교 - // 플랫폼 차이를 무시하기 위해 정규화된 문자열로 비교 - let normalized1 = path1.replace('\\', "/").to_lowercase(); - let normalized2 = path2.replace('\\', "/").to_lowercase(); + matches!( + (p1.canonicalize(), p2.canonicalize()), + (Ok(canonical1), Ok(canonical2)) if canonical1 == canonical2 + ) +} - if normalized1 == normalized2 { - return true; - } +#[cfg(test)] +mod tests { + use super::apply_reload_if_current; + use crate::{ + custom_css::ValidatedCssFile, + models::{AppStoreData, CustomCss, TabCss}, + }; - // 문자열 불일치 시 canonicalize로 최종 확인 (고비용 — 마지막 단계에서만) - if let (Ok(canonical1), Ok(canonical2)) = (p1.canonicalize(), p2.canonicalize()) { - return canonical1 == canonical2; - } + #[test] + fn stale_global_reload_cannot_replace_a_new_path() { + let mut state = AppStoreData { + use_custom_css: true, + custom_css: CustomCss { + path: Some("/tmp/new.css".to_string()), + content: "new".to_string(), + }, + ..AppStoreData::default() + }; + let stale = ValidatedCssFile { + canonical_path: "/tmp/old.css".to_string(), + content: "old".to_string(), + }; - false - } - _ => false, + let (global, tabs) = apply_reload_if_current(&mut state, "/tmp/old.css", &stale); + assert!(global.is_none()); + assert!(tabs.is_empty()); + assert_eq!(state.custom_css.content, "new"); + } + + #[test] + fn current_global_reload_replaces_content() { + let path = "/tmp/current.css"; + let mut state = AppStoreData { + use_custom_css: true, + custom_css: CustomCss { + path: Some(path.to_string()), + content: "before".to_string(), + }, + ..AppStoreData::default() + }; + let reloaded = ValidatedCssFile { + canonical_path: "/tmp/canonical.css".to_string(), + content: "after".to_string(), + }; + + let (global, _) = apply_reload_if_current(&mut state, path, &reloaded); + assert!(global.is_some()); + assert_eq!(state.custom_css.content, "after"); + assert_eq!(state.custom_css.path.as_deref(), Some("/tmp/canonical.css")); + } + + #[cfg(unix)] + #[test] + fn symlink_alias_and_canonical_target_share_reload_identity() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "dmnote-css-watcher-canonical-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let target = root.join("target.css"); + let alias = root.join("alias.css"); + std::fs::write(&target, "body {}").unwrap(); + symlink(&target, &alias).unwrap(); + + assert!(super::paths_match( + &alias.to_string_lossy(), + &target.to_string_lossy() + )); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn stale_tab_reload_preserves_new_path_and_enabled_state() { + let mut state = AppStoreData::default(); + state.tab_css_overrides.insert( + "4key".to_string(), + TabCss { + path: Some("/tmp/new.css".to_string()), + content: "new".to_string(), + enabled: false, + }, + ); + let loaded = ValidatedCssFile { + canonical_path: "/tmp/old.css".to_string(), + content: "old".to_string(), + }; + + let (_, tabs) = apply_reload_if_current(&mut state, "/tmp/old.css", &loaded); + + assert!(tabs.is_empty()); + let css = &state.tab_css_overrides["4key"]; + assert_eq!(css.content, "new"); + assert!(!css.enabled); + } + + #[test] + fn shared_file_reload_fans_out_to_global_and_all_tabs() { + let path = "/tmp/shared.css"; + let tab_css = TabCss { + path: Some(path.to_string()), + content: "before".to_string(), + enabled: true, + }; + let mut state = AppStoreData { + use_custom_css: true, + custom_css: CustomCss { + path: Some(path.to_string()), + content: "before".to_string(), + }, + ..AppStoreData::default() + }; + state + .tab_css_overrides + .insert("4key".to_string(), tab_css.clone()); + state.tab_css_overrides.insert("5key".to_string(), tab_css); + let loaded = ValidatedCssFile { + canonical_path: path.to_string(), + content: "after".to_string(), + }; + + let (global, tabs) = apply_reload_if_current(&mut state, path, &loaded); + + assert_eq!(global.unwrap().content, "after"); + assert_eq!(tabs.len(), 2); + assert!(state + .tab_css_overrides + .values() + .all(|css| css.content == "after" && css.enabled)); } } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index b8434a61..c39b72de 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -2,6 +2,7 @@ use std::time::Instant; use std::{ collections::HashSet, io::{BufRead, BufReader}, + path::Path, process::{Child, ChildStdin, Command, Stdio}, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -34,11 +35,13 @@ use crate::{ }, keyboard::KeyboardManager, models::{ - overlay_resize_anchor_from_str, BootstrapOverlayState, BootstrapPayload, DefaultsPayload, - KeyCounterSettings, KeyCounters, KeyMappings, KeySoundOutputBackendPersist, OverlayBounds, - OverlayResizeAnchor, SettingsDiff, SettingsState, + overlay_resize_anchor_from_str, AppStoreData, BootstrapOverlayState, BootstrapPayload, + DefaultsPayload, KeyCounterSettings, KeyCounters, KeyMappings, + KeySoundOutputBackendPersist, OverlayBounds, OverlayResizeAnchor, SettingsDiff, + SettingsState, }, services::{css_watcher::CssWatcher, obs_bridge::ObsBridgeService, settings::SettingsService}, + state::local_asset_path::path_identity_key, }; const OVERLAY_LABEL: &str = "overlay"; @@ -131,6 +134,10 @@ pub struct AppState { /// Raw input stream subscriber count - emit only when > 0 raw_input_subscribers: Arc, key_sound: Arc, + /// 전역 CSS 상태와 워처 전환 직렬화 + css_operation_lock: Mutex<()>, + /// 현재 세션에서 사용자가 승인한 CSS 경로 + authorized_css_paths: RwLock>, /// CSS 파일 핫리로딩 워처 css_watcher: RwLock>, /// OBS WebSocket 브릿지 @@ -165,6 +172,7 @@ impl AppState { .unwrap_or_default(); let key_sound = Arc::new(KeySoundEngine::with_output_backend(initial_backend)); let obs_bridge = Arc::new(ObsBridgeService::new(env!("CARGO_PKG_VERSION"))); + let authorized_css_paths = collect_authorized_css_paths(&snapshot); Ok(Self { store, @@ -180,6 +188,8 @@ impl AppState { key_counter_enabled, raw_input_subscribers: Arc::new(std::sync::atomic::AtomicU32::new(0)), key_sound, + css_operation_lock: Mutex::new(()), + authorized_css_paths: RwLock::new(authorized_css_paths), css_watcher: RwLock::new(None), obs_bridge, obs_previous_overlay_visible: Arc::new(RwLock::new(None)), @@ -2099,6 +2109,41 @@ impl AppState { // ========== CSS 핫리로딩 관련 메서드 ========== + pub(crate) fn lock_css_operation(&self) -> parking_lot::MutexGuard<'_, ()> { + self.css_operation_lock.lock() + } + + pub(crate) fn authorize_css_path(&self, path: &str) { + self.authorized_css_paths + .write() + .insert(path_identity_key(Path::new(path))); + } + + pub(crate) fn is_css_path_authorized(&self, path: &str) -> bool { + self.authorized_css_paths + .read() + .contains(&path_identity_key(Path::new(path))) + } + + pub(crate) fn resync_global_css_watcher( + &self, + previous: &AppStoreData, + current: &AppStoreData, + ) { + let previous_path = global_css_watch_path(previous); + let current_path = global_css_watch_path(current); + if previous_path == current_path { + return; + } + + self.unwatch_global_css(); + if let Some(path) = current_path { + if let Err(error) = self.watch_global_css(path) { + log::warn!("[AppState] Failed to resync global CSS watcher: {error}"); + } + } + } + /// CSS 워처 초기화 fn initialize_css_watcher(&self, app: &AppHandle) { let watcher = CssWatcher::new(self.store.clone(), app.clone()); @@ -2140,6 +2185,28 @@ impl AppState { } } +fn global_css_watch_path(state: &AppStoreData) -> Option<&str> { + state + .use_custom_css + .then_some(state.custom_css.path.as_deref()) + .flatten() +} + +fn collect_authorized_css_paths(state: &AppStoreData) -> HashSet { + state + .custom_css + .path + .iter() + .chain( + state + .tab_css_overrides + .values() + .filter_map(|css| css.path.as_ref()), + ) + .map(|path| path_identity_key(Path::new(path))) + .collect() +} + impl Drop for AppState { fn drop(&mut self) { self.shutdown(); @@ -2727,8 +2794,16 @@ struct OverlayPosition { mod tests { use std::collections::HashMap; - use super::{bootstrap_active_keys, should_create_overlay_on_startup}; - use crate::keyboard::KeyboardManager; + use super::{ + bootstrap_active_keys, collect_authorized_css_paths, global_css_watch_path, + should_create_overlay_on_startup, + }; + use crate::{ + keyboard::KeyboardManager, + models::{AppStoreData, CustomCss, TabCss}, + state::local_asset_path::path_identity_key, + }; + use std::path::Path; #[test] fn startup_overlay_creation_covers_all_visibility_and_obs_combinations() { @@ -2748,4 +2823,30 @@ mod tests { assert!(manager.register_key_down("4key", "KeyD")); assert_eq!(bootstrap_active_keys(&manager), vec!["KeyD"]); } + + #[test] + fn startup_authorizes_global_and_tab_css_paths_even_when_disabled() { + let mut state = AppStoreData { + use_custom_css: false, + custom_css: CustomCss { + path: Some("/tmp/global.css".to_string()), + content: String::new(), + }, + ..AppStoreData::default() + }; + state.tab_css_overrides.insert( + "4key".to_string(), + TabCss { + path: Some("/tmp/tab.css".to_string()), + content: String::new(), + enabled: false, + }, + ); + + let authorized = collect_authorized_css_paths(&state); + + assert!(authorized.contains(&path_identity_key(Path::new("/tmp/global.css")))); + assert!(authorized.contains(&path_identity_key(Path::new("/tmp/tab.css")))); + assert_eq!(global_css_watch_path(&state), None); + } } diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index b6d9b6c2..66eb3826 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -2,6 +2,7 @@ use std::{ collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; @@ -15,13 +16,15 @@ use uuid::Uuid; use super::local_asset_path::{file_url_to_path, path_identity_key, FileUrlPath}; use crate::{ + custom_css::{migrate_custom_css_history_at_load, normalize_custom_css_history}, defaults::{default_keys, default_positions}, models::{ - AppStoreData, CounterAnimationPreset, CustomCss, CustomFont, CustomJs, CustomTab, FontType, - GradientSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, - JsPlugin, KeyCounters, KeyMappings, KeyPosition, KeyPositions, KnobPosition, KnobPositions, - LayerGroupDef, LayerGroups, NoteSettings, OverlayBounds, ShortcutsState, SoundLibraryEntry, - StatPosition, StatPositions, StatType, TabCss, TabNoteSettings, + AppStoreData, CounterAnimationPreset, CustomCss, CustomCssHistoryEntry, CustomFont, + CustomJs, CustomTab, FontType, GradientSpec, GraphPosition, GraphPositions, GraphStatType, + GraphType, GridSettings, JsPlugin, KeyCounters, KeyMappings, KeyPosition, KeyPositions, + KnobPosition, KnobPositions, LayerGroupDef, LayerGroups, NoteSettings, OverlayBounds, + ShortcutsState, SoundLibraryEntry, StatPosition, StatPositions, StatType, TabCss, + TabNoteSettings, }, }; @@ -80,6 +83,12 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { .map(|c| !c.trim().is_empty()) .unwrap_or(false) }); + let active_css_path = data.custom_css.path.clone(); + needs_persist |= migrate_custom_css_history_at_load( + &mut data.custom_css_history, + active_css_path.as_deref(), + current_unix_millis(), + ); // rgba로 깨진 noteBorderColor가 있으면 정규화 후 디스크에도 영속 (이슈 #73) if has_convertible_note_border_color(&data) { needs_persist = true; @@ -132,6 +141,12 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { // 정리가 발생하면 마이그레이션과 같은 경로로 디스크에도 영속 let mut state = state; let mut needs_persist = needs_persist; + let active_css_path = state.custom_css.path.clone(); + needs_persist |= migrate_custom_css_history_at_load( + &mut state.custom_css_history, + active_css_path.as_deref(), + current_unix_millis(), + ); if clear_dangling_group_ids(&mut state) { needs_persist = true; } @@ -148,6 +163,13 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { }) } +fn current_unix_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + .unwrap_or(0) +} + fn migrate_sound_library_enabled(value: &mut Value) -> bool { let Some(sound_library) = value.get_mut("soundLibrary").and_then(Value::as_object_mut) else { return false; @@ -761,6 +783,7 @@ fn has_convertible_note_border_color(data: &AppStoreData) -> bool { /// store 데이터 정규화 및 레거시 마이그레이션 적용 pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { + normalize_custom_css_history(&mut data.custom_css_history); repair_editor_revision(&mut data); if data.keys.is_empty() { @@ -1179,6 +1202,7 @@ fn recover_collection_field(field: &str, value: &Value) -> Option { "layerGroups" => recover_position_entries::(field, value), "keyCounters" => recover_key_counter_entries(value), "customCss" => recover_object_fields::(field, value), + "customCssHistory" => recover_array_entries::(field, value), "fontSettings" => recover_font_settings(value), "counterAnimationPresets" => recover_array_entries::(field, value), "tabCssOverrides" => recover_map_object_entries::(field, value), @@ -1823,10 +1847,10 @@ mod tests { use crate::{ defaults::{default_keys, default_positions}, models::{ - AppStoreData, CustomFont, CustomTab, FontType, GraphPosition, GraphStatType, GraphType, - KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, - KeyPosition, KnobPosition, LayerGroupDef, OverlayBounds, SoundLibraryEntry, - StatPosition, StatType, TabCss, TabNoteSettings, + AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, FontType, GraphPosition, + GraphStatType, GraphType, KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, + KeyCounterPlacement, KeyPosition, KnobPosition, LayerGroupDef, OverlayBounds, + SoundLibraryEntry, StatPosition, StatType, TabCss, TabNoteSettings, }, }; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; @@ -2956,6 +2980,140 @@ mod tests { ); } + #[test] + fn custom_css_history_normalizes_at_the_store_load_boundary() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-normalize-test-{}.json", + uuid::Uuid::new_v4() + )); + let duplicate = absolute_fixture_path("history-duplicate.css"); + let mut history = vec![ + CustomCssHistoryEntry { + path: "relative.css".to_string(), + loaded_at: 999, + last_used_at: 999, + }, + CustomCssHistoryEntry { + path: duplicate.clone(), + loaded_at: 1, + last_used_at: 1, + }, + CustomCssHistoryEntry { + path: duplicate.clone(), + loaded_at: 100, + last_used_at: 100, + }, + ]; + for index in 0..12 { + history.push(CustomCssHistoryEntry { + path: absolute_fixture_path(&format!("history-{index}.css")), + loaded_at: index, + last_used_at: index, + }); + } + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCssHistory"] = serde_json::to_value(history).unwrap(); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.needs_persist); + assert_eq!(loaded.data.custom_css_history.len(), 10); + assert!(loaded + .data + .custom_css_history + .windows(2) + .all(|pair| pair[0].last_used_at >= pair[1].last_used_at)); + assert_eq!( + loaded + .data + .custom_css_history + .iter() + .filter(|entry| entry.path == duplicate) + .count(), + 1 + ); + assert!(loaded + .data + .custom_css_history + .iter() + .all(|entry| std::path::Path::new(&entry.path).is_absolute())); + } + + #[test] + fn custom_css_history_recovers_valid_entries_individually() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-recovery-test-{}.json", + uuid::Uuid::new_v4() + )); + let first = CustomCssHistoryEntry { + path: absolute_fixture_path("history-first.css"), + loaded_at: 10, + last_used_at: 10, + }; + let second = CustomCssHistoryEntry { + path: absolute_fixture_path("history-second.css"), + loaded_at: 20, + last_used_at: 20, + }; + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCssHistory"] = json!([ + serde_json::to_value(&first).unwrap(), + { "path": 42, "lastUsedAt": "invalid" }, + serde_json::to_value(&second).unwrap() + ]); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.custom_css_history, vec![second, first]); + } + + #[test] + fn custom_css_history_migrates_loaded_at_and_seeds_active_path() { + let path = std::env::temp_dir().join(format!( + "dmnote-css-history-seed-test-{}.json", + uuid::Uuid::new_v4() + )); + let existing_path = absolute_fixture_path("history-existing.css"); + let active_path = absolute_fixture_path("history-active.css"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + fixture["customCss"] = json!({ + "path": active_path, + "content": "body {}" + }); + fixture["customCssHistory"] = json!([{ + "path": existing_path, + "lastUsedAt": 42 + }]); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(loaded.needs_persist); + assert_eq!(loaded.data.custom_css_history.len(), 2); + let existing = loaded + .data + .custom_css_history + .iter() + .find(|entry| entry.path == existing_path) + .unwrap(); + assert_eq!(existing.loaded_at, 42); + let seeded = loaded + .data + .custom_css_history + .iter() + .find(|entry| entry.path == active_path) + .unwrap(); + assert!(seeded.loaded_at >= 42); + assert_eq!(seeded.loaded_at, seeded.last_used_at); + } + #[test] fn custom_tab_whole_mode_damage_recovers_parallel_shape_only() { let path = std::env::temp_dir().join(format!( diff --git a/src/renderer/__tests__/customCssPriorityContract.test.ts b/src/renderer/__tests__/customCssPriorityContract.test.ts new file mode 100644 index 00000000..b0e82b2a --- /dev/null +++ b/src/renderer/__tests__/customCssPriorityContract.test.ts @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { computeKeyElementStyles } from '@hooks/overlay/useKeyElementStyles'; +import { getCounterTypographyStyle } from '@utils/core/counterStyles'; +import type { GradientSpec } from '@src/types/color'; + +const gradient: GradientSpec = { + angle: 135, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: 'rgba(255,0,0,0)', pos: 1 }, + ], +}; + +describe('커스텀 CSS 우선순위 계약', () => { + it('일반 키 모드는 앱 외형을 inline 속성이 아닌 fallback 변수로만 제공한다', () => { + const { keyStyle, borderRingStyle, textStyle } = computeKeyElementStyles({ + active: false, + label: 'A', + position: { + dx: 0, + dy: 0, + width: 60, + height: 60, + backgroundGradient: gradient, + borderGradient: gradient, + borderWidth: 3, + borderRadius: 8, + fontSize: 18, + fontColor: '#abcdef', + useInlineStyles: false, + }, + }); + + expect(keyStyle.backgroundColor).toBeUndefined(); + expect(keyStyle.backgroundImage).toBeUndefined(); + expect(keyStyle.backgroundClip).toBeUndefined(); + expect(keyStyle.border).toBeUndefined(); + expect(keyStyle.borderRadius).toBeUndefined(); + expect(keyStyle.padding).toBeUndefined(); + expect(keyStyle.color).toBeUndefined(); + expect(keyStyle.fontSize).toBeUndefined(); + expect(keyStyle['--dmn-key-bg-image-default']).toContain('linear-gradient'); + expect(keyStyle['--dmn-key-border-default']).toBe('none'); + expect(keyStyle['--dmn-key-padding-default']).toBe('3px'); + expect(keyStyle['--dmn-key-radius-default']).toBe('8px'); + expect(keyStyle['--dmn-key-text-color-default']).toBe('#abcdef'); + expect(borderRingStyle?.background).toBeUndefined(); + expect(borderRingStyle?.['--dmn-border-gradient-image-default']).toContain( + 'linear-gradient', + ); + expect( + (keyStyle as Record)['--dmn-key-bg-image-default'], + ).toContain('linear-gradient'); + expect(textStyle.fontSize).toBe('inherit'); + expect(textStyle.fontWeight).toBe('inherit'); + }); + + it('인라인 우선 모드에서만 속성 패널 외형을 inline으로 승격한다', () => { + const { keyStyle, borderRingStyle } = computeKeyElementStyles({ + active: false, + label: 'A', + position: { + dx: 0, + dy: 0, + width: 60, + height: 60, + backgroundGradient: gradient, + borderGradient: gradient, + borderWidth: 3, + useInlineStyles: true, + }, + }); + + expect(keyStyle.backgroundImage).toContain('linear-gradient'); + expect(keyStyle.backgroundClip).toBe('padding-box'); + expect(keyStyle.border).toBe('none'); + expect(keyStyle.padding).toBe('3px'); + expect(borderRingStyle?.background).toContain('linear-gradient'); + }); + + it('카운터 타이포도 일반 모드에서는 fallback 변수만 사용한다', () => { + const fallback = getCounterTypographyStyle({ + fontSize: 22, + fontFamily: 'Example', + fontWeight: 700, + fontItalic: true, + fontUnderline: true, + }); + const inline = getCounterTypographyStyle({ + fontSize: 22, + fontWeight: 700, + useInlineStyles: true, + }); + + expect(fallback.fontSize).toBeUndefined(); + expect(fallback.fontWeight).toBeUndefined(); + expect(fallback['--dmn-counter-font-size-default']).toBe('22px'); + expect(fallback['--dmn-counter-font-weight-default']).toBe('700'); + expect(inline.fontSize).toBe('22px'); + expect(inline.fontWeight).toBe(700); + }); + + it('전역 앱 외형은 특이도 0 규칙이고 공개 변수가 그라데이션을 끈다', () => { + const css = readFileSync( + resolve(process.cwd(), 'src/renderer/styles/global.css'), + 'utf8', + ); + + expect(css).toMatch( + /:where\(\[data-key-element\]\)\s*\{[\s\S]*?background-image:\s*var\(\s*--key-bg-image,\s*var\(--key-bg,/, + ); + const rootRule = css.match( + /:where\(\[data-key-element\]\)\s*\{[\s\S]*?\n\}/, + )?.[0]; + expect(rootRule).toBeDefined(); + expect(rootRule).not.toMatch(/\n\s*background\s*:/); + expect(css).toMatch( + /:where\(\[data-key-element\] > \[data-gradient-border-ring\]\)\s*\{[\s\S]*?--key-border-image,[\s\S]*?var\(--key-border,/, + ); + expect(css).toMatch( + /:where\(\.counter\)\s*\{[\s\S]*?--counter-fill-image,[\s\S]*?--counter-color,/, + ); + expect(css).toMatch(/:where\(\[data-graph-element\]\)/); + expect(css).toMatch(/:where\(\[data-knob-element\]\)/); + }); + + it('ko/en 문서는 인라인 우선 모드를 직접 CSS 속성으로 덮어쓴다', () => { + for (const locale of ['en', 'ko']) { + const docs = readFileSync( + resolve( + process.cwd(), + `docs/content/${locale}/custom-css/variables/page.mdx`, + ), + 'utf8', + ); + expect(docs).not.toContain('--key-bg: #ff2b80 !important'); + expect(docs).toContain('background: #ff2b80 !important'); + } + }); +}); diff --git a/src/renderer/api/modules/cssApi.ts b/src/renderer/api/modules/cssApi.ts index 26cc4128..63151fc1 100644 --- a/src/renderer/api/modules/cssApi.ts +++ b/src/renderer/api/modules/cssApi.ts @@ -2,9 +2,11 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; import type { + CssActivateResult, CssLoadResult, CssSetContentResult, CssTogglePayload, + CustomCssHistoryItem, } from '@src/types/plugin/api'; import type { CustomCss } from '@src/types/plugin/css'; @@ -17,6 +19,11 @@ export const cssApi = { setContent: (content: string) => invoke('css_set_content', { content }), reset: () => invoke('css_reset'), + historyGet: () => invoke('css_history_get'), + historyActivate: (path: string) => + invoke('css_history_activate', { path }), + historyRemove: (path: string) => + invoke('css_history_remove', { path }), onUse: (listener: (payload: CssTogglePayload) => void) => subscribe('css:use', listener), onContent: (listener: (payload: CustomCss) => void) => @@ -49,6 +56,21 @@ export const cssApi = { enabled, }, ), + activateHistory: (tabId: string, path: string) => + invoke( + 'css_tab_activate_history', + { + tabId, + path, + }, + ), + export: (tabId: string) => + invoke( + 'css_tab_export', + { + tabId, + }, + ), set: (tabId: string, css: import('@src/types/plugin/css').TabCss | null) => invoke('css_tab_set', { tabId, diff --git a/src/renderer/assets/svgs/plus.svg b/src/renderer/assets/svgs/plus.svg deleted file mode 100644 index a3487852..00000000 --- a/src/renderer/assets/svgs/plus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/renderer/assets/svgs/plus2.svg b/src/renderer/assets/svgs/plus2.svg deleted file mode 100644 index a099a860..00000000 --- a/src/renderer/assets/svgs/plus2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/renderer/components/main/Modal/FloatingPopup.test.tsx b/src/renderer/components/main/Modal/FloatingPopup.test.tsx index bec6fd41..3e97dabb 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.test.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.test.tsx @@ -120,6 +120,51 @@ describe('FloatingPopup focus contract', () => { expect(document.activeElement).toBe(opener); }); + it('focuses the surface first when initialFocus is surface', async () => { + const opener = document.createElement('button'); + document.body.prepend(opener); + opener.focus(); + + const renderSurfacePopup = async (open: boolean) => { + await act(async () => { + root.render( + undefined} + > + {open ? : null} + , + ); + }); + }; + + await renderSurfacePopup(true); + const dialog = document.querySelector( + '[role="dialog"][aria-modal="false"]', + ); + // 입력 필드가 아니라 표면이 최초 포커스를 가짐 + expect(document.activeElement).toBe(dialog); + + // 첫 Tab은 첫 포커서블 자식으로 이동 + const forward = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(forward); + expect(forward.defaultPrevented).toBe(true); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Search'); + + // 닫으면 opener로 복원 + await renderSurfacePopup(false); + expect(document.activeElement).toBe(opener); + }); + it('exposes an accessible dialog name', async () => { await renderPopup(true, ); diff --git a/src/renderer/components/main/Modal/FloatingPopup.tsx b/src/renderer/components/main/Modal/FloatingPopup.tsx index 9d01922c..b7c06b14 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.tsx @@ -31,6 +31,8 @@ interface FloatingPopupBaseProps { animate?: boolean; onKeyDown?: React.KeyboardEventHandler; focusOriginRef?: React.MutableRefObject; + /** surface: 열릴 때 첫 포커서블 대신 팝업 표면에 포커스 (입력 필드 자동 포커스 방지) */ + initialFocus?: 'first' | 'surface'; } interface FloatingDialogPopupProps extends FloatingPopupBaseProps { @@ -71,6 +73,7 @@ interface FloatingPopupSurfaceProps { onMenuTab?: (event: KeyboardEvent) => void; onKeyDown?: React.KeyboardEventHandler; focusOriginRef?: React.MutableRefObject; + initialFocus?: 'first' | 'surface'; children?: React.ReactNode; } @@ -83,6 +86,7 @@ const FloatingPopupSurface = ({ onMenuTab, onKeyDown, focusOriginRef, + initialFocus = 'first', children, }: FloatingPopupSurfaceProps) => { const surfaceRef = useRef(null); @@ -107,13 +111,15 @@ const FloatingPopupSurface = ({ } if (surface.contains(document.activeElement)) return; const initialTarget = - role === 'menu' + initialFocus === 'surface' + ? surface + : role === 'menu' ? surface.querySelector( '[role^="menuitem"]:not(:disabled)', ) ?? surface : getFocusableElements(surface)[0] ?? surface; initialTarget.focus(); - }, [focusOriginRef, role]); + }, [focusOriginRef, initialFocus, role]); useLayoutEffect(() => { const prevFocused = prevFocusedRef.current; @@ -202,6 +208,7 @@ const FloatingPopup = ({ onKeyDown, onMenuTab, focusOriginRef, + initialFocus, }: FloatingPopupProps) => { const { x, y, refs, strategy, update } = useFloating({ placement: placement as Placement, @@ -474,6 +481,7 @@ const FloatingPopup = ({ onMenuTab={onMenuTab} onKeyDown={onKeyDown} focusOriginRef={focusOriginRef} + initialFocus={initialFocus} > {children} diff --git a/src/renderer/components/main/Modal/ListPopup.tsx b/src/renderer/components/main/Modal/ListPopup.tsx index 4194a57d..903ab30c 100644 --- a/src/renderer/components/main/Modal/ListPopup.tsx +++ b/src/renderer/components/main/Modal/ListPopup.tsx @@ -8,8 +8,6 @@ export type ListItem = { id: string; label: string; disabled?: boolean; - /** 구분선 항목 */ - type?: 'item' | 'separator'; /** 토글 항목의 체크 상태 */ checked?: boolean; /** 서브메뉴 항목 */ @@ -33,6 +31,24 @@ interface ListPopupProps { maxVisibleItems?: number; } +// 아이템 26 + 갭 4 리듬 공용 스크롤 계산 — 메인 메뉴·서브메뉴가 함께 사용 +const ITEM_HEIGHT = 26; +const ITEM_GAP = 4; +const SCROLL_EDGE_PADDING = 6; + +const getListScrollMetrics = ( + itemCount: number, + maxVisibleItems?: number, +): { needsScroll: boolean; maxHeight: number | undefined } => { + if (maxVisibleItems == null || itemCount <= maxVisibleItems) { + return { needsScroll: false, maxHeight: undefined }; + } + return { + needsScroll: true, + maxHeight: maxVisibleItems * (ITEM_HEIGHT + ITEM_GAP) + SCROLL_EDGE_PADDING, + }; +}; + const DOCUMENT_FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', @@ -201,15 +217,10 @@ const SubMenu = ({ firstItem?.focus(); }, [focusFirst, pos]); - const itemHeight = 26; - const itemGap = 4; - const separatorCount = items.filter((i) => i.type === 'separator').length; - const normalItemCount = items.length - separatorCount; - const effectiveMax = maxVisibleItems ?? normalItemCount; - const needsScroll = normalItemCount > effectiveMax; - const maxHeight = needsScroll - ? effectiveMax * (itemHeight + itemGap) + separatorCount * (1 + itemGap) + 6 - : undefined; + const { needsScroll, maxHeight } = getListScrollMetrics( + items.length, + maxVisibleItems, + ); const hasCheckColumn = items.some((it) => typeof it.checked === 'boolean'); const { scrollContainerRef: subLenisRef } = useLenis({ @@ -345,15 +356,6 @@ const MenuItemRow = ({ }; }, []); - // 구분선 (부모 p-[4px] 패딩을 무시하고 전체 폭 사용) - if (item.type === 'separator') { - return ( -
-
-
- ); - } - const hasCheck = typeof item.checked === 'boolean'; const handleSelect = () => { @@ -513,18 +515,10 @@ const ListPopup = ({ 'z-40 bg-glass backdrop-glass-popup shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px]'; const effectiveClassName = `${defaultClassName} ${className}`.trim(); - // 스크롤 필요 여부 계산 (아이템 26 + 갭 4 리듬) - const itemHeight = 26; - const itemGap = 4; - const separatorCount = items.filter((i) => i.type === 'separator').length; - const normalItemCount = items.length - separatorCount; - const needsScroll = - maxVisibleItems != null && normalItemCount > maxVisibleItems; - const maxHeight = needsScroll - ? maxVisibleItems * (itemHeight + itemGap) + - separatorCount * (1 + itemGap) + - 6 - : undefined; + const { needsScroll, maxHeight } = getListScrollMetrics( + items.length, + maxVisibleItems, + ); const hasCheckColumn = items.some((it) => typeof it.checked === 'boolean'); const siblingActiveRef = useRef<{ diff --git a/src/renderer/components/main/Modal/ManagerModalLayout.tsx b/src/renderer/components/main/Modal/ManagerModalLayout.tsx deleted file mode 100644 index 74e93e12..00000000 --- a/src/renderer/components/main/Modal/ManagerModalLayout.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { - useRef, - useState, - useLayoutEffect, - type ReactNode, -} from 'react'; -import { useLenis } from '@hooks/useLenis'; -import Modal from './Modal'; - -const MAX_SCROLL_HEIGHT = 195; - -interface ManagerModalLayoutProps { - isOpen: boolean; - onClose: () => void; - /** 탭 영역 (TabSwitch 등) */ - tabs?: ReactNode; - /** 스크롤 영역 내부 콘텐츠 */ - children: ReactNode; - /** 하단 버튼 영역 */ - footer: ReactNode; - /** 스크롤 영역 아래 추가 콘텐츠 (로딩/에러 메시지 등) */ - extra?: ReactNode; - /** 콘텐츠 변경 시 높이/스크롤 재계산 트리거 의존성 */ - contentDeps?: unknown[]; - /** 스크린리더용 다이얼로그 이름 */ - ariaLabel?: string; -} - -const ManagerModalLayout = ({ - isOpen, - onClose, - tabs, - children, - footer, - extra, - contentDeps = [], - ariaLabel, -}: ManagerModalLayoutProps) => { - const contentRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(null); - const [isScrollable, setIsScrollable] = useState(false); - const isFirstRender = useRef(true); - - const { scrollContainerRef: scrollRef } = useLenis(); - - useLayoutEffect(() => { - if (!isOpen) { - isFirstRender.current = true; - return; - } - - setIsScrollable(false); - - const contentEl = contentRef.current; - if (!contentEl) return; - - const updateHeight = () => { - const contentHeight = contentEl.scrollHeight; - setContainerHeight(Math.min(contentHeight, MAX_SCROLL_HEIGHT)); - setIsScrollable(contentHeight > MAX_SCROLL_HEIGHT); - }; - - // 콘텐츠 크기 변경 감지 (높이 애니메이션용) - const resizeObserver = new ResizeObserver(updateHeight); - resizeObserver.observe(contentEl); - updateHeight(); - - const rafId = requestAnimationFrame(() => { - isFirstRender.current = false; - }); - - return () => { - resizeObserver.disconnect(); - cancelAnimationFrame(rafId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, ...contentDeps]); - - if (!isOpen) return null; - - return ( - -
e.stopPropagation()} - > - {/* 탭 영역 */} - {tabs &&
{tabs}
} - - {/* 스크롤 영역 */} -
-
- {children} -
-
- - {/* 하단 버튼 */} -
{footer}
- - {/* 추가 콘텐츠 (로딩/에러 등) */} - {extra} -
-
- ); -}; - -export default ManagerModalLayout; diff --git a/src/renderer/components/main/Modal/Modal.tsx b/src/renderer/components/main/Modal/Modal.tsx index 0d46c4c2..3f233b32 100644 --- a/src/renderer/components/main/Modal/Modal.tsx +++ b/src/renderer/components/main/Modal/Modal.tsx @@ -151,6 +151,17 @@ const Modal = ({ e.stopPropagation(); }; + // 포털이어도 React 합성 이벤트는 React 트리로 버블링 - 그리드 등 뒤 표면의 + // 우클릭 메뉴가 모달 위 우클릭에 반응하지 않게 차단 + const handleContextMenu = (e: React.MouseEvent) => { + e.stopPropagation(); + // 텍스트 입력의 네이티브 편집 메뉴는 보존 + const target = e.target as HTMLElement; + if (!target.closest('input, textarea, [contenteditable="true"]')) { + e.preventDefault(); + } + }; + // 스크림 딤은 모달의 조상이 아니라 형제 언더레이가 소유해야 함 — // 조상이 backdrop-filter나 반투명을 가지면 backdrop root가 생겨 모달 카드 // 글래스의 블러가 WebKit에서 배경을 샘플링하지 못함. 형제 레이어는 정상 합성됨 @@ -171,6 +182,7 @@ const Modal = ({ onPointerDown={handleBackdropPointerDown} onClick={handleBackdropClick} onWheel={handleWheel} + onContextMenu={handleContextMenu} > {/* 스크림 언더레이 — 클릭은 래퍼로 통과. 전면 시트는 스크림 생략 — 시트가 영역을 다 덮어 어둡히기가 무의미하고, diff --git a/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx b/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx index 054acc2d..f4006ef9 100644 --- a/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx @@ -1,13 +1,21 @@ -/* eslint-disable react-hooks/set-state-in-effect */ import React, { useEffect, useState, useRef } from 'react'; import Modal from '../../Modal'; import Checkbox from '@components/main/common/Checkbox'; +import { PropertySection } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; import { - PropertyRow, - PropertySection, -} from '@components/main/Grid/PropertiesPanel/PropertyInputs'; + FILL_DISABLED_CLASS, + FILL_INTERACTIVE_CLASS, + PANEL_APPLIED_LABEL_CLASS, + PANEL_PILL_CLASS, + PANEL_STATUS_BADGE_CLASS, +} from '@components/main/SettingsPanel/panelChrome'; +import { FORM_ROW_CLASS, FORM_LABEL_CLASS } from '@utils/cardRecipes'; import { useTranslation } from '@contexts/useTranslation'; +import { useLenis } from '@hooks/useLenis'; import { useKeyStore } from '@stores/data/useKeyStore'; +import { pathBaseName } from '@utils/core/pathDisplay'; +import { cssHistoryStatusLabel } from '@utils/cssHistoryStatus'; +import type { CustomCssHistoryItem } from '@src/types/plugin/api'; import type { TabCss } from '@src/types/plugin/css'; interface TabCssModalProps { @@ -16,17 +24,44 @@ interface TabCssModalProps { showAlert?: (message: string, confirmText?: string) => void; } +const ACTION_BUTTON_CLASS = + 'flex-1 h-[23px] rounded-md flex items-center justify-center text-body transition-colors duration-fast'; + const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { const { t } = useTranslation(); const selectedKeyType = useKeyStore((state) => state.selectedKeyType); const [tabCss, setTabCss] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [history, setHistory] = useState([]); + const [pendingHistoryPath, setPendingHistoryPath] = useState( + null, + ); + const [isExporting, setIsExporting] = useState(false); + + const { scrollContainerRef: historyScrollRef } = useLenis(); // 모달 열기 시점의 원본 상태 저장 (취소 시 복원용) const originalStateRef = useRef(null); - // 모달이 열릴 때 현재 탭의 CSS 정보 로드 및 원본 상태 저장 + // 진행 중 백엔드 변경 - 취소가 이보다 먼저 비교하면 취소한 변경이 + // 뒤늦게 커밋되는 레이스가 생기므로 취소 시 완료를 기다림 + const pendingMutationRef = useRef | null>(null); + + const invokeTracked = async (op: () => Promise): Promise => { + const promise = op(); + pendingMutationRef.current = promise.then( + () => undefined, + () => undefined, + ); + try { + return await promise; + } finally { + pendingMutationRef.current = null; + } + }; + + // 모달이 열릴 때 현재 탭의 CSS 정보와 글로벌 히스토리 로드, 원본 상태 저장 useEffect(() => { if (!isOpen) return; @@ -45,6 +80,13 @@ const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { .finally(() => { setIsLoading(false); }); + + window.api.css + .historyGet() + .then(setHistory) + .catch((error) => { + console.error('Failed to fetch CSS history:', error); + }); }, [isOpen, selectedKeyType]); // 탭 CSS 변경 이벤트 구독 (실시간 미리보기 반영) @@ -77,7 +119,9 @@ const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { const handleClearCss = async () => { try { - const result = await window.api.css.tab.clear(selectedKeyType); + const result = await invokeTracked(() => + window.api.css.tab.clear(selectedKeyType), + ); if (result.success) { setTabCss(null); } @@ -89,9 +133,8 @@ const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { const handleToggleCss = async () => { const newEnabled = !(tabCss?.enabled ?? true); try { - const result = await window.api.css.tab.toggle( - selectedKeyType, - newEnabled, + const result = await invokeTracked(() => + window.api.css.tab.toggle(selectedKeyType, newEnabled), ); if (result.success) { setTabCss((prev) => @@ -105,22 +148,73 @@ const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { } }; + // 글로벌 히스토리 항목을 현재 탭 CSS로 적용 + const handleApplyHistory = async (item: CustomCssHistoryItem) => { + if (pendingHistoryPath || item.status !== 'available') return; + if (tabCss?.path === item.path) return; + setPendingHistoryPath(item.path); + try { + const result = await invokeTracked(() => + window.api.css.tab.activateHistory(selectedKeyType, item.path), + ); + if (result.success && result.css) { + setTabCss(result.css); + } else if (!result.success) { + showAlert?.( + result.code + ? t(`settings.cssHistoryError.${result.code}`) + : t('tabCss.loadFailed'), + ); + } + } catch (error) { + console.error('Failed to apply history CSS to tab:', error); + } finally { + setPendingHistoryPath(null); + } + }; + + // 현재 탭 CSS 콘텐츠를 파일로 내보내기 + const handleExport = async () => { + if (isExporting) return; + setIsExporting(true); + try { + const result = await window.api.css.tab.export(selectedKeyType); + if (result.success) { + showAlert?.(t('tabCss.exported')); + } else if (result.code) { + showAlert?.( + t('tabCss.exportFailed') + (result.error ? ': ' + result.error : ''), + ); + } + // 저장 다이얼로그 취소(success:false, code 없음)는 무시 + } catch (error) { + console.error('Failed to export tab CSS:', error); + showAlert?.(t('tabCss.exportFailed')); + } finally { + setIsExporting(false); + } + }; + // 저장: 현재 상태 유지하고 모달 닫기 const handleSave = () => { onClose(); }; - // 취소: 원본 상태로 복원하고 모달 닫기 + // 취소: 진행 중 변경 커밋을 기다린 뒤 백엔드 상태 기준으로 원본 복원 + // (로컬 state 비교는 응답 도착 전 취소 시 변경 없음으로 오판) const handleCancel = async () => { const original = originalStateRef.current; try { - // 원본 상태와 현재 상태가 다른 경우에만 복원 - const currentState = tabCss; + if (pendingMutationRef.current) { + await pendingMutationRef.current; + } + const { css } = await window.api.css.tab.get(selectedKeyType); + const current = css || null; const statesAreDifferent = - original?.path !== currentState?.path || - original?.content !== currentState?.content || - original?.enabled !== currentState?.enabled; + original?.path !== current?.path || + original?.content !== current?.content || + original?.enabled !== current?.enabled; if (statesAreDifferent) { // css.tab.set을 사용하여 원본 상태로 직접 복원 @@ -135,44 +229,141 @@ const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { if (!isOpen) return null; - const hasTabCss = tabCss && tabCss.path; + const hasTabCss = Boolean(tabCss && tabCss.path); const cssEnabled = tabCss?.enabled ?? true; + const canExport = Boolean(tabCss?.content) && !isExporting; return (
e.stopPropagation()} > - {/* CSS 설정 카드 */} + {/* 상태 카드 - 토글, 적용 파일, 파일 액션 */} - - - - + {/* 행 전체가 button role=switch라 키보드로도 조작 가능 */} + +
+

+ {t('tabCss.cssFile')} +

+ + {tabCss?.path + ? pathBaseName(tabCss.path) + : t('settings.noCssFile')} + +
+ {/* 파일 액션 - 전폭 3등분으로 여백 확보 */} +
+ + - - +
+ {/* 글로벌 히스토리 - 헤더 없이 목록만, 히스토리가 없으면 통째로 숨김 */} + {history.length > 0 && ( +
+ {/* 4행(30px) + 상단 패딩 + 다섯째 행 24px 피크 - 하단 페이드(20px)가 + 피크 구간에 걸려 잘림 대신 스크롤 여지로 읽힘 */} +
+
+ {history.map((item) => { + const badge = cssHistoryStatusLabel(t, item); + const available = item.status === 'available'; + const isCurrent = tabCss?.path === item.path; + const applicable = available && !isCurrent; + return ( +
+ + {pathBaseName(item.path)} + + {badge ? ( + + {badge} + + ) : null} + {isCurrent ? ( + + {t('settings.cssApplied')} + + ) : ( + + )} +
+ ); + })} +
+
+
+ )} + {/* 버튼 영역 */}
- - - } - > - {plugins.length === 0 ? ( -
- {t('settings.noPlugins')} -
- ) : ( - plugins.map((plugin: Plugin) => { - const isPending = - pendingPluginAction && pendingPluginAction.id === plugin.id; - const isRemovePending = - isPending && pendingPluginAction.op === 'remove'; - return ( -
-
- - {plugin.name} -
-
- { - if (pendingPluginAction) return; - onToggle(plugin.id, !plugin.enabled); - }} - /> -
-
- ); - }) - )} - -); diff --git a/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx b/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx index b8c31bde..5e8452f4 100644 --- a/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx +++ b/src/renderer/components/main/Modal/content/pickers/CommonListPickerPage.tsx @@ -1,6 +1,8 @@ import React, { useEffect } from 'react'; import { useLenis } from '@hooks/useLenis'; import Dropdown from '@components/main/common/Dropdown'; +import SearchField from '@components/main/common/SearchField'; +import AddIconButton from '@components/main/common/AddIconButton'; type FilterOption = { value: string; @@ -93,43 +95,17 @@ export default function CommonListPickerPage({
{/* 검색 — 리스트와 인접한 프라이머리 컨트롤 */}
-
- - - - - onSearchQueryChange(event.target.value)} - onKeyDown={(event) => { - if (event.key !== 'Escape') return; - event.preventDefault(); - event.stopPropagation(); - onBack(); - }} - placeholder={searchPlaceholder} - aria-label={searchPlaceholder} - className="w-full h-[30px] pl-[30px] pr-[10px] bg-inset rounded-surface text-fg text-body placeholder-fg-faint focus:shadow-focus-ring outline-none transition-shadow duration-fast" - /> -
+ { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + onBack(); + }} + />
{/* 리스트 컨테이너 — 배경보다 한 단계 밝은 필 테이블. 빈 공간도 테이블의 빈 영역으로 읽힘 */}
@@ -151,7 +127,7 @@ export default function CommonListPickerPage({

) : null} {errorText ? ( -

+

{errorText}

) : null} @@ -172,23 +148,12 @@ export default function CommonListPickerPage({ />
) : null} - + label={addLabel} + className="ml-auto" + />
diff --git a/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx b/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx index 3ede14f3..9e13d1ee 100644 --- a/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx +++ b/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx @@ -432,11 +432,7 @@ const WebFontInputModal = ({ > {extractedFontFamily}

-