mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 04:31:09 +02:00
skin studio updates, save sync CLOSES #1533
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local SyncClient = {}
|
||||
SyncClient.__index = SyncClient
|
||||
|
||||
SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL")
|
||||
or "https://sync.147.182.215.255.sslip.io"
|
||||
SyncClient.MAX_BLOB = 2 * 1024 * 1024
|
||||
SyncClient.MAX_RESPONSE = 4 * 1024 * 1024
|
||||
SyncClient.TIMEOUT = 25
|
||||
|
||||
function SyncClient.normalizeCode(code)
|
||||
if type(code) ~= "string" and type(code) ~= "number" then return nil end
|
||||
local digits = tostring(code):gsub("[^%d]", "")
|
||||
if #digits ~= 8 then return nil end
|
||||
return digits
|
||||
end
|
||||
|
||||
function SyncClient.formatCode(code)
|
||||
local digits = SyncClient.normalizeCode(code)
|
||||
if not digits then return nil end
|
||||
return digits:sub(1, 4) .. "-" .. digits:sub(5, 8)
|
||||
end
|
||||
|
||||
local function escape(s)
|
||||
return (tostring(s):gsub("[^%w%-%._~]", function(c)
|
||||
return ("%%%02X"):format(c:byte())
|
||||
end))
|
||||
end
|
||||
|
||||
local function query(params)
|
||||
local names = {}
|
||||
for name in pairs(params or {}) do names[#names + 1] = tostring(name) end
|
||||
table.sort(names)
|
||||
local out = {}
|
||||
for _, name in ipairs(names) do
|
||||
out[#out + 1] = escape(name) .. "=" .. escape(params[name])
|
||||
end
|
||||
if #out == 0 then return "" end
|
||||
return "?" .. table.concat(out, "&")
|
||||
end
|
||||
|
||||
function SyncClient.new(opts)
|
||||
opts = opts or {}
|
||||
local base = opts.baseUrl or SyncClient.DEFAULT_URL
|
||||
base = tostring(base):gsub("/+$", "")
|
||||
local transport = opts.transport
|
||||
if not transport then
|
||||
transport = require("src.sync.SyncTransport").new()
|
||||
end
|
||||
return setmetatable({
|
||||
baseUrl = base,
|
||||
transport = transport,
|
||||
account = opts.account,
|
||||
token = opts.token,
|
||||
}, SyncClient)
|
||||
end
|
||||
|
||||
function SyncClient:setAuth(account, token)
|
||||
self.account = type(account) == "string" and account ~= "" and account or nil
|
||||
self.token = type(token) == "string" and token ~= "" and token or nil
|
||||
end
|
||||
|
||||
function SyncClient:clearAuth()
|
||||
self.account, self.token = nil, nil
|
||||
end
|
||||
|
||||
function SyncClient:isLinked()
|
||||
return self.account ~= nil and self.token ~= nil
|
||||
end
|
||||
|
||||
function SyncClient:send(method, path, body, opts)
|
||||
opts = opts or {}
|
||||
local headers = { ["Accept"] = "application/json" }
|
||||
local payload
|
||||
if body ~= nil then
|
||||
local ok, encoded = pcall(Json.encode, body)
|
||||
if not ok then return nil, "could not encode the request" end
|
||||
payload = encoded
|
||||
headers["Content-Type"] = "application/json"
|
||||
end
|
||||
if not opts.noAuth then
|
||||
if not self:isLinked() then return nil, "this device is not linked" end
|
||||
headers["x-sync-account"] = self.account
|
||||
headers["x-sync-token"] = self.token
|
||||
end
|
||||
local url = self.baseUrl .. path .. query(opts.params)
|
||||
local handle = self.transport:begin({
|
||||
url = url, method = method, body = payload, headers = headers,
|
||||
maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT,
|
||||
})
|
||||
if handle == nil then return nil, "no network transport" end
|
||||
return handle
|
||||
end
|
||||
|
||||
function SyncClient:poll(handle)
|
||||
if handle == nil then return { status = "error", err = "no request" } end
|
||||
local res = self.transport:poll(handle)
|
||||
if res.status == "pending" then return { status = "pending" } end
|
||||
if res.status ~= "ok" then
|
||||
return { status = "error", err = res.err or "sync request failed" }
|
||||
end
|
||||
local raw = res.body or ""
|
||||
local code = tonumber(res.code) or 0
|
||||
if #raw > SyncClient.MAX_RESPONSE then
|
||||
return { status = "error", code = code, err = "the reply was too large" }
|
||||
end
|
||||
local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE)
|
||||
if type(data) ~= "table" then
|
||||
local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply"
|
||||
if code >= 400 then
|
||||
return { status = "error", code = code,
|
||||
err = ("the server answered %d"):format(code) }
|
||||
end
|
||||
return { status = "error", code = code, err = why }
|
||||
end
|
||||
if code >= 400 or data.error then
|
||||
local err = data.error
|
||||
if type(err) ~= "string" or err == "" then
|
||||
err = ("the server answered %d"):format(code)
|
||||
end
|
||||
return { status = "error", code = code, data = data, err = err }
|
||||
end
|
||||
return { status = "ok", code = code, data = data }
|
||||
end
|
||||
|
||||
function SyncClient:release(handle)
|
||||
if handle ~= nil then self.transport:release(handle) end
|
||||
end
|
||||
|
||||
function SyncClient:create(deviceLabel)
|
||||
return self:send("POST", "/sync/create",
|
||||
{ device = deviceLabel or "device" }, { noAuth = true })
|
||||
end
|
||||
|
||||
function SyncClient:link(code1, code2, deviceLabel)
|
||||
local a = SyncClient.normalizeCode(code1)
|
||||
local b = SyncClient.normalizeCode(code2)
|
||||
if not a or not b then return nil, "both codes are 8 digits" end
|
||||
return self:send("POST", "/sync/link",
|
||||
{ code1 = a, code2 = b, device = deviceLabel or "device" },
|
||||
{ noAuth = true })
|
||||
end
|
||||
|
||||
function SyncClient:fetchState()
|
||||
return self:send("GET", "/sync/state")
|
||||
end
|
||||
|
||||
function SyncClient:putSave(entry)
|
||||
if type(entry) ~= "table" then return nil, "missing save entry" end
|
||||
if type(entry.blob) ~= "string" or entry.blob == "" then
|
||||
return nil, "missing save data"
|
||||
end
|
||||
if #entry.blob > SyncClient.MAX_BLOB then
|
||||
return nil, "this save is too large to sync"
|
||||
end
|
||||
return self:send("PUT", "/sync/save", {
|
||||
version = entry.version,
|
||||
slot = entry.slot,
|
||||
meta = entry.meta,
|
||||
blob = entry.blob,
|
||||
baseRev = entry.baseRev,
|
||||
force = entry.force and true or nil,
|
||||
})
|
||||
end
|
||||
|
||||
function SyncClient:getSave(version, id)
|
||||
return self:send("GET", "/sync/save", nil,
|
||||
{ params = { version = version, id = id } })
|
||||
end
|
||||
|
||||
function SyncClient:putMods(manifest)
|
||||
return self:send("PUT", "/sync/mods", { manifest = manifest })
|
||||
end
|
||||
|
||||
function SyncClient:getMods()
|
||||
return self:send("GET", "/sync/mods")
|
||||
end
|
||||
|
||||
function SyncClient:shareMods(manifest)
|
||||
return self:send("POST", "/sync/modshare", { manifest = manifest })
|
||||
end
|
||||
|
||||
function SyncClient:fetchShare(code)
|
||||
local trimmed = tostring(code or ""):gsub("%s", ""):upper()
|
||||
if not trimmed:match("^[A-Z2-9]+$") or #trimmed ~= 6 then
|
||||
return nil, "share codes are 6 characters"
|
||||
end
|
||||
return self:send("GET", "/sync/modshare", nil,
|
||||
{ noAuth = true, params = { code = trimmed } })
|
||||
end
|
||||
|
||||
function SyncClient:unlink(device)
|
||||
return self:send("POST", "/sync/unlink", { device = device })
|
||||
end
|
||||
|
||||
return SyncClient
|
||||
@@ -0,0 +1,690 @@
|
||||
local SyncClient = require("src.sync.SyncClient")
|
||||
local SyncState = require("src.sync.SyncState")
|
||||
local SyncMods = require("src.sync.SyncMods")
|
||||
|
||||
local SyncEngine = {}
|
||||
SyncEngine.__index = SyncEngine
|
||||
|
||||
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||
SyncEngine.AUTO_INTERVAL = 300
|
||||
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||
|
||||
local IDLE_STATUS = "Ready"
|
||||
local UNLINKED_STATUS = "Not set up"
|
||||
|
||||
local function saveApi()
|
||||
return require("src.core.SaveData")
|
||||
end
|
||||
|
||||
local function gameVersions()
|
||||
return require("src.core.GameVersion").ORDER
|
||||
end
|
||||
|
||||
local function slotForPlaythrough(options, version, playthroughId)
|
||||
local byVersion = options.playthroughIds and options.playthroughIds[version]
|
||||
for slotId, id in pairs(byVersion or {}) do
|
||||
if id == playthroughId then return slotId end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function SyncEngine.defaultSaves()
|
||||
return {
|
||||
list = function()
|
||||
local SaveData = saveApi()
|
||||
local options = SaveData.loadOptions()
|
||||
local out = {}
|
||||
for _, version in ipairs(gameVersions()) do
|
||||
for _, slot in ipairs(SaveData.listSlots(version)) do
|
||||
if slot.exists then
|
||||
local source = SaveData.readSlotSource(version, slot.id)
|
||||
local save = source and SaveData.decode(source)
|
||||
if type(save) == "table" then
|
||||
local meta = type(save.meta) == "table" and save.meta or {}
|
||||
local id = meta.playthroughId
|
||||
if type(id) ~= "string" or id == "" then
|
||||
local byVersion = options.playthroughIds
|
||||
and options.playthroughIds[version]
|
||||
id = byVersion and byVersion[slot.id] or nil
|
||||
end
|
||||
if id then
|
||||
local name, summary = SaveData.slotSummary(save)
|
||||
out[#out + 1] = {
|
||||
version = version,
|
||||
slot = slot.id,
|
||||
playthroughId = id,
|
||||
blob = source,
|
||||
meta = {
|
||||
savedAt = tonumber(meta.savedAt),
|
||||
sessionStart = tonumber(meta.sessionStart),
|
||||
playthroughId = id,
|
||||
format = meta.format,
|
||||
engine = meta.engine,
|
||||
playTime = tonumber(save.playTime),
|
||||
summary = {
|
||||
name = name,
|
||||
badges = summary and summary.badges,
|
||||
timeText = summary and summary.timeText,
|
||||
dexCount = summary and summary.dexCount,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end,
|
||||
|
||||
write = function(version, playthroughId, blob, mode)
|
||||
local SaveData = saveApi()
|
||||
local save = SaveData.decode(blob)
|
||||
if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end
|
||||
save.version = save.version or version
|
||||
local options = SaveData.loadOptions()
|
||||
local slotId
|
||||
if mode == "new" then
|
||||
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||
save.meta.playthroughId = SaveData.newPlaythroughId()
|
||||
else
|
||||
slotId = slotForPlaythrough(options, version, playthroughId)
|
||||
end
|
||||
if not slotId then
|
||||
slotId = SaveData.createSlot(version)
|
||||
if not slotId then return nil, "could not make a save slot" end
|
||||
end
|
||||
local ok, err = SaveData.writeSlot(version, slotId, save)
|
||||
if not ok then return nil, err or "could not write the save" end
|
||||
options = SaveData.loadOptions()
|
||||
options.playthroughIds = options.playthroughIds or {}
|
||||
options.playthroughIds[version] = options.playthroughIds[version] or {}
|
||||
options.playthroughIds[version][slotId] =
|
||||
save.meta and save.meta.playthroughId or playthroughId
|
||||
SaveData.saveOptions(options)
|
||||
return slotId
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
function SyncEngine.overlaps(a, b)
|
||||
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
||||
local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt)
|
||||
local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt)
|
||||
if not (aStart and aEnd and bStart and bEnd) then return false end
|
||||
return aStart <= bEnd and bStart <= aEnd
|
||||
end
|
||||
|
||||
function SyncEngine.new(opts)
|
||||
opts = opts or {}
|
||||
local eng = setmetatable({}, SyncEngine)
|
||||
eng.fs = opts.fs
|
||||
eng.state = opts.state or SyncState.load(eng.fs)
|
||||
eng.client = opts.client or SyncClient.new({
|
||||
baseUrl = opts.baseUrl, transport = opts.transport })
|
||||
eng.saves = opts.saves or SyncEngine.defaultSaves()
|
||||
eng.modDeps = opts.modDeps
|
||||
eng.now = opts.now or os.time
|
||||
eng.persist = opts.persist ~= false
|
||||
eng.phase = "idle"
|
||||
eng.error = nil
|
||||
eng.conflicts = {}
|
||||
eng.codes = nil
|
||||
eng.modPlan = nil
|
||||
eng.shareCode = nil
|
||||
eng.clock = 0
|
||||
eng.queue = {}
|
||||
eng.pending = nil
|
||||
eng.uploadAt = nil
|
||||
eng.client:setAuth(eng.state.account, eng.state.deviceToken)
|
||||
eng.status = eng:defaultStatus()
|
||||
return eng
|
||||
end
|
||||
|
||||
function SyncEngine.shared(opts)
|
||||
if SyncEngine._shared == nil then
|
||||
local ok, eng = pcall(SyncEngine.new, opts or {})
|
||||
SyncEngine._shared = (ok and type(eng) == "table") and eng or false
|
||||
end
|
||||
return SyncEngine._shared or nil
|
||||
end
|
||||
|
||||
function SyncEngine.forgetShared()
|
||||
SyncEngine._shared = nil
|
||||
end
|
||||
|
||||
function SyncEngine:defaultStatus()
|
||||
if not SyncState.linked(self.state) then return UNLINKED_STATUS end
|
||||
return IDLE_STATUS
|
||||
end
|
||||
|
||||
function SyncEngine:linked()
|
||||
return SyncState.linked(self.state)
|
||||
end
|
||||
|
||||
function SyncEngine:busy()
|
||||
return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil
|
||||
end
|
||||
|
||||
function SyncEngine:_persist()
|
||||
if not self.persist then return end
|
||||
SyncState.save(self.state, self.fs)
|
||||
end
|
||||
|
||||
function SyncEngine:_fail(message)
|
||||
self.phase = "error"
|
||||
self.error = tostring(message or "sync failed")
|
||||
self.status = "Sync failed: " .. self.error
|
||||
self.queue = {}
|
||||
self.pending = nil
|
||||
end
|
||||
|
||||
function SyncEngine:_finish()
|
||||
if #self.conflicts > 0 then
|
||||
self.phase = "conflict"
|
||||
local overlap = false
|
||||
for _, row in ipairs(self.conflicts) do
|
||||
if row.overlap then overlap = true end
|
||||
end
|
||||
self.status = overlap
|
||||
and "These saves were played at the same time."
|
||||
or "This save also changed on another device."
|
||||
return
|
||||
end
|
||||
self.phase = "idle"
|
||||
self.error = nil
|
||||
self.state.lastSyncAt = self.now()
|
||||
self.status = self:defaultStatus()
|
||||
self:_persist()
|
||||
end
|
||||
|
||||
function SyncEngine:_request(handle, err, onOk, onErr)
|
||||
if not handle then
|
||||
self:_fail(err or "could not start the request")
|
||||
return false
|
||||
end
|
||||
self.pending = { handle = handle, onOk = onOk, onErr = onErr }
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:_enqueue(fn)
|
||||
self.queue[#self.queue + 1] = fn
|
||||
end
|
||||
|
||||
function SyncEngine:cancel()
|
||||
if self.pending then self.client:release(self.pending.handle) end
|
||||
self.pending = nil
|
||||
self.queue = {}
|
||||
self.modApply = nil
|
||||
self.uploadAt = nil
|
||||
if self.phase ~= "conflict" then
|
||||
self.phase = "idle"
|
||||
self.status = self:defaultStatus()
|
||||
end
|
||||
end
|
||||
|
||||
function SyncEngine:noteSaveWritten()
|
||||
if not (self.state.enabled and self:linked()) then return end
|
||||
self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE
|
||||
end
|
||||
|
||||
function SyncEngine:update(dt)
|
||||
self.clock = self.clock + (tonumber(dt) or 0)
|
||||
if self.pending then
|
||||
local res = self.client:poll(self.pending.handle)
|
||||
if res.status == "pending" then return end
|
||||
local job = self.pending
|
||||
self.pending = nil
|
||||
self.client:release(job.handle)
|
||||
if res.status == "ok" then
|
||||
local ok, err = pcall(job.onOk, self, res)
|
||||
if not ok then self:_fail(err) end
|
||||
else
|
||||
local handled = false
|
||||
if job.onErr then
|
||||
local ok, result = pcall(job.onErr, self, res)
|
||||
if not ok then self:_fail(result) return end
|
||||
handled = result == true
|
||||
end
|
||||
if not handled then self:_fail(res.err) end
|
||||
end
|
||||
end
|
||||
if self.pending then return end
|
||||
if self.modApply then
|
||||
self:_stepModApply()
|
||||
return
|
||||
end
|
||||
if self.uploadAt and self.clock >= self.uploadAt and not self:busy() then
|
||||
self.uploadAt = nil
|
||||
if self.state.enabled and self:linked() then self:syncNow() end
|
||||
end
|
||||
local steps = 0
|
||||
while not self.pending and #self.queue > 0
|
||||
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||
steps = steps + 1
|
||||
local task = table.remove(self.queue, 1)
|
||||
local ok, err = pcall(task, self)
|
||||
if not ok then self:_fail(err) return end
|
||||
if not self.pending and #self.queue == 0 and self.phase ~= "error" then
|
||||
self:_finish()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SyncEngine:createAccount(label)
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "checking"
|
||||
self.status = "Creating a sync account..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:create(label)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
||||
eng:_fail("the server sent an unexpected reply")
|
||||
return
|
||||
end
|
||||
eng.codes = {
|
||||
code1 = SyncClient.formatCode(data.code1) or tostring(data.code1 or ""),
|
||||
code2 = SyncClient.formatCode(data.code2) or tostring(data.code2 or ""),
|
||||
}
|
||||
eng.state.account = data.account
|
||||
eng.state.deviceToken = data.deviceToken
|
||||
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
||||
eng.state.deviceLabel = label
|
||||
eng.state.enabled = true
|
||||
eng.client:setAuth(data.account, data.deviceToken)
|
||||
eng.phase = "idle"
|
||||
eng.status = "Sync account created"
|
||||
eng:_persist()
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:linkDevice(code1, code2, label)
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local a = SyncClient.normalizeCode(code1)
|
||||
local b = SyncClient.normalizeCode(code2)
|
||||
if not a or not b then
|
||||
self:_fail("both codes are 8 digits")
|
||||
return false, "both codes are 8 digits"
|
||||
end
|
||||
self.phase = "checking"
|
||||
self.status = "Linking this device..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:link(a, b, label)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
||||
eng:_fail("the server sent an unexpected reply")
|
||||
return
|
||||
end
|
||||
eng.state.account = data.account
|
||||
eng.state.deviceToken = data.deviceToken
|
||||
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
||||
eng.state.deviceLabel = label
|
||||
eng.state.enabled = true
|
||||
eng.client:setAuth(data.account, data.deviceToken)
|
||||
eng.status = "This device is linked"
|
||||
eng:_persist()
|
||||
eng:syncNow()
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_forgetLocal()
|
||||
self.state = SyncState.defaults()
|
||||
self.client:clearAuth()
|
||||
self.codes = nil
|
||||
self.conflicts = {}
|
||||
self.devices = nil
|
||||
self.phase = "idle"
|
||||
self.status = UNLINKED_STATUS
|
||||
self:_persist()
|
||||
end
|
||||
|
||||
function SyncEngine:unlink()
|
||||
if not self:linked() then
|
||||
self:_forgetLocal()
|
||||
return true
|
||||
end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "checking"
|
||||
self.status = "Unlinking this device..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:unlink(self.state.deviceId)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng:_forgetLocal()
|
||||
end, function(eng, res)
|
||||
if res.code == 401 or res.code == 404 then
|
||||
eng:_forgetLocal()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:unlinkDevice(deviceId)
|
||||
if type(deviceId) ~= "string" or deviceId == "" then
|
||||
return false, "no such device"
|
||||
end
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if deviceId == self.state.deviceId then return self:unlink() end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "checking"
|
||||
self.status = "Unlinking that device..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:unlink(deviceId)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng.status = "That device was unlinked"
|
||||
eng.phase = "idle"
|
||||
eng:syncNow()
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:setEnabled(enabled)
|
||||
self.state.enabled = enabled and true or false
|
||||
self:_persist()
|
||||
return self.state.enabled
|
||||
end
|
||||
|
||||
function SyncEngine:syncNow()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self.pending then return false, "sync is busy" end
|
||||
self.queue = {}
|
||||
self.conflicts = {}
|
||||
self.state.pendingConflicts = {}
|
||||
self.phase = "checking"
|
||||
self.status = "Checking for changes..."
|
||||
self.error = nil
|
||||
local handle, err = self.client:fetchState()
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
eng:_planFrom(res.data or {})
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_planFrom(remoteState)
|
||||
self.devices = nil
|
||||
if type(remoteState.devices) == "table" then
|
||||
local list = {}
|
||||
for _, row in ipairs(remoteState.devices) do
|
||||
if type(row) == "table" and type(row.id) == "string" and row.id ~= "" then
|
||||
list[#list + 1] = {
|
||||
id = row.id,
|
||||
label = type(row.label) == "string" and row.label ~= "" and row.label
|
||||
or "device",
|
||||
createdAt = tonumber(row.createdAt),
|
||||
current = row.current == true or row.id == self.state.deviceId,
|
||||
}
|
||||
end
|
||||
end
|
||||
self.devices = list
|
||||
end
|
||||
local remote = type(remoteState.saves) == "table" and remoteState.saves or {}
|
||||
local locals = self.saves.list() or {}
|
||||
local seen = {}
|
||||
for _, entry in ipairs(locals) do
|
||||
local key = SyncState.key(entry.version, entry.playthroughId)
|
||||
if key then
|
||||
seen[key] = true
|
||||
local row = remote[key]
|
||||
local knownRev = SyncState.rev(self.state, key)
|
||||
local stamp = SyncState.stamp(self.state, key)
|
||||
local localChanged = stamp == nil
|
||||
or tonumber(entry.meta and entry.meta.savedAt) ~= stamp
|
||||
local remoteRev = row and tonumber(row.rev)
|
||||
local remoteChanged = row ~= nil and remoteRev ~= knownRev
|
||||
if not row then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif localChanged and remoteChanged then
|
||||
self:_addConflict(entry, key, row)
|
||||
elseif localChanged then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif remoteChanged then
|
||||
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||
end
|
||||
end
|
||||
end
|
||||
for key, row in pairs(remote) do
|
||||
if not seen[key] then
|
||||
local version, id = SyncState.splitKey(key)
|
||||
if version and id then
|
||||
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||
end
|
||||
end
|
||||
end
|
||||
if #self.queue == 0 then self:_finish() end
|
||||
end
|
||||
|
||||
function SyncEngine:_addConflict(entry, key, row)
|
||||
local remoteMeta = row
|
||||
if type(row.meta) == "table" then
|
||||
remoteMeta = row.meta
|
||||
elseif type(row.remoteMeta) == "table" then
|
||||
remoteMeta = row.remoteMeta
|
||||
end
|
||||
self.conflicts[#self.conflicts + 1] = {
|
||||
key = key,
|
||||
version = entry.version,
|
||||
playthroughId = entry.playthroughId,
|
||||
slot = entry.slot,
|
||||
entry = entry,
|
||||
localMeta = entry.meta,
|
||||
remoteMeta = remoteMeta,
|
||||
remoteRev = tonumber(row.rev),
|
||||
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
||||
}
|
||||
local pending = self.state.pendingConflicts or {}
|
||||
self.state.pendingConflicts = pending
|
||||
for _, row in ipairs(pending) do
|
||||
if row.key == key then return end
|
||||
end
|
||||
pending[#pending + 1] = {
|
||||
key = key,
|
||||
version = entry.version,
|
||||
playthroughId = entry.playthroughId,
|
||||
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
||||
}
|
||||
end
|
||||
|
||||
function SyncEngine:_queueUpload(entry, key, force)
|
||||
self:_enqueue(function(eng)
|
||||
eng.phase = "uploading"
|
||||
eng.status = "Uploading saves..."
|
||||
local handle, err = eng.client:putSave({
|
||||
version = entry.version,
|
||||
slot = entry.slot,
|
||||
meta = entry.meta,
|
||||
blob = entry.blob,
|
||||
baseRev = SyncState.rev(eng.state, key),
|
||||
force = force,
|
||||
})
|
||||
eng:_request(handle, err, function(e, res)
|
||||
local data = res.data or {}
|
||||
SyncState.setRev(e.state, key, tonumber(data.rev),
|
||||
entry.meta and entry.meta.savedAt)
|
||||
e:_persist()
|
||||
if not e:busy() then e:_finish() end
|
||||
end, function(e, res)
|
||||
if res.code == 409 then
|
||||
local row = res.data or {}
|
||||
e:_addConflict(entry, key, row)
|
||||
if not e:busy() then e:_finish() end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev)
|
||||
self:_enqueue(function(eng)
|
||||
eng.phase = "downloading"
|
||||
eng.status = "Downloading saves..."
|
||||
local handle, err = eng.client:getSave(version, playthroughId)
|
||||
eng:_request(handle, err, function(e, res)
|
||||
local data = res.data or {}
|
||||
if type(data.blob) ~= "string" or data.blob == "" then
|
||||
e:_fail("the server sent no save data")
|
||||
return
|
||||
end
|
||||
local slotId, writeErr = e.saves.write(version, playthroughId, data.blob, mode)
|
||||
if not slotId then
|
||||
e:_fail(writeErr or "could not write the downloaded save")
|
||||
return
|
||||
end
|
||||
if mode ~= "new" then
|
||||
local meta = type(data.meta) == "table" and data.meta or {}
|
||||
SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev,
|
||||
tonumber(meta.savedAt))
|
||||
end
|
||||
e:_persist()
|
||||
if not e:busy() then e:_finish() end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:resolveConflict(key, choice)
|
||||
local index
|
||||
for i, row in ipairs(self.conflicts) do
|
||||
if row.key == key then index = i break end
|
||||
end
|
||||
if not index then return false, "no such conflict" end
|
||||
local conflict = table.remove(self.conflicts, index)
|
||||
local kept = {}
|
||||
for _, row in ipairs(self.state.pendingConflicts or {}) do
|
||||
if row.key ~= key then kept[#kept + 1] = row end
|
||||
end
|
||||
self.state.pendingConflicts = kept
|
||||
|
||||
if choice == "local" then
|
||||
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
||||
self:_queueUpload(conflict.entry, key, true)
|
||||
elseif choice == "remote" then
|
||||
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
||||
"replace", conflict.remoteRev)
|
||||
elseif choice == "both" then
|
||||
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
||||
"new", conflict.remoteRev)
|
||||
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
||||
self:_queueUpload(conflict.entry, key, true)
|
||||
else
|
||||
return false, "unknown resolution"
|
||||
end
|
||||
self.phase = "uploading"
|
||||
self.status = "Applying your choice..."
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:uploadMods()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local manifest = SyncMods.build(self.modDeps)
|
||||
self.phase = "uploading"
|
||||
self.status = "Uploading the mod list..."
|
||||
local handle, err = self.client:putMods(manifest)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng.phase = "idle"
|
||||
eng.status = "Mod list synced"
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:fetchModPlan()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "downloading"
|
||||
self.status = "Reading the mod list..."
|
||||
local handle, err = self.client:getMods()
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||
eng.phase = "idle"
|
||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||
and "Mods already match" or "Mod changes ready to apply"
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:shareMods()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local manifest = SyncMods.build(self.modDeps)
|
||||
self.phase = "uploading"
|
||||
self.status = "Sharing the mod list..."
|
||||
local handle, err = self.client:shareMods(manifest)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
eng.shareCode = type(data.code) == "string" and data.code or nil
|
||||
eng.phase = "idle"
|
||||
eng.status = eng.shareCode and ("Share code " .. eng.shareCode)
|
||||
or "The server sent no share code"
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:fetchShare(code)
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
self.phase = "downloading"
|
||||
self.status = "Fetching that mod list..."
|
||||
local handle, err = self.client:fetchShare(code)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||
eng.phase = "idle"
|
||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||
and "Mods already match" or "Mod changes ready to apply"
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:applyModPlan(progress)
|
||||
if not self.modPlan then return false, "no mod plan" end
|
||||
if self.modApply then return false, "the mods are already being applied" end
|
||||
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
||||
if #steps == 0 then
|
||||
self.modPlan = nil
|
||||
self.status = "Mods already match"
|
||||
if progress then progress(0, 0, nil, true) end
|
||||
return true
|
||||
end
|
||||
self.modApply = { steps = steps, index = 0, failures = {},
|
||||
progress = progress }
|
||||
self.phase = "applying"
|
||||
self.status = ("Applying mods... 0 of %d"):format(#steps)
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:applyingMods()
|
||||
return self.modApply ~= nil
|
||||
end
|
||||
|
||||
function SyncEngine:_stepModApply()
|
||||
local job = self.modApply
|
||||
local step = job.steps[job.index + 1]
|
||||
job.index = job.index + 1
|
||||
local ok, res, why = pcall(step.run)
|
||||
if not ok then
|
||||
job.failures[#job.failures + 1] = tostring(res)
|
||||
elseif not res then
|
||||
job.failures[#job.failures + 1] = tostring(why or step.label)
|
||||
end
|
||||
local total = #job.steps
|
||||
local done = job.index >= total
|
||||
if not done then
|
||||
self.status = ("Applying mods... %d of %d"):format(job.index, total)
|
||||
if job.progress then
|
||||
pcall(job.progress, job.index, total, step.label, false)
|
||||
end
|
||||
return
|
||||
end
|
||||
self.modApply = nil
|
||||
self.modPlan = nil
|
||||
self.phase = "idle"
|
||||
if #job.failures > 0 then
|
||||
self.status = "Some mods could not be applied: "
|
||||
.. table.concat(job.failures, "; ")
|
||||
else
|
||||
self.status = "Mods applied"
|
||||
end
|
||||
if job.progress then
|
||||
pcall(job.progress, job.index, total, step.label, true)
|
||||
end
|
||||
end
|
||||
|
||||
return SyncEngine
|
||||
@@ -0,0 +1,196 @@
|
||||
local SyncMods = {}
|
||||
|
||||
SyncMods.REV = 1
|
||||
|
||||
local function versions()
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
||||
return { "red", "blue", "yellow", "gold" }
|
||||
end
|
||||
|
||||
local function defaultDeps()
|
||||
return {
|
||||
installed = function()
|
||||
return require("src.mods.LauncherMods").list()
|
||||
end,
|
||||
indexes = function()
|
||||
return require("src.mods.ModIndex").sources()
|
||||
end,
|
||||
addIndex = function(url)
|
||||
return require("src.mods.ModIndex").addSource(url)
|
||||
end,
|
||||
findEntry = function(id)
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
for _, source in ipairs(ModIndex.sources()) do
|
||||
local cached = ModIndex.readCache(source.feed)
|
||||
for _, entry in ipairs((cached and cached.mods) or {}) do
|
||||
if entry.id == id then return entry end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
install = function(entry)
|
||||
return require("src.mods.LauncherMods").installFromIndex(entry)
|
||||
end,
|
||||
setEnabled = function(id, enabled, version)
|
||||
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function deps(given)
|
||||
local out = defaultDeps()
|
||||
if type(given) == "table" then
|
||||
for k, v in pairs(given) do out[k] = v end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function sourceOf(row)
|
||||
local github = row.github
|
||||
or (type(row.manifest) == "table" and row.manifest.github)
|
||||
if type(github) == "string" and github ~= "" then
|
||||
return "github:" .. github
|
||||
end
|
||||
return "local"
|
||||
end
|
||||
|
||||
function SyncMods.build(given)
|
||||
local d = deps(given)
|
||||
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
local url = row.url or row.feed
|
||||
if type(url) == "string" and url ~= "" then
|
||||
manifest.indexes[#manifest.indexes + 1] = url
|
||||
end
|
||||
end
|
||||
table.sort(manifest.indexes)
|
||||
for _, row in ipairs(d.installed() or {}) do
|
||||
if type(row) == "table" and type(row.id) == "string" then
|
||||
local enabledFor = {}
|
||||
local answers = row.enabledByVersion or {}
|
||||
for _, version in ipairs(versions()) do
|
||||
if answers[version] then enabledFor[#enabledFor + 1] = version end
|
||||
end
|
||||
manifest.mods[#manifest.mods + 1] = {
|
||||
id = row.id,
|
||||
version = row.version,
|
||||
source = sourceOf(row),
|
||||
enabledFor = enabledFor,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(manifest.mods, function(a, b) return a.id < b.id end)
|
||||
return manifest
|
||||
end
|
||||
|
||||
function SyncMods.plan(manifest, given)
|
||||
local d = deps(given)
|
||||
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
|
||||
if type(manifest) ~= "table" then return plan end
|
||||
|
||||
local haveIndex = {}
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
if type(row.url) == "string" then haveIndex[row.url] = true end
|
||||
if type(row.feed) == "string" then haveIndex[row.feed] = true end
|
||||
end
|
||||
for _, url in ipairs(manifest.indexes or {}) do
|
||||
if type(url) == "string" and url ~= "" and not haveIndex[url] then
|
||||
plan.indexes[#plan.indexes + 1] = url
|
||||
haveIndex[url] = true
|
||||
end
|
||||
end
|
||||
|
||||
local installed = {}
|
||||
for _, row in ipairs(d.installed() or {}) do
|
||||
if type(row) == "table" and type(row.id) == "string" then
|
||||
installed[row.id] = row
|
||||
end
|
||||
end
|
||||
|
||||
for _, mod in ipairs(manifest.mods or {}) do
|
||||
if type(mod) == "table" and type(mod.id) == "string" then
|
||||
local here = installed[mod.id]
|
||||
local available = here ~= nil
|
||||
if not here then
|
||||
local entry = d.findEntry(mod.id)
|
||||
if entry then
|
||||
available = true
|
||||
plan.toInstall[#plan.toInstall + 1] =
|
||||
{ id = mod.id, version = mod.version, entry = entry }
|
||||
else
|
||||
plan.missing[#plan.missing + 1] =
|
||||
{ id = mod.id, version = mod.version, source = mod.source }
|
||||
end
|
||||
end
|
||||
if available then
|
||||
local answers = (here and here.enabledByVersion) or {}
|
||||
for _, version in ipairs(mod.enabledFor or {}) do
|
||||
if answers[version] ~= true then
|
||||
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return plan
|
||||
end
|
||||
|
||||
function SyncMods.planEmpty(plan)
|
||||
if type(plan) ~= "table" then return true end
|
||||
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
|
||||
and #(plan.toEnable or {}) == 0
|
||||
end
|
||||
|
||||
function SyncMods.steps(plan, given)
|
||||
local d = deps(given)
|
||||
local out = {}
|
||||
if type(plan) ~= "table" then return out end
|
||||
local broken = {}
|
||||
|
||||
for _, url in ipairs(plan.indexes or {}) do
|
||||
out[#out + 1] = { label = url, run = function()
|
||||
local ok, err = d.addIndex(url)
|
||||
if not ok then return nil, tostring(err or url) end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
for _, mod in ipairs(plan.toInstall or {}) do
|
||||
out[#out + 1] = { label = mod.id, run = function()
|
||||
local ok, err = d.install(mod.entry)
|
||||
if not ok then
|
||||
broken[mod.id] = true
|
||||
return nil, mod.id .. ": " .. tostring(err or "install failed")
|
||||
end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
for _, want in ipairs(plan.toEnable or {}) do
|
||||
out[#out + 1] = { label = want.id, run = function()
|
||||
if broken[want.id] then return true end
|
||||
local ok, err = d.setEnabled(want.id, true, want.version)
|
||||
if ok == false then
|
||||
return nil, want.id .. ": " .. tostring(err or "could not enable")
|
||||
end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SyncMods.apply(plan, progress, given)
|
||||
if type(plan) ~= "table" then return false, "nothing to apply" end
|
||||
local steps = SyncMods.steps(plan, given)
|
||||
local failures = {}
|
||||
for i, step in ipairs(steps) do
|
||||
local ok, err = step.run()
|
||||
if not ok then failures[#failures + 1] = err end
|
||||
if progress then progress(i, #steps, step.label) end
|
||||
end
|
||||
if #failures > 0 then
|
||||
return false, table.concat(failures, "; ")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return SyncMods
|
||||
@@ -0,0 +1,129 @@
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local SyncState = {}
|
||||
|
||||
SyncState.KEY = "saveSync"
|
||||
|
||||
function SyncState.defaults()
|
||||
return {
|
||||
enabled = false,
|
||||
lastSyncAt = 0,
|
||||
revs = {},
|
||||
stamps = {},
|
||||
pendingConflicts = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function str(v)
|
||||
if type(v) == "string" and v ~= "" then return v end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function num(v)
|
||||
local n = tonumber(v)
|
||||
if type(n) ~= "number" or n ~= n or n == math.huge or n == -math.huge then
|
||||
return nil
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
function SyncState.sanitize(raw)
|
||||
local out = SyncState.defaults()
|
||||
if type(raw) ~= "table" then return out end
|
||||
out.enabled = raw.enabled == true
|
||||
out.account = str(raw.account)
|
||||
out.deviceToken = str(raw.deviceToken)
|
||||
out.deviceId = str(raw.deviceId)
|
||||
out.deviceLabel = str(raw.deviceLabel)
|
||||
out.lastSyncAt = num(raw.lastSyncAt) or 0
|
||||
if type(raw.revs) == "table" then
|
||||
for key, rev in pairs(raw.revs) do
|
||||
local n = num(rev)
|
||||
if type(key) == "string" and n then out.revs[key] = n end
|
||||
end
|
||||
end
|
||||
if type(raw.stamps) == "table" then
|
||||
for key, at in pairs(raw.stamps) do
|
||||
local n = num(at)
|
||||
if type(key) == "string" and n then out.stamps[key] = n end
|
||||
end
|
||||
end
|
||||
if type(raw.pendingConflicts) == "table" then
|
||||
for _, row in ipairs(raw.pendingConflicts) do
|
||||
if type(row) == "table" and str(row.key) then
|
||||
out.pendingConflicts[#out.pendingConflicts + 1] = {
|
||||
key = row.key,
|
||||
version = str(row.version),
|
||||
playthroughId = str(row.playthroughId),
|
||||
overlap = row.overlap == true,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SyncState.load(fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
return SyncState.sanitize(opts and opts[SyncState.KEY])
|
||||
end
|
||||
|
||||
function SyncState.save(state, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts[SyncState.KEY] = SyncState.sanitize(state)
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return opts[SyncState.KEY]
|
||||
end
|
||||
|
||||
function SyncState.update(fn, fs)
|
||||
local state = SyncState.load(fs)
|
||||
fn(state)
|
||||
return SyncState.save(state, fs)
|
||||
end
|
||||
|
||||
function SyncState.clear(fs)
|
||||
return SyncState.save(SyncState.defaults(), fs)
|
||||
end
|
||||
|
||||
function SyncState.linked(state)
|
||||
return type(state) == "table" and str(state.account) ~= nil
|
||||
and str(state.deviceToken) ~= nil
|
||||
end
|
||||
|
||||
function SyncState.key(version, playthroughId)
|
||||
if type(version) ~= "string" or version == "" then return nil end
|
||||
if type(playthroughId) ~= "string" or playthroughId == "" then return nil end
|
||||
return version .. "/" .. playthroughId
|
||||
end
|
||||
|
||||
function SyncState.splitKey(key)
|
||||
if type(key) ~= "string" then return nil end
|
||||
local version, id = key:match("^([^/]+)/(.+)$")
|
||||
return version, id
|
||||
end
|
||||
|
||||
function SyncState.rev(state, key)
|
||||
if type(state) ~= "table" or type(state.revs) ~= "table" then return nil end
|
||||
return state.revs[key]
|
||||
end
|
||||
|
||||
function SyncState.stamp(state, key)
|
||||
if type(state) ~= "table" or type(state.stamps) ~= "table" then return nil end
|
||||
return state.stamps[key]
|
||||
end
|
||||
|
||||
function SyncState.setRev(state, key, rev, savedAt)
|
||||
if type(state) ~= "table" or type(key) ~= "string" then return end
|
||||
state.revs = state.revs or {}
|
||||
state.stamps = state.stamps or {}
|
||||
state.revs[key] = num(rev)
|
||||
state.stamps[key] = num(savedAt)
|
||||
end
|
||||
|
||||
function SyncState.forget(state, key)
|
||||
if type(state) ~= "table" or type(key) ~= "string" then return end
|
||||
if type(state.revs) == "table" then state.revs[key] = nil end
|
||||
if type(state.stamps) == "table" then state.stamps[key] = nil end
|
||||
end
|
||||
|
||||
return SyncState
|
||||
@@ -0,0 +1,41 @@
|
||||
local Transport = {}
|
||||
Transport.__index = Transport
|
||||
|
||||
function Transport.new(fetch)
|
||||
return setmetatable({ fetch = fetch or require("src.net.Fetch") }, Transport)
|
||||
end
|
||||
|
||||
function Transport:begin(req)
|
||||
return self.fetch.request(req.url, {
|
||||
method = req.method,
|
||||
body = req.body,
|
||||
headers = req.headers,
|
||||
maxSeconds = req.maxSeconds,
|
||||
})
|
||||
end
|
||||
|
||||
function Transport:poll(handle)
|
||||
local st = self.fetch.poll(handle)
|
||||
if st.status == "pending" then return { status = "pending" } end
|
||||
if st.status == "cancelled" then
|
||||
return { status = "error", err = "sync request cancelled" }
|
||||
end
|
||||
if st.status ~= "ok" then
|
||||
return { status = "error", err = st.err or "sync request failed" }
|
||||
end
|
||||
return { status = "ok", body = st.body or "", code = tonumber(st.code) }
|
||||
end
|
||||
|
||||
function Transport:release(handle)
|
||||
if handle ~= nil and self.fetch.release then self.fetch.release(handle) end
|
||||
end
|
||||
|
||||
function Transport:cancel(handle)
|
||||
if handle ~= nil and self.fetch.cancel then self.fetch.cancel(handle) end
|
||||
end
|
||||
|
||||
function Transport:available()
|
||||
return self.fetch.available and self.fetch.available() or false
|
||||
end
|
||||
|
||||
return Transport
|
||||
Reference in New Issue
Block a user