From 78aab0eca4ad5d3e0785af4ca0d75cc1ec51e029 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 29 Jan 2026 13:47:56 -0600 Subject: [PATCH] Implement one-shot mode for explicit confirmation in ServeSession - Introduced one-shot mode to block automatic outgoing writes and require explicit user confirmation for all changes. - Updated confirmation logic in App:startSession to ensure no auto-acceptance occurs during one-shot mode. - Enhanced logging to provide clarity on actions taken during one-shot mode, improving user awareness and control over sync operations. --- plugin/src/App/init.lua | 132 +++++++++++++++++------------------- plugin/src/ServeSession.lua | 16 +++++ 2 files changed, 80 insertions(+), 68 deletions(-) diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 48cf72ef..42e84154 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -709,87 +709,76 @@ function App:startSession() end) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) - -- Helper to schedule one-shot disconnect after auto-accept - local function scheduleOneShotDisconnect() - if Settings:get("oneShotSync") then - task.delay(0.5, function() - if self.serveSession then - Log.info("One-shot sync: disconnecting after sync") - self:endSession() - end - end) + local isOneShotMode = Settings:get("oneShotSync") + + -- ONE-SHOT MODE: Never auto-accept, always require explicit confirmation + -- This ensures nothing can "sneak through" without user review + if not isOneShotMode then + if PatchSet.isEmpty(patch) then + Log.trace("Accepting patch without confirmation because it is empty") + return "Accept" end - end - if PatchSet.isEmpty(patch) then - Log.trace("Accepting patch without confirmation because it is empty") - scheduleOneShotDisconnect() - return "Accept" - end + -- Play solo auto-connect does not require confirmation + if self:isAutoConnectPlaytestServerAvailable() then + Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled") + return "Accept" + end - -- Play solo auto-connect does not require confirmation - if self:isAutoConnectPlaytestServerAvailable() then - Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled") - scheduleOneShotDisconnect() - return "Accept" - end - - local confirmationBehavior = Settings:get("confirmationBehavior") - if confirmationBehavior ~= "Always" then - if confirmationBehavior == "Initial" then - -- Only confirm if we haven't synced this project yet this session - if self.knownProjects[serverInfo.projectName] then - Log.trace( - "Accepting patch without confirmation because project has already been connected and behavior is set to Initial" - ) - scheduleOneShotDisconnect() - return "Accept" - end - elseif confirmationBehavior == "Large Changes" then - -- Only confirm if the patch impacts many instances - if PatchSet.countInstances(patch) < Settings:get("largeChangesConfirmationThreshold") then - Log.trace( - "Accepting patch without confirmation because patch is small and behavior is set to Large Changes" - ) - scheduleOneShotDisconnect() - return "Accept" - end - elseif confirmationBehavior == "Unlisted PlaceId" then - -- Only confirm if the current placeId is not in the servePlaceIds allowlist - if serverInfo.expectedPlaceIds then - local isListed = table.find(serverInfo.expectedPlaceIds, game.PlaceId) ~= nil - if isListed then + local confirmationBehavior = Settings:get("confirmationBehavior") + if confirmationBehavior ~= "Always" then + if confirmationBehavior == "Initial" then + -- Only confirm if we haven't synced this project yet this session + if self.knownProjects[serverInfo.projectName] then Log.trace( - "Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId" + "Accepting patch without confirmation because project has already been connected and behavior is set to Initial" ) - scheduleOneShotDisconnect() return "Accept" end + elseif confirmationBehavior == "Large Changes" then + -- Only confirm if the patch impacts many instances + if PatchSet.countInstances(patch) < Settings:get("largeChangesConfirmationThreshold") then + Log.trace( + "Accepting patch without confirmation because patch is small and behavior is set to Large Changes" + ) + return "Accept" + end + elseif confirmationBehavior == "Unlisted PlaceId" then + -- Only confirm if the current placeId is not in the servePlaceIds allowlist + if serverInfo.expectedPlaceIds then + local isListed = table.find(serverInfo.expectedPlaceIds, game.PlaceId) ~= nil + if isListed then + Log.trace( + "Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId" + ) + return "Accept" + end + end + elseif confirmationBehavior == "Never" then + Log.trace("Accepting patch without confirmation because behavior is set to Never") + return "Accept" end - elseif confirmationBehavior == "Never" then - Log.trace("Accepting patch without confirmation because behavior is set to Never") - scheduleOneShotDisconnect() - return "Accept" end - end - -- The datamodel name gets overwritten by Studio, making confirmation of it intrusive - -- and unnecessary. This special case allows it to be accepted without confirmation. - if - PatchSet.hasAdditions(patch) == false - and PatchSet.hasRemoves(patch) == false - and PatchSet.containsOnlyInstance(patch, instanceMap, game) - then - local datamodelUpdates = PatchSet.getUpdateForInstance(patch, instanceMap, game) + -- The datamodel name gets overwritten by Studio, making confirmation of it intrusive + -- and unnecessary. This special case allows it to be accepted without confirmation. if - datamodelUpdates ~= nil - and next(datamodelUpdates.changedProperties) == nil - and datamodelUpdates.changedClassName == nil + PatchSet.hasAdditions(patch) == false + and PatchSet.hasRemoves(patch) == false + and PatchSet.containsOnlyInstance(patch, instanceMap, game) then - Log.trace("Accepting patch without confirmation because it only contains a datamodel name change") - scheduleOneShotDisconnect() - return "Accept" + local datamodelUpdates = PatchSet.getUpdateForInstance(patch, instanceMap, game) + if + datamodelUpdates ~= nil + and next(datamodelUpdates.changedProperties) == nil + and datamodelUpdates.changedClassName == nil + then + Log.trace("Accepting patch without confirmation because it only contains a datamodel name change") + return "Accept" + end end + else + Log.info("One-shot mode: skipping all auto-accept paths, requiring explicit confirmation") end self:setState({ @@ -845,6 +834,13 @@ function App:startSession() end) serveSession:setPatchUpdateCallback(function(instanceMap, patch) + -- ONE-SHOT MODE: Ignore all further changes during confirmation + -- They will be picked up on the next connection attempt + if Settings:get("oneShotSync") then + Log.info("One-shot mode: ignoring incoming changes during confirmation") + return + end + -- If all changes have been reverted, auto-accept the empty patch if PatchSet.isEmpty(patch) then Log.trace("Patch became empty after merging, auto-accepting") diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index 48184cc8..8a7dcc9b 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -78,6 +78,15 @@ function ServeSession.new(options) end local function onChangesFlushed(patch) + -- ONE-SHOT MODE: Block ALL automatic outgoing writes from the ChangeBatcher. + -- The only writes allowed in One-shot mode are explicit "pull" selections + -- from the confirmation dialog (handled directly in __confirmAndApplyInitialPatch). + -- This ensures nothing can be pushed to the server without explicit user action. + if Settings:get("oneShotSync") then + Log.info("One-shot mode: blocking automatic outgoing write from ChangeBatcher") + return + end + self.__apiContext:write(patch) end @@ -211,7 +220,14 @@ function ServeSession:__onWebSocketMessage(messagesPacket) end -- If we're already waiting for confirmation, merge into the patch being confirmed + -- UNLESS we're in One-shot mode, where we ignore all further changes if self.__confirmingPatch ~= nil then + if Settings:get("oneShotSync") then + Log.info("One-shot mode: ignoring incoming changes while confirming (will be picked up on next connection)") + self.__apiContext:setMessageCursor(messagesPacket.messageCursor) + return + end + Log.trace("Already confirming, merging into current patch") PatchSet.merge(self.__confirmingPatch, combinedPatch, self.__instanceMap) -- Notify the UI to update the displayed patch