CVE-2022-4879 (published 2023-01-06T11:15Z)

A vulnerability was found in Forged Alliance Forever up to 3746. It has been declared as critical. Affected by this vulnerability is an unknown functionality of the component Vote Handler. The manipulation leads to improper authorization. Upgrading to version 3747 is able to address this issue. The patch is named 6880971bd3d73d942384aff62d53058c206ce644. It is recommended to upgrade the affected component. The associated identifier of this vulnerability is VDB-217555.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N)

Weakness information:
cwe_id: NVD-CWE-Other
cwe_name: Other
description: NVD is only using a subset of CWE for mapping instead of the entire CWE, and the weakness type is not covered by that subset.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/FAForever/fa
commit_details: repo_url: https://github.com/FAForever/fa
author: G C
author_date: 2022-11-15 09:05:38-08:00
msg: Update recall feature (#4398)

Fix a few exploits that people could use to vote for another player
repository: repo_url: https://github.com/FAForever/fa
repo_name: FAForever/fa
description: Lua code for FAF
repo_language: Lua
owner: FAForever
file_changes: filename: SimSync.lua
change_type: ModificationType.MODIFY
diff: @@ -125,12 +125,12 @@ end
 function NoteFocusArmyChanged(new, old)
     --LOG('NoteFocusArmyChanged(new=' .. repr(new) .. ', old=' .. repr(old) .. ')')
     import("/lua/simping.lua").OnArmyChange()
+    import("/lua/sim/recall.lua").OnArmyChange()
     for entityID, data in UnitData do
         if new == -1 or data.OwnerArmy == new then
             Sync.UnitData[entityID] = data.Data
         elseif old == -1 or data.OwnerArmy == old then
             Sync.ReleaseIds[entityID] = true
-        else
         end
     end
     SyncUnitEnhancements()

programming_language: Lua
filename: SimUtils.lua
change_type: ModificationType.MODIFY
diff: @@ -703,6 +703,7 @@ function BreakAlliance(data)
         table.insert(Sync.BrokenAlliances, { From = data.From, To = data.To })
     end
     import("/lua/simping.lua").OnAllianceChange()
+    import("/lua/sim/recall.lua").OnAllianceChange(data)
 end
 
 ---@param resultData {From: number, To: number, ResultValue: DiplomacyActionType}

programming_language: Lua
filename: Recall.lua
change_type: ModificationType.MODIFY
diff: @@ -1,108 +1,186 @@
 
+-- import recall parameters
 doscript "/lua/shared/RecallParams.lua"
 
----@alias CannotRecallReason "active" | "ai" | "gate" | "request" | "scenario" | "vote" | false
+local SyncAnnouncement = import("/lua/simdiplomacy.lua").SyncAnnouncement
+
+
+---@alias CannotRecallReason false
+---| "active"
+---| "ai"
+---| "gate"
+---| "request"
+---| "scenario"
+---| "vote"
+---| "observer"
+
 
 function init()
+    -- setup sim recall state in the brains
+    local playerCooldown = PlayerGateCooldown - PlayerRequestCooldown
+    local teamCooldown = PlayerGateCooldown - TeamVoteCooldown
     for _, brain in ArmyBrains do
-        brain.LastRecallRequestTime = PlayerGateCooldown - PlayerRequestCooldown
-        brain.LastRecallVoteTime = PlayerGateCooldown - TeamVoteCooldown
-    end
-    ForkThread(function()
-        if ScenarioInfo.RecallDisabled then
-            Sync.RecallRequest = {CannotRequest = "scenario"}
-        else
-            Sync.RecallRequest = {CannotRequest = "gate"}
-            WaitTicks(PlayerGateCooldown + 1)
-
-            local focus = GetFocusArmy()
-            local brain = GetArmyBrain(focus)
-            for index, brainWith in ArmyBrains do
-                if brain ~= brainWith then
-                    if  not brain:IsDefeated() and
-                        IsAlly(focus, index) and
-                        brainWith.BrainType ~= "Human" and
-                        not ArmyIsCivilian(index)
-                    then
-                        Sync.RecallRequest = {CannotRequest = "ai"}
-                        return
-                    end
+        brain.LastRecallRequestTime = playerCooldown
+        brain.LastRecallVoteTime = teamCooldown
+    end
+
+    -- setup user recall state notifier in this thread
+    SyncRecallStatus()
+end
+
+function OnArmyChange()
+    local focus = GetFocusArmy()
+    if focus == -1 then
+        SyncCancelRecallVote()
+        SyncRecallStatus()
+        return
+    end
+    local teamSize = 0
+    local accept, veto = 0, 0
+    local votingThreadBrain
+    for index, brain in ArmyBrains do
+        if IsAlly(focus, index) and not ArmyIsCivilian(index) then
+            -- Found a voting thread. We really do need a better way to handle team data...
+            teamSize = teamSize + 1
+            if brain.Vote ~= nil then
+                if brain.Vote then
+                    accept = accept + 1
+                else
+                    veto = veto + 1
                 end
             end
-            Sync.RecallRequest = {CannotRequest = false}
+            if brain.recallVotingThread then
+                votingThreadBrain = brain
+            end
+        end
+    end
+    if votingThreadBrain then
+        Sync.RecallRequest = {
+            StartTime = votingThreadBrain.RecallVoteStartTime,
+            Open = VoteTime * 0.1,
+            Blocks = teamSize,
+            Accept = accept,
+            Veto = veto,
+            CanVote = GetArmyBrain(focus).Vote ~= nil,
+        }
+    end
+    SyncRecallStatus()
+end
+
+---@param data {From: number, To: number}
+function OnAllianceChange(data)
+    local armyFrom, armyTo = data.From, data.To
+    local oldTeammates = 0
+    local oldTeam = {}
+    local votingThreadBrain
+    for index, ally in ArmyBrains do
+        if (IsAlly(armyFrom, index) or IsAlly(armyTo, index))
+            and not ally:IsDefeated()
+            and not ArmyIsCivilian(index)
+        then
+            oldTeammates = oldTeammates + 1
+            oldTeam[oldTeammates] = ally.Nickname
+            -- Found a voting thread. We really do need a better way to handle team data...
+            if ally.recallVotingThread then
+                votingThreadBrain = ally
+            end
+        end
+    end
+    if votingThreadBrain then
+        SPEW("Canceling recall voting for team " .. table.concat(oldTeam, ", ") .. " due to alliance break")
+        votingThreadBrain.VoteCancelled = true
+        coroutine.resume(votingThreadBrain.recallVotingThread)
+        if IsAlly(votingThreadBrain, GetFocusArmy()) then
+            SyncCancelRecallVote()
+            SyncRecallStatus()
         end
-    end)
+    end
 end
 
+
 ---@param lastTeamVote number
 ---@param lastPlayerRequest number
 ---@param playerGatein? number
----@return number cooldown
----@return CannotRecallReason reason
+---@return CannotRecallReason CannotRecallReason
+---@return number? cooldown
 function RecallRequestCooldown(lastTeamVote, lastPlayerRequest, playerGatein)
-    playerGatein = playerGatein or 0
+    -- note that this doesn't always return the reason that currently has the longest cooldown, it
+    -- returns the more "fundamental" one (i.e. the reason whose base cooldown is longest)
+    -- this is more useful in reporting the reason, and isn't a problem when put in a loop
     local gametime = GetGameTick()
-    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
-    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
-    local gateCooldown = playerGatein + PlayerGateCooldown - gametime
-
-    local largest, reason = reqCooldown, "request"
-    if largest < voteCooldown then
-        largest, reason = voteCooldown, "vote"
+    local gateCooldown = (playerGatein or 0) + PlayerGateCooldown - gametime
+    if gateCooldown > 0 then
+        return "gate", gateCooldown
     end
-    if largest < gateCooldown then
-        largest, reason = gateCooldown, "gate"
+    local reqCooldown = lastPlayerRequest + PlayerRequestCooldown - gametime
+    if reqCooldown > 0 then
+        return "request", reqCooldown
     end
-    if largest < 0 then
-        return 0, false
+    local voteCooldown = lastTeamVote + TeamVoteCooldown - gametime
+    if voteCooldown > 0 then
+        return "vote", voteCooldown
     end
-    return largest, reason
+    return false
 end
 
---- Returns the recall request cooldown for an army
+--- Returns the current reason an army cannot request recall and the cooldown of that reason, or
+--- false
 ---@param army Army
----@return number cooldown
----@return string reason
+---@return CannotRecallReason
+---@return number? cooldown no timeout/cooldown if absent
 function ArmyRecallRequestCooldown(army)
+    if army == -1 then
+        return "observer"
+    end
     local brain = GetArmyBrain(army)
     if ScenarioInfo.RecallDisabled then
-        return 36000, "scenario"
+        return "scenario"
     end
     if brain.RecallVote ~= nil then
-        return 36000, "active"
+        return "active", VoteTime
     end
     local lastPlayerRequest = brain.LastRecallRequestTime
     local lastTeamVote
     army = brain.Army
     lastTeamVote = lastPlayerRequest
     for index, ally in ArmyBrains do
-        if not ally:IsDefeated() and IsAlly(army, index) and not ArmyIsCivilian(index) then
+        if IsAlly(army, index) and not ally:IsDefeated() and not ArmyIsCivilian(index) then
+            if ally.BrainType ~= "Human" then
+                return "ai"
+            end
             local allyTeamVote = ally.LastRecallVoteTime
             if allyTeamVote < lastTeamVote then
                 lastTeamVote = allyTeamVote
             end
         end
     end
+    -- if someone adds a feature that gates in commanders at different times, that time should be
+    -- added as an argument to this method call
     return RecallRequestCooldown(lastTeamVote, lastPlayerRequest)
 end
 
----@param reason CannotRecallReason
-local function SetCannotRequestRecall(reason)
-    local recallSync = Sync.RecallRequest
-    if not recallSync then
-        Sync.RecallRequest = {CannotRequest = reason}
-    else
-        recallSync.CannotRequest = reason
-    end
-end
-
+---@param requestingArmy number
 local function RecallVotingThread(requestingArmy)
-    WaitTicks(VoteTime + 1)
+    local requestingBrain = GetArmyBrain(requestingArmy)
+    requestingBrain.RecallVoteStartTime = GetGameTick()
+    WaitTicks(VoteTime) -- may be interrupted if the vote closes or is canceled by an alliance break
+
+    local focus = GetFocusArmy()
+    if requestingBrain.VoteCancelled then
+        if IsAlly(requestingArmy, focus) then
+            SyncCancelRecallVote()
+            SyncRecallStatus()
+        end
+        requestingBrain.VoteCancelled = nil
+        requestingBrain.RecallVoteStartTime = nil
+        requestingBrain.recallVotingThread = nil
+        return
+    end
 
     local gametick = GetGameTick()
     local recallAcceptance = 0
-    local team = {}
     local teammates = 0
+    local team = {}
     for index, brain in ArmyBrains do
         if not brain:IsDefeated() and IsAlly(requestingArmy, brain.Army) and not ArmyIsCivilian(index) then
             teammates = teammates + 1
@@ -114,49 +192,47 @@ local function RecallVotingThread(requestingArmy)
             brain.LastRecallVoteTime = gametick
         end
     end
+    -- this function is found in the recall params file, for those looking
     local recallAccepted = RecallRequestAccepted(recallAcceptance, teammates)
-    local focus = GetFocusArmy()
-    local brain = GetArmyBrain(requestingArmy)
     if IsAlly(focus, requestingArmy) then
-        Sync.RecallRequest = {
-            Close = recallAccepted,
-        }
+        SyncCloseRecallVote(recallAccepted)
+        -- the recall UI will handle the announcement in this case
     elseif recallAccepted then
-        local dip = Sync.DiplomacyAnnouncement
-        if not dip then
-            dip = {}
-            Sync.DiplomacyAnnouncement = dip
-        end
-        table.insert(dip, {
+        -- in this case though, we need to handle the announcement
+        SyncAnnouncement {
             Action = "recall",
-            Team = brain.Nickname,
-        })
+            Team = requestingBrain.Nickname,
+        }
+    end
+    local listTeam = team[1].Nickname
+    for i = 2, table.getn(team) do
+        listTeam = listTeam .. ", " .. team[i].Nickname
     end
     if recallAccepted then
-        SPEW("Vote passed; recalling!")
-        for army, brain in team do
+        SPEW("Recalling team " .. listTeam .. " at the request of " .. requestingBrain.Nickname .. " (vote passed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
+        for _, brain in team do
             brain:RecallAllCommanders()
         end
     else
-        brain.LastRecallRequestTime = gametick
-
+        SPEW("Not recalling team " .. listTeam .. " (vote failed " .. recallAcceptance .. " to " .. (teammates - recallAcceptance ) .. ")")
+        requestingBrain.LastRecallRequestTime = gametick
+    end
+    if IsAlly(requestingArmy, focus) then
         -- update UI once the cooldown dissipates
-        local cooldown, reason = ArmyRecallRequestCooldown(focus)
-        repeat
-            SetCannotRequestRecall(reason)
-            WaitTicks(cooldown + 1)
-
-            cooldown, reason = ArmyRecallRequestCooldown(focus)
-        until cooldown <= 0
-        SetCannotRequestRecall(false)
+        SyncRecallStatus()
     end
-    brain.recallVotingThread = nil
+    requestingBrain.RecallVoteStartTime = nil
+    requestingBrain.recallVotingThread = nil
 end
 
+---@param army number
+---@param vote boolean
+---@param lastVote boolean
+---@return boolean # if further user sync should happen
 local function ArmyVoteRecall(army, vote, lastVote)
     if lastVote then
         for index, ally in ArmyBrains do
-            if army ~= index and not ally:IsDefeated() and IsAlly(army, index) then
+            if army ~= index and IsAlly(army, index) and not ally:IsDefeated() then
                 local thread = ally.recallVotingThread
                 if thread then
                     -- end voting period
@@ -168,87 +244,82 @@ local function ArmyVoteRecall(army, vote, lastVote)
     end
 
     local focus = GetFocusArmy()
-    -- don't update the recall panel for votes we aren't a part of
-    if not IsAlly(focus, army) then
-        return false
-    end
-    local recallSync = Sync.RecallRequest
-    if not recallSync then
-        recallSync = {}
-        Sync.RecallRequest = recallSync
-    end
-    if vote then
-        local accept = recallSync.Accept or 0
-        recallSync.Accept = accept + 1
-    else
-        local veto = recallSync.Veto or 0
-        recallSync.Veto = veto + 1
+    if focus == -1 or not IsAlly(focus, army) then
+        return false -- don't update the recall panel for votes we aren't a part of or for observers
     end
+    SyncRecallVote(vote)
     if army == focus then
-        recallSync.CannotRequest = "active"
+        SyncCannotRequestRecall("active")
     end
     return true
 end
 
-local function ArmyRequestRecall(army, teammates, lastVote)
+---@param army number
+---@param teammates number
+local function ArmyRequestRecall(army, teammates)
+    local brain = GetArmyBrain(army)
     if teammates > 0 then
-        GetArmyBrain(army).recallVotingThread = ForkThread(RecallVotingThread, army)
-        if ArmyVoteRecall(army, true, lastVote) then
-            local recallSync = Sync.RecallRequest
-            recallSync.Open = VoteTime * 0.1
-            recallSync.CanVote = army ~= GetFocusArmy()
-            recallSync.Blocks = teammates + 1
+        brain.recallVotingThread = ForkThread(RecallVotingThread, army)
+        if ArmyVoteRecall(army, true, false) then
+            SyncOpenRecallVote(teammates + 1, army)
         end
-    elseif lastVote then
-        -- if we're the first and last vote, it's just us; recall our army
-        SPEW("Immediately recalling")
-        GetArmyBrain(army):RecallAllCommanders()
+    else
+        -- it's just us; recall our army
+        SPEW("Recalling " .. brain.Nickname)
+        brain:RecallAllCommanders()
+
     end
 end
 
 ---@param data {From: number, Vote: boolean}
 function SetRecallVote(data)
-    local focus = GetFocusArmy()
     local army = data.From
+    if not OkayToMessWithArmy(army) then
+        return
+    end
+    local focus = GetFocusArmy()
     if not ScenarioInfo.TeamGame then
         if army == focus then
-            SetCannotRequestRecall("scenario")
+            SyncCannotRequestRecall("scenario")
         end
         return
     end
     local vote = data.Vote and true or false
 
+    -- determine team voting status
     local isRequest = true
     local lastVote = true
     local teammates = 0
+    local team = {}
     for index, ally in ArmyBrains do
         if army ~= index and not ally:IsDefeated() and IsAlly(army, index) and not ArmyIsCivilian(index) then
             if ally.BrainType ~= "Human" then
                 if army == focus then
-                    SetCannotRequestRecall("ai")
+                    SyncCannotRequestRecall("ai")
                 end
                 return
             end
             local allyHasVoted = ally.RecallVote ~= nil
-            lastVote = lastVote and allyHasVoted
-            isRequest = isRequest and not allyHasVoted
+            lastVote = lastVote and allyHasVoted -- only the last vote if all allies have also voted
+            isRequest = isRequest and not allyHasVoted -- only the last vote if no allies have voted
             teammates = teammates + 1
+            team[teammates] = ally.Nickname
         end
     end
 
     local brain = GetArmyBrain(army)
     if isRequest then
         -- the player is making a recall request; this will reset their recall request cooldown
-        local cooldown, reason = ArmyRecallRequestCooldown(army)
-        if cooldown > 0 then
+        local reason = ArmyRecallRequestCooldown(army)
+        if reason then
             if army == focus then
-                SetCannotRequestRecall(reason)
+                SyncCannotRequestRecall(reason)
             end
             return
         end
-        SPEW("Army " .. tostring(army) .. " is requesting recall")
+        SPEW("Army " .. tostring(army) .. " is requesting recall for " .. table.concat(team, ','))
         brain.RecallVote = vote
-        ArmyRequestRecall(army, teammates, lastVote)
+        ArmyRequestRecall(army, teammates)
     else
         -- the player is responding to a recall request; we don't count this against their
         -- individual recall request cooldown
@@ -257,3 +328,89 @@ function SetRecallVote(data)
         ArmyVoteRecall(army, vote, lastVote)
     end
 end
+
+
+---@param reason CannotRecallReason
+function SyncCannotRequestRecall(reason)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        Sync.RecallRequest = {CannotRequest = reason}
+    else
+        recallSync.CannotRequest = reason
+    end
+end
+
+---@param result boolean
+function SyncCloseRecallVote(result)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        Sync.RecallRequest = {Close = result}
+    else
+        recallSync.Close = result
+    end
+end
+
+function SyncCancelRecallVote()
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        Sync.RecallRequest = {Cancel = true}
+    else
+        recallSync.Cancel = true
+    end
+end
+
+---@param vote boolean
+function SyncRecallVote(vote)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        recallSync = {}
+        Sync.RecallRequest = recallSync
+    end
+    if vote then
+        recallSync.Accept = (recallSync.Accept or 0) + 1
+    else
+        recallSync.Veto = (recallSync.Veto or 0) + 1
+    end
+end
+
+---@param teamSize number
+---@param army number
+function SyncOpenRecallVote(teamSize, army)
+    local recallSync = Sync.RecallRequest
+    if not recallSync then
+        recallSync = {}
+        Sync.RecallRequest = recallSync
+    end
+    local focus = GetFocusArmy()
+    recallSync.Open = VoteTime * 0.1
+    recallSync.CanVote = focus ~= -1 and army ~= focus
+    recallSync.Blocks = teamSize
+end
+
+local UserRecallStatusThread
+
+local function SyncRecallStatusThread()
+    local reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
+    while reason do
+        SyncCannotRequestRecall(reason)
+        if not cooldown then
+            UserRecallStatusThread = nil
+            return
+        end
+        -- may be interrupted for various reasons, such as the focus army changing
+        -- this will be fine, we'll pick up the proper cooldown reason anyway and loop again
+        WaitTicks(cooldown)
+
+        reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy())
+    end
+    SyncCannotRequestRecall(false)
+    UserRecallStatusThread = nil
+end
+
+function SyncRecallStatus()
+    if UserRecallStatusThread then
+        coroutine.resume(UserRecallStatusThread) -- force update the existing thread
+    else
+        UserRecallStatusThread = ForkThread(SyncRecallStatusThread)
+    end
+end

programming_language: Lua
filename: diplomacy.lua
change_type: ModificationType.MODIFY
diff: @@ -36,14 +36,14 @@ local offerQueue = {}
 local drawOffered = false
 
 ---@type CannotRecallReason
-local cannotRequestRecall = false
+local CannotRequestRecallReason = false
 
-function CannotRequestRecall()
-    return cannotRequestRecall
+function GetCannotRequestRecallReason()
+    return CannotRequestRecallReason
 end
 
-function UpdateCannotRequestRecall(canRequest)
-    cannotRequestRecall = canRequest
+function SetCannotRequestRecallReason(canRequest)
+    CannotRequestRecallReason = canRequest
     if parent then
         BuildPlayerLines()
     end
@@ -379,6 +379,12 @@ end
 function BuildPlayerLines()
     local sessionOptions = SessionGetScenarioInfo().Options
     local focusArmy = GetFocusArmy()
+    if focusArmy == -1 then
+        focusArmy = import("/lua/ui/game/gamemain.lua").OriginalFocusArmy
+        if focusArmy == -1 then
+            return
+        end
+    end
 
     local group = parent.personalGroup
     if group then
@@ -416,7 +422,8 @@ function BuildPlayerLines()
 
     local belowEntry = parent
 
-    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode then
+    local reason = GetCannotRequestRecallReason()
+    if not import("/lua/ui/campaign/campaignmanager.lua").campaignMode and reason ~= "observer" then
         local personalGroup = CreateBitmapStd(parent, "/game/options-diplomacy-panel/panel-recall")
         if belowEntry == parent then
             LayoutHelpers.AtLeftTopIn(personalGroup, belowEntry, 0, 8)
@@ -431,28 +438,32 @@ function BuildPlayerLines()
             :Over(personalGroup, 5)
         personalGroup.button = recallButton
 
-        local reason = CannotRequestRecall()
         if reason then
             recallButton:Disable()
             Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_" .. reason)
         else
+            local function OnAcceptRecall()
+                SimCallback({
+                    Func = "SetRecallVote",
+                    Args = {
+                        From = GetFocusArmy(),
+                        Vote = true,
+                    },
+                })
+                -- preemptively expect the sim to accept our recall and disable the button so we
+                -- can't possiblely confuse the sim with more than one request
+                -- note that if--for some reason (*ahem* due to a mod maybe)--the sim *doesn't*
+                -- end up accepting it, then we'll be stuck until the sim sends a new update
+                recallButton:Disable()
+                SetCannotRequestRecallReason("active")
+                Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
+                import("/lua/ui/game/tabs.lua").CollapseWindow()
+            end
             recallButton.OnClick = function(self, modifiers)
                 UIUtil.QuickDialog(GetFrame(0),
                     "<LOC diplomacy_0019>Are you sure you're ready to recall from battle? This will send a request to your team.",
                     "<LOC _Yes>",
-                    function()
-                        SimCallback({
-                            Func = "SetRecallVote",
-                            Args = {
-                                From = GetFocusArmy(),
-                                Vote = true,
-                            },
-                        })
-                        recallButton:Disable()
-                        cannotRequestRecall = "active"
-                        Tooltip.AddButtonTooltip(recallButton, "dip_recall_request_dis_active")
-                        import("/lua/ui/game/tabs.lua").CollapseWindow()
-                    end,
+                    OnAcceptRecall,
                     "<LOC _No>", nil, nil, nil, nil,
                     {worldCover = false, enterButton = 1, escapeButton = 2}
                 )
@@ -462,6 +473,7 @@ function BuildPlayerLines()
 
         local recallIcon = CreateBitmapStd(recallButton, "/game/recall-panel/icon-recall")
         Layouter(recallIcon)
+            --:DisableHitTest()
             :AtCenterIn(recallButton)
             :Over(recallButton, 5)
         recallButton.label = recallIcon

programming_language: Lua
filename: recall.lua
change_type: ModificationType.MODIFY
diff: @@ -58,10 +58,10 @@ end
 
 function RequestHandler(data)
     if data.CannotRequest ~= nil then
-        import("/lua/ui/game/diplomacy.lua").UpdateCannotRequestRecall(data.CannotRequest)
+        import("/lua/ui/game/diplomacy.lua").SetCannotRequestRecallReason(data.CannotRequest)
     end
     if data.Open then
-        panel:StartVote(data.Blocks, data.Open, data.CanVote)
+        panel:StartVote(data.Blocks, data.Open, data.CanVote, data.StartTime)
     end
     local accept, veto = data.Accept, data.Veto
     if accept or veto then
@@ -69,6 +69,8 @@ function RequestHandler(data)
     end
     if data.Close ~= nil then
         panel:CloseVote(data.Close)
+    elseif data.Cancel then
+        panel:CancelVote()
     end
 end
 
@@ -266,10 +268,9 @@ RecallPanel = Class(NinePatch.NinePatch) {
         end
     end;
 
-    StartVote = function(self, blocks, duration, canVote)
-        SPEW("Recall voting!")
+    StartVote = function(self, blocks, duration, canVote, startingTime)
         self.duration = duration
-        self.startTime:Set(GetGameTimeSeconds())
+        self.startTime:Set(startingTime or GetGameTimeSeconds())
         self:SetCanVote(canVote)
         self:LayoutBlocks(blocks) -- can depend on `canVote`, so put after it
         self.collapseArrow:Enable()
@@ -297,6 +298,16 @@ RecallPanel = Class(NinePatch.NinePatch) {
         end
     end;
 
+    CancelVote = function(self)
+        self:SetCanVote(false)
+        self.startTime:Set(-9999) -- make sure the OnFrame animation ends
+        if self.reviewResultsThread then
+            KillThread(self.reviewResultsThread)
+            self.reviewResultsThread = nil
+        end
+        self:OnResultsReviewed()
+    end;
+
     CloseVote = function(self, accepted)
         self:SetCanVote(false)
         self.startTime:Set(-9999) -- make sure the OnFrame animation ends
@@ -386,6 +397,7 @@ RecallPanel = Class(NinePatch.NinePatch) {
             if time >= dur then
                 self.startTime:Set(-9999)
                 self.progressBar.Width:Set(0)
+                self.progressBar:Hide()
             else
                 self.progressBar.Width:Set((1 - time / dur) * nominalWidth)
             end

programming_language: Lua