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.
This commit is contained in:
Joe 2026-01-29 13:47:56 -06:00
parent d002c4e821
commit 78aab0eca4
2 changed files with 80 additions and 68 deletions

View file

@ -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")

View file

@ -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