diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 82f36700fa..07f766ab61 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -45,6 +45,32 @@ (defonce interval-sub (atom nil)) +;; Resolves the promise that keeps the session Web Lock held; nil when none is. +(defonce ^:private session-lock-release + (atom nil)) + +(defn- hold-session-lock + "Holds a shared Web Lock while the MCP connection is requested. Chrome + does not freeze a page that holds a Web Lock, and a frozen page cannot + run the plugin." + [] + (when-let [locks (and (nil? @session-lock-release) + (unchecked-get globals/navigator "locks"))] + (let [released (js/Promise. (fn [resolve] (reset! session-lock-release resolve))) + release @session-lock-release] + (-> (.request locks "penpot-mcp-session" #js {:mode "shared"} (constantly released)) + (.catch (fn [cause] + ;; lets a later connection request try again, unless a + ;; newer lock request has already replaced this one + (compare-and-set! session-lock-release release nil) + (log/warn :hint "unable to hold the MCP session lock" :cause cause))))))) + +(defn- release-session-lock + [] + (when-let [release @session-lock-release] + (reset! session-lock-release nil) + (release))) + (defn connect-mcp [] (ptk/reify ::connect-mcp @@ -61,7 +87,12 @@ (watch [_ state _] (if (get-in state [:mcp :connection-requested]) (rx/of (ptk/data-event ::connect)) - (rx/empty))))) + (rx/empty))) + + ptk/EffectEvent + (effect [_ state _] + (when (get-in state [:mcp :connection-requested]) + (hold-session-lock))))) (defn- start-reconnect-watcher [] @@ -134,6 +165,7 @@ ptk/EffectEvent (effect [_ _ _] + (release-session-lock) (stop-reconnect-watcher!)))) (defn- extension-hello? @@ -231,6 +263,7 @@ (.removeEventListener globals/window "message" on-message) (when @announce? (announce-session "disconnected" nil)) + (release-session-lock) (stop-reconnect-watcher!) (dp/close-plugin! default-manifest)))) (.addEventListener globals/window "message" on-message) diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 7462eb944c..77c685036d 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -13,6 +13,7 @@ [app.main.data.profile :as du] [app.main.data.workspace.mcp :as mcp] [app.plugins.register :as preg] + [app.util.globals :as globals] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] @@ -183,6 +184,47 @@ :on-next #(swap! events conj (ptk/type %)))) (t/is (= [:app.main.data.workspace.mcp/connect] @events)))) +(defn- connect + "Runs the effect of a connection request made with the given MCP state." + [mcp-state] + (let [event (mcp/connect-mcp)] + (ptk/effect event (ptk/update event {:mcp mcp-state}) nil))) + +(t/deftest ^:async test-session-lock-follows-connection-request + (let [requests (atom []) + released (atom 0) + locks #js {:request (fn [lock-name options callback] + (swap! requests conj [lock-name (.-mode options)]) + (.then (callback nil) #(swap! released inc)))}] + (await + (mock/with-mocks* + {globals/navigator #js {:locks locks}} + (connect {:enabled false}) + (t/is (empty? @requests)) + (connect {:enabled true :token-valid true}) + (connect {:enabled true :token-valid true}) + (t/is (= [["penpot-mcp-session" "shared"]] @requests)) + (ptk/effect (mcp/user-disconnect-mcp) nil nil) + (await (a/settle)) + (t/is (= 1 @released)) + (ptk/effect (mcp/user-disconnect-mcp) nil nil) + (await (a/settle)) + (t/is (= 1 @released)))))) + +(t/deftest ^:async test-session-lock-retried-after-failed-request + (let [requests (atom 0) + locks #js {:request (fn [_ _ _] + (swap! requests inc) + (js/Promise.reject (js/Error. "lock request denied")))}] + (await + (mock/with-mocks* + {globals/navigator #js {:locks locks}} + (connect {:enabled true :token-valid true}) + (await (a/settle)) + (connect {:enabled true :token-valid true}) + (await (a/settle)) + (t/is (= 2 @requests)))))) + (t/deftest test-disconnect-clears-connection-intent (let [state {:mcp {:connection-requested true :connection-status "connecting"