diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b18895fef..65d337caa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -260,6 +260,14 @@ - **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958) +### ✨ New Features + +- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959) + +### 🔧 Bug Fixes + +- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958) + --- ## [3.8.7] — 2026-05-29 diff --git a/electron/main.js b/electron/main.js index e2835296e6..ddcaa25bb6 100644 --- a/electron/main.js +++ b/electron/main.js @@ -410,6 +410,10 @@ function createTray() { try { icon = nativeImage.createFromPath(iconPath); if (icon.isEmpty()) icon = nativeImage.createEmpty(); + if (process.platform === "darwin" && !icon.isEmpty()) { + icon = icon.resize({ width: 20, height: 20 }); + icon.setTemplateImage(true); + } } catch { icon = nativeImage.createEmpty(); } diff --git a/electron/preload.js b/electron/preload.js index 18fbad3980..1979c2ef79 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -11,6 +11,84 @@ const { contextBridge, ipcRenderer } = require("electron"); +const MAC_DRAG_STYLE_ID = "omniroute-electron-drag-region-style"; +const MAC_DRAG_FALLBACK_ID = "omniroute-electron-drag-region"; +const MAC_DRAG_OBSERVER_KEY = "__omnirouteMacDragRegionObserver"; + +function installMacDragRegion() { + if (process.platform !== "darwin") return; + + const attach = () => { + if (!document.head || !document.body) return; + + document.getElementById(MAC_DRAG_STYLE_ID)?.remove(); + document.getElementById(MAC_DRAG_FALLBACK_ID)?.remove(); + + const style = document.createElement("style"); + style.id = MAC_DRAG_STYLE_ID; + style.textContent = ` + header, + .omniroute-electron-drag-region { + app-region: drag; + -webkit-app-region: drag; + user-select: none; + } + + header a, + header button, + header input, + header select, + header textarea, + header [role="button"], + header [role="link"], + header [tabindex]:not([tabindex="-1"]) { + app-region: no-drag; + -webkit-app-region: no-drag; + } + + .omniroute-electron-drag-region { + position: fixed; + top: 0; + left: 96px; + right: 180px; + height: 46px; + z-index: 9999; + } + `; + + const dragRegion = document.createElement("div"); + dragRegion.id = MAC_DRAG_FALLBACK_ID; + dragRegion.className = "omniroute-electron-drag-region"; + dragRegion.setAttribute("aria-hidden", "true"); + + document.head.appendChild(style); + document.body.appendChild(dragRegion); + + const syncDragFallback = () => { + const hasHeader = Boolean(document.querySelector("header")); + dragRegion.hidden = hasHeader; + if (hasHeader) observer.disconnect(); + }; + const previousObserver = window[MAC_DRAG_OBSERVER_KEY]; + if (previousObserver) previousObserver.disconnect(); + + const observer = new MutationObserver(syncDragFallback); + observer.observe(document.body, { childList: true, subtree: true }); + window[MAC_DRAG_OBSERVER_KEY] = observer; + window.setTimeout(() => observer.disconnect(), 5000); + window.addEventListener("pagehide", () => observer.disconnect(), { once: true }); + syncDragFallback(); + }; + + if (document.readyState === "loading") { + window.addEventListener("DOMContentLoaded", attach, { once: true }); + } else { + attach(); + } +} + +installMacDragRegion(); + // ── Channel Whitelist ────────────────────────────────────── const VALID_CHANNELS = { invoke: [ diff --git a/tests/unit/electron-preload.test.ts b/tests/unit/electron-preload.test.ts index 0d21ad0422..e9a608e04b 100644 --- a/tests/unit/electron-preload.test.ts +++ b/tests/unit/electron-preload.test.ts @@ -10,6 +10,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const preloadSource = readFileSync(resolve(process.cwd(), "electron/preload.js"), "utf8"); // ─── Channel Whitelist Tests ───────────────────────────────── @@ -179,6 +183,46 @@ describe("Preload Listener Disposer Pattern", () => { }); }); +// ─── macOS Drag Region Tests ───────────────────────────────── + +describe("macOS Drag Region", () => { + it("should make the real header draggable when available", () => { + assert.match(preloadSource, /header,\s*\.omniroute-electron-drag-region/); + assert.match(preloadSource, /document\.querySelector\("header"\)/); + }); + + it("should preserve pointer events on header controls", () => { + for (const selector of ["a", "button", "input", "select", "textarea"]) { + assert.ok(preloadSource.includes(selector)); + } + assert.match(preloadSource, /-webkit-app-region: no-drag/); + }); + + it("should use a moderate fallback layer", () => { + assert.match(preloadSource, /z-index: 9999/); + assert.match(preloadSource, /left: 96px/); + assert.match(preloadSource, /right: 180px/); + assert.ok(!preloadSource.includes("2147483647")); + }); + + it("should guard DOM attachment and replace prior injected elements", () => { + assert.match(preloadSource, /if \(!document\.head \|\| !document\.body\) return/); + assert.match(preloadSource, /getElementById\(MAC_DRAG_STYLE_ID\)\?\.remove\(\)/); + assert.match(preloadSource, /getElementById\(MAC_DRAG_FALLBACK_ID\)\?\.remove\(\)/); + }); + + it("should avoid modern CSS pseudo-classes for drag selectors", () => { + assert.ok(!preloadSource.includes(":is(")); + assert.ok(!preloadSource.includes(":has(")); + assert.match(preloadSource, /new MutationObserver\(syncDragFallback\)/); + }); + + it("should stop observing after the real header appears", () => { + assert.match(preloadSource, /if \(hasHeader\) observer\.disconnect\(\)/); + assert.match(preloadSource, /setTimeout\(\(\) => observer\.disconnect\(\), 5000\)/); + }); +}); + // ─── Generic Wrapper Tests (#16) ───────────────────────────── describe("Generic IPC Wrappers", () => {