189 lines
6.7 KiB
Go
189 lines
6.7 KiB
Go
package sdkserver
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const clientUnstuckRequestFile = "scripts/client_unstuck_request.lua"
|
|
|
|
type clientUnstuckStatus struct {
|
|
RequestID string `json:"request_id"`
|
|
State string `json:"state"`
|
|
Error string `json:"error,omitempty"`
|
|
QueuedAt int64 `json:"queued_at"`
|
|
CompletedAt int64 `json:"completed_at,omitempty"`
|
|
}
|
|
|
|
var clientUnstuckState struct {
|
|
sync.Mutex
|
|
status clientUnstuckStatus
|
|
}
|
|
|
|
func queueClientUnstuck() (clientUnstuckStatus, error) {
|
|
clientUnstuckState.Lock()
|
|
defer clientUnstuckState.Unlock()
|
|
var nonce [16]byte
|
|
if _, err := rand.Read(nonce[:]); err != nil {
|
|
return clientUnstuckStatus{}, err
|
|
}
|
|
id := hex.EncodeToString(nonce[:])
|
|
// Only a generated hexadecimal nonce is interpolated. No user-supplied
|
|
// command, path, endpoint or Lua source can enter this heartbeat script.
|
|
script := fmt.Sprintf(clientUnstuckLua, id)
|
|
if err := os.MkdirAll(filepath.Dir(clientUnstuckRequestFile), 0700); err != nil {
|
|
return clientUnstuckStatus{}, err
|
|
}
|
|
file, err := os.CreateTemp(filepath.Dir(clientUnstuckRequestFile), ".client-unstuck-*.tmp")
|
|
if err != nil {
|
|
return clientUnstuckStatus{}, err
|
|
}
|
|
defer os.Remove(file.Name())
|
|
if _, err = file.WriteString(script); err != nil {
|
|
file.Close()
|
|
return clientUnstuckStatus{}, err
|
|
}
|
|
if err = file.Close(); err != nil {
|
|
return clientUnstuckStatus{}, err
|
|
}
|
|
if err = os.Rename(file.Name(), clientUnstuckRequestFile); err != nil {
|
|
return clientUnstuckStatus{}, err
|
|
}
|
|
status := clientUnstuckStatus{RequestID: id, State: "queued", QueuedAt: time.Now().UnixMilli()}
|
|
clientUnstuckState.status = status
|
|
Log("[UNSTUCK] UI recovery queued request=" + id)
|
|
return status, nil
|
|
}
|
|
|
|
func apiClientUnstuck(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
clientUnstuckState.Lock()
|
|
status := clientUnstuckState.status
|
|
clientUnstuckState.Unlock()
|
|
writeJSON(w, status)
|
|
case http.MethodPost:
|
|
status, err := queueClientUnstuck()
|
|
if err != nil {
|
|
http.Error(w, "cannot queue UI recovery", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
writeJSON(w, status)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func apiClientUnstuckResult(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var result struct {
|
|
RequestID string `json:"request_id"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error"`
|
|
}
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&result); err != nil {
|
|
http.Error(w, "invalid recovery result", http.StatusBadRequest)
|
|
return
|
|
}
|
|
clientUnstuckState.Lock()
|
|
defer clientUnstuckState.Unlock()
|
|
if result.RequestID == "" || result.RequestID != clientUnstuckState.status.RequestID {
|
|
http.Error(w, "recovery request is no longer current", http.StatusConflict)
|
|
return
|
|
}
|
|
clientUnstuckState.status.State = "failed"
|
|
if result.Success {
|
|
clientUnstuckState.status.State = "completed"
|
|
}
|
|
clientUnstuckState.status.Error = result.Error
|
|
clientUnstuckState.status.CompletedAt = time.Now().UnixMilli()
|
|
// Queueing and acknowledgement share the lock, so removing this owned
|
|
// request cannot delete a newer recovery. A leftover script still checks
|
|
// the live nonce before touching UI, including after an SDK restart.
|
|
if err := os.Remove(clientUnstuckRequestFile); err != nil && !os.IsNotExist(err) {
|
|
Log("[UNSTUCK] cannot remove completed request: " + err.Error())
|
|
}
|
|
Log("[UNSTUCK] UI recovery " + clientUnstuckState.status.State + " request=" + result.RequestID)
|
|
writeJSON(w, clientUnstuckState.status)
|
|
}
|
|
|
|
// Named UIManager API observed on the current Client. Clear only network UI
|
|
// waits: never close a Battle, change a scene, fabricate a successful gameplay
|
|
// response, erase a run, or reset the Client's loading/phase flags.
|
|
const clientUnstuckLua = `do
|
|
local id = %q
|
|
local function tick()
|
|
local url=_G.__GAC_SRTOOL_LIVE_URL
|
|
if type(url)~='string' or url=='' then return end
|
|
local s=_G.__GAC_UNSTUCK_WORK
|
|
if not s or s.id~=id then
|
|
if s and s.request then pcall(function()s.request:Dispose()end) end
|
|
s={id=id,phase='check',next=0};_G.__GAC_UNSTUCK_WORK=s
|
|
end
|
|
if s.phase=='done' or s.phase=='stale' then return end
|
|
local now=CS.UnityEngine.Time.unscaledTime
|
|
if s.request then
|
|
if not s.request.isDone then return end
|
|
local request=s.request;s.request=nil
|
|
local ok,err=pcall(function()
|
|
local code=request.responseCode
|
|
if code<200 or code>=300 then error('recovery HTTP '..tostring(code)) end
|
|
if s.phase=='check' then
|
|
local status=CS.Newtonsoft.Json.Linq.JObject.Parse(request.downloadHandler.text)
|
|
local request_id=status:get_Item('request_id')
|
|
local phase=status:get_Item('state')
|
|
if not request_id or tostring(request_id)~=id or not phase or tostring(phase)~='queued' then
|
|
s.phase='stale';return
|
|
end
|
|
-- Only the current queued SDK request authorizes a UI recovery. Old
|
|
-- generated files are inert after reconnect or SDK process restart.
|
|
local success,message=pcall(function()
|
|
local ui=CS.RPG.Client.GlobalVars.s_UIManager
|
|
if not ui then error('Client UIManager is unavailable') end
|
|
ui:ClearWaitNetPacketFullScreenBlock()
|
|
end)
|
|
s.body='{"request_id":"'..id..'","success":'..tostring(success)..',"error":'..CS.Newtonsoft.Json.JsonConvert.SerializeObject(tostring(message or ''))..'}'
|
|
s.phase='ack'
|
|
CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] request='..id..' success='..tostring(success)..' '..tostring(message or ''))
|
|
else
|
|
s.phase='done';_G.__GAC_UNSTUCK_LAST=id
|
|
end
|
|
end)
|
|
pcall(function()request:Dispose()end)
|
|
if not ok then
|
|
s.next=now+5
|
|
if s.error~=tostring(err) then CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] '..tostring(err));s.error=tostring(err) end
|
|
return
|
|
end
|
|
end
|
|
if s.phase=='done' or s.phase=='stale' or now<s.next then return end
|
|
local req
|
|
if s.phase=='check' then
|
|
req=CS.UnityEngine.Networking.UnityWebRequest.Get(url..'/api/client/unstuck')
|
|
else
|
|
req=CS.UnityEngine.Networking.UnityWebRequest(url..'/api/client/unstuck/result','POST')
|
|
req.uploadHandler=CS.UnityEngine.Networking.UploadHandlerRaw(CS.System.Text.Encoding.UTF8:GetBytes(s.body))
|
|
req.downloadHandler=CS.UnityEngine.Networking.DownloadHandlerBuffer()
|
|
req:SetRequestHeader('Content-Type','application/json')
|
|
end
|
|
req.timeout=10;s.request=req;req:SendWebRequest()
|
|
end
|
|
local ok,err=pcall(tick)
|
|
if not ok then CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] '..tostring(err)) end
|
|
end
|
|
`
|