otclient-redemption/modules/client_assets/client_assets.lua
Eduardo Dantas 465b7a217e
fix: select client asset archives by version (#1766)
Bug Fixes:
- Improved release archive selection by scoring candidate assets against the client version for more accurate matches.
- Added macOS-archive-path detection to exclude incompatible .app.zip/macos-variant archives from selection.
- Refined the no-match behavior so the system cleanly falls back to the standard zip download when no best archive is available.
2026-07-18 20:52:38 -03:00

1586 lines
55 KiB
Lua

ClientAssets = {}
local DEFAULT_CONFIG = {
enabled = true,
repository = 'dudantas/tibia-client',
rawBaseUrl = 'https://raw.githubusercontent.com/%s/%s/',
installSounds = true,
timeout = 30,
retries = 2,
preferPackedManifestUrls = true,
strictManifestSha256 = true,
allowRawFallbackHashMismatch = false,
allowMissingPackedRawFallback = true,
preferArchive = true,
fallbackToArchiveOnManifestFailure = false,
installArchiveExtras = true,
archiveExtraPrefixes = { 'bin' },
archiveExtrasDestination = '',
installPackagedFiles = true,
packagedFilesDestination = '',
packagedFilesRequired = false,
installInWorkDir = true
}
local activeDownload
local releasesCache = {}
local ARCHIVE_EXTENSIONS = { '.zip', '.rar' }
local DOWNLOAD_WINDOW_WIDTH = 360
local DOWNLOAD_WINDOW_HEIGHT = 140
local DOWNLOAD_TEXT_MARGIN = 34
local DOWNLOAD_BAR_HEIGHT = 22
local BUSY_INDICATOR_IMAGE = '/client_assets/images/hourglass'
local BUSY_INDICATOR_SIZE = 20
local BUSY_INDICATOR_ROTATION_DELAY = 650
local BUSY_INDICATOR_ROTATION_STEP = 180
local HTTP_NOT_FOUND_SHA256 = 'd5558cd419c8d46bdc958064cb97f963d1ea793866414c025906ec15033512ed'
local function logInfo(message)
g_logger.info('[client_assets] ' .. tostring(message))
end
local function logWarning(message)
g_logger.warning('[client_assets] ' .. tostring(message))
end
local function logError(message)
g_logger.error('[client_assets] ' .. tostring(message))
end
local function startsWith(value, prefix)
return type(value) == 'string' and value:sub(1, #prefix) == prefix
end
local function endsWith(value, suffix)
return type(value) == 'string' and suffix ~= '' and value:sub(-#suffix) == suffix
end
local function isArchivePath(path)
path = tostring(path or ''):lower()
for _, extension in ipairs(ARCHIVE_EXTENSIONS) do
if endsWith(path, extension) then
return true
end
end
return false
end
local function isLzmaPath(path)
return endsWith(tostring(path or ''):lower(), '.lzma')
end
local function isMacArchivePath(path)
path = tostring(path or ''):lower()
return endsWith(path, '.app.zip') or
path:find('macos', 1, true) or
path:find('%f[%a]mac%f[^%a]')
end
local function isNotFoundError(message)
message = tostring(message or ''):lower()
return message:find('404', 1, true) or message:find('not found', 1, true)
end
local function isMissingPackedHashError(message)
return tostring(message or ''):find(HTTP_NOT_FOUND_SHA256, 1, true) ~= nil
end
local function isPackedToRawFallback(downloadInfo, fallbackDownload)
if not downloadInfo or not fallbackDownload then
return false
end
local sourcePath = tostring(downloadInfo.sourcePath or '')
local fallbackPath = tostring(fallbackDownload.sourcePath or '')
if fallbackPath == '' or isLzmaPath(fallbackPath) or isArchivePath(fallbackPath) then
return false
end
return isLzmaPath(sourcePath) or isArchivePath(sourcePath)
end
local function parentPath(path)
path = tostring(path or ''):gsub('\\', '/')
return path:match('^(.*)/[^/]+$') or ''
end
local function stripArchiveExtension(path)
for _, extension in ipairs(ARCHIVE_EXTENSIONS) do
if endsWith(path:lower(), extension) then
return path:sub(1, #path - #extension)
end
end
return path
end
local function toList(value)
if type(value) == 'table' then
return value
end
if type(value) == 'string' then
return { value }
end
return {}
end
local function matchesAnyPrefix(path, prefixes)
path = tostring(path or '')
local prefixList = toList(prefixes)
if #prefixList == 0 then
return true
end
for _, prefix in ipairs(prefixList) do
if startsWith(path, prefix) then
return true
end
end
return false
end
local function joinUrl(baseUrl, path)
if not baseUrl or baseUrl == '' then
return path
end
if startsWith(path, 'http://') or startsWith(path, 'https://') then
return path
end
if not endsWith(baseUrl, '/') then
baseUrl = baseUrl .. '/'
end
while startsWith(path, '/') do
path = path:sub(2)
end
return baseUrl .. path
end
local function cloneConfig()
local config = {}
for key, value in pairs(DEFAULT_CONFIG) do
config[key] = value
end
local custom = Services and Services.clientAssets
if custom == false then
config.enabled = false
return config
end
if type(custom) == 'string' then
config.manifestUrl = custom
elseif type(custom) == 'table' then
for key, value in pairs(custom) do
config[key] = value
end
end
if not config.releasesUrl and config.repository then
config.releasesUrl = string.format('https://api.github.com/repos/%s/releases?per_page=100', config.repository)
end
return config
end
local function withHttpTimeout(config, fn)
local oldTimeout = HTTP.timeout
HTTP.timeout = config.timeout or oldTimeout
local ok, result = pcall(fn)
HTTP.timeout = oldTimeout
if not ok then
error(result)
end
return result
end
local function httpGet(config, url, callback)
return withHttpTimeout(config, function()
return HTTP.get(url, callback)
end)
end
local function httpGetJSON(config, url, callback)
return withHttpTimeout(config, function()
return HTTP.getJSON(url, callback)
end)
end
local function httpDownload(config, url, path, callback, progressCallback)
return withHttpTimeout(config, function()
return HTTP.download(url, path, callback, progressCallback)
end)
end
local function versionLabel(version)
version = tonumber(version) or 0
return string.format('%d.%02d', math.floor(version / 100), version % 100)
end
local function shortenText(value, maxLength)
value = tostring(value or '')
maxLength = maxLength or 56
if #value <= maxLength then
return value
end
if maxLength <= 8 then
return value:sub(1, maxLength)
end
local head = math.floor((maxLength - 3) / 2)
local tail = maxLength - 3 - head
return value:sub(1, head) .. '...' .. value:sub(#value - tail + 1)
end
local function userFacingError(message)
if not message then
return nil
end
message = tostring(message)
if message:find('SHA%-256') or #message > 180 then
return 'Unable to download client assets.\nSee the console for full details.\n' .. shortenText(message, 120)
end
return message
end
local function createDownloadWindow()
local initialText = tr('Preparing download...') .. '\n0%'
local window = displayCancelBox(tr('Downloading Assets'), initialText)
local width = DOWNLOAD_WINDOW_WIDTH
if rootWidget then
width = math.min(width, math.max(280, rootWidget:getWidth() - 40))
end
window:setWidth(width)
window:setHeight(DOWNLOAD_WINDOW_HEIGHT)
window.content:setTextWrap(true)
window.content:setWidth(width - DOWNLOAD_TEXT_MARGIN)
window.content:setText(initialText)
window.content:resizeToText()
local progressBar = g_ui.createWidget('UIWidget', window)
progressBar:setId('assetDownloadProgressBar')
progressBar:setHeight(DOWNLOAD_BAR_HEIGHT)
progressBar:setBackgroundColor('#1a1a1a')
progressBar:setBorderWidth(1)
progressBar:setBorderColor('#000000')
progressBar:addAnchor(AnchorLeft, 'parent', AnchorLeft)
progressBar:addAnchor(AnchorRight, 'parent', AnchorRight)
progressBar:addAnchor(AnchorTop, 'content', AnchorBottom)
progressBar:setMarginLeft(15)
progressBar:setMarginRight(15)
progressBar:setMarginTop(8)
window.progressBar = progressBar
local progressFill = g_ui.createWidget('UIWidget', progressBar)
progressFill:setId('assetDownloadProgressFill')
progressFill:setBackgroundColor('#4444ff')
progressFill:setHeight(DOWNLOAD_BAR_HEIGHT - 2)
progressFill:setWidth(0)
progressFill:addAnchor(AnchorLeft, 'parent', AnchorLeft)
progressFill:addAnchor(AnchorTop, 'parent', AnchorTop)
progressFill:addAnchor(AnchorBottom, 'parent', AnchorBottom)
progressFill:setMarginLeft(1)
progressFill:setMarginTop(1)
progressFill:setMarginBottom(1)
window.progressFill = progressFill
local busyIndicator = g_ui.createWidget('UIWidget', window)
busyIndicator:setId('assetDownloadBusyIndicator')
busyIndicator:setSize(string.format('%d %d', BUSY_INDICATOR_SIZE, BUSY_INDICATOR_SIZE))
busyIndicator:setImageSource(BUSY_INDICATOR_IMAGE)
busyIndicator:setImageSmooth(true)
busyIndicator:setPhantom(true)
busyIndicator:setVisible(false)
busyIndicator:addAnchor(AnchorHorizontalCenter, 'assetDownloadProgressBar', AnchorHorizontalCenter)
busyIndicator:addAnchor(AnchorVerticalCenter, 'assetDownloadProgressBar', AnchorVerticalCenter)
busyIndicator:raise()
window.busyIndicator = busyIndicator
local separator = window:getChildById('messageBoxSeparator')
if separator then
separator:setVisible(false)
end
window.holder = window:getChildById('holder')
if window.holder then
window.holder:breakAnchors()
window.holder:addAnchor(AnchorRight, 'assetDownloadProgressBar', AnchorRight)
window.holder:addAnchor(AnchorLeft, 'assetDownloadProgressBar', AnchorLeft)
window.holder:addAnchor(AnchorTop, 'assetDownloadProgressBar', AnchorBottom)
window.holder:addAnchor(AnchorBottom, 'parent', AnchorBottom)
window.holder:setMarginTop(14)
end
return window
end
local function clampPercent(percent)
percent = math.floor(tonumber(percent) or 0)
return math.max(0, math.min(100, percent))
end
local function updateDownloadBar(window, percent)
if not window or not window.progressFill or not window.progressBar then
return
end
percent = clampPercent(percent)
local width = math.max(0, (window.progressBar:getWidth() or 0) - 2)
window.progressFill:setWidth(math.floor(width * percent / 100))
end
local function completeMarkerPath(version)
return string.format('data/things/%d/.client-assets-complete', version)
end
local function joinPhysicalPath(base, path)
base = tostring(base or ''):gsub('\\', '/'):gsub('/+$', '')
path = tostring(path or ''):gsub('\\', '/'):gsub('^/+', '')
if base == '' then
return path
end
return base .. '/' .. path
end
local function physicalInstallPath(path)
return joinPhysicalPath(g_resources.getWorkDir(), path)
end
local function shouldInstallInWorkDir(config)
return config and config.installInWorkDir ~= false
end
local function writeInstallFile(config, path, contents)
if shouldInstallInWorkDir(config) and g_resources.writeFileContentsToWorkDir then
return g_resources.writeFileContentsToWorkDir(path, contents)
end
local directory = parentPath(path)
if directory ~= '' then
g_resources.makeDir('/' .. directory)
end
return g_resources.writeFileContents('/' .. path, contents)
end
local function hasCatalogEntryFile(basePath, entry)
if type(entry) ~= 'table' or type(entry.file) ~= 'string' then
return false
end
return g_resources.fileExists(basePath .. entry.file)
end
local function installFileExists(path)
if g_resources.fileExistsInWorkDir and g_resources.fileExistsInWorkDir(path) then
return true
end
return g_resources.fileExists('/' .. path)
end
local function readInstallFile(path)
if g_resources.readFileContentsFromWorkDir and
(not g_resources.fileExistsInWorkDir or g_resources.fileExistsInWorkDir(path)) then
local ok, contents = pcall(function() return g_resources.readFileContentsFromWorkDir(path) end)
if ok and type(contents) == 'string' then
return contents
end
end
return g_resources.readFileContents('/' .. path)
end
local function hasInstallCatalogEntryFile(basePath, entry)
if type(entry) ~= 'table' or type(entry.file) ~= 'string' then
return false
end
return installFileExists(basePath .. entry.file)
end
local function hasModernClientFilesAtPath(basePath, installPath)
local fileExists = installPath and installFileExists or g_resources.fileExists
local readFile = installPath and readInstallFile or g_resources.readFileContents
if not fileExists(basePath .. 'catalog-content.json') or
not fileExists(basePath .. 'assets.json.sha256') then
return false
end
local ok, data = pcall(readFile, basePath .. 'catalog-content.json')
if not ok or type(data) ~= 'string' then
return false
end
local decodeOk, catalog = pcall(json.decode, data)
if not decodeOk or type(catalog) ~= 'table' then
return false
end
local hasAppearances = false
local hasStaticData = false
for _, entry in ipairs(catalog) do
local entryType = entry.type
if entryType == 'appearances' or entryType == 'staticdata' or entryType == 'proficiencies' then
if installPath and not hasInstallCatalogEntryFile(basePath, entry) then
return false
elseif not installPath and not hasCatalogEntryFile(basePath, entry) then
return false
end
if entryType == 'appearances' then
hasAppearances = true
elseif entryType == 'staticdata' then
hasStaticData = true
end
end
end
return hasAppearances and hasStaticData
end
local function hasModernClientFiles(version)
return hasModernClientFilesAtPath(string.format('data/things/%d/', version), true)
end
local hasInstalledModernClientFiles = hasModernClientFiles
local function markClientVersionInstalled(config, version)
local markerPath = completeMarkerPath(version)
local contents = string.format('client=%d\n', version)
if shouldInstallInWorkDir(config) and g_resources.writeFileContentsToWorkDir then
return g_resources.writeFileContentsToWorkDir(markerPath, contents)
end
g_resources.makeDir(string.format('/data/things/%d', version))
return g_resources.writeFileContents('/' .. markerPath, contents)
end
local function normalizeVersionKey(version)
return tostring(tonumber(version) or version)
end
local function readVersionEntry(manifest, version)
local key = normalizeVersionKey(version)
if type(manifest) ~= 'table' then
return nil
end
if type(manifest.versions) == 'table' then
return manifest.versions[key] or manifest.versions[tonumber(version)]
end
return manifest[key] or manifest[tonumber(version)]
end
local function normalizeDescriptor(config, version, descriptor)
if type(descriptor) ~= 'table' then
return nil
end
descriptor.version = tonumber(version)
descriptor.installSounds = descriptor.installSounds
if descriptor.installSounds == nil then
descriptor.installSounds = config.installSounds
end
descriptor.baseUrl = descriptor.baseUrl or descriptor.rawBaseUrl
if not descriptor.baseUrl and descriptor.tag and config.repository then
descriptor.baseUrl = string.format(config.rawBaseUrl, config.repository, descriptor.tag)
end
descriptor.manifestUrl = descriptor.manifestUrl or descriptor.manifest or descriptor.assetsManifest
descriptor.manifestSha256 = descriptor.manifestSha256 or descriptor.assetsManifestSha256
descriptor.manifestSha256Url = descriptor.manifestSha256Url or descriptor.assetsManifestSha256Url
if type(descriptor.archive) == 'table' then
descriptor.archiveUrl = descriptor.archive.url or descriptor.archiveUrl
descriptor.archiveSha256 = descriptor.archive.sha256 or descriptor.archiveSha256
descriptor.archiveThingsPrefix = descriptor.archive.thingsPrefix or descriptor.archiveThingsPrefix
descriptor.archiveSoundsPrefix = descriptor.archive.soundsPrefix or descriptor.archiveSoundsPrefix
end
descriptor.archiveUrl = descriptor.archiveUrl or descriptor.zipUrl or descriptor.packageUrl
descriptor.archiveThingsPrefix = descriptor.archiveThingsPrefix or 'assets'
descriptor.archiveSoundsPrefix = descriptor.archiveSoundsPrefix or 'sounds'
descriptor.installArchiveExtras = descriptor.installArchiveExtras
if descriptor.installArchiveExtras == nil then
descriptor.installArchiveExtras = config.installArchiveExtras
end
descriptor.archiveExtraPrefixes = descriptor.archiveExtraPrefixes or config.archiveExtraPrefixes
descriptor.archiveExtrasDestination = descriptor.archiveExtrasDestination or config.archiveExtrasDestination
descriptor.treeUrl = descriptor.treeUrl or descriptor.packagedFilesUrl
if not descriptor.treeUrl and descriptor.tag and config.repository then
descriptor.treeUrl = string.format('https://api.github.com/repos/%s/git/trees/%s?recursive=1', config.repository, descriptor.tag)
end
descriptor.installPackagedFiles = descriptor.installPackagedFiles
if descriptor.installPackagedFiles == nil then
descriptor.installPackagedFiles = config.installPackagedFiles
end
descriptor.packagedFilePrefixes = descriptor.packagedFilePrefixes or config.packagedFilePrefixes
descriptor.packagedFilesDestination = descriptor.packagedFilesDestination or config.packagedFilesDestination
descriptor.packagedFilesRequired = descriptor.packagedFilesRequired
if descriptor.packagedFilesRequired == nil then
descriptor.packagedFilesRequired = config.packagedFilesRequired
end
return descriptor
end
local function findReleaseArchive(release, version)
if type(release.assets) ~= 'table' then
return nil
end
local tag = tostring(release.tag_name or ''):lower()
local label = version and versionLabel(version):lower() or ''
local bestUrl
local bestScore = 0
for _, asset in ipairs(release.assets) do
local name = tostring(asset.name or ''):lower()
local url = asset.browser_download_url
if url and isArchivePath(name) and not isMacArchivePath(name) then
local score = 0
if tag ~= '' and name:find(tag, 1, true) then
score = score + 4
end
if label ~= '' and name:find(label, 1, true) then
score = score + 2
end
-- Releases may contain unrelated legacy client archives. Only accept
-- an archive that identifies the requested tag or client version.
if score > 0 then
if name:find('client', 1, true) then
score = score + 1
end
if name:find('original', 1, true) or name:find('linux', 1, true) then
score = score - 1
end
if score > bestScore then
bestUrl = url
bestScore = score
end
end
end
end
return bestUrl
end
local function codeloadZipUrl(repository, tag)
if not repository or not tag then
return nil
end
return string.format('https://codeload.github.com/%s/zip/refs/tags/%s', repository, tag)
end
local function descriptorFromRelease(config, version, release)
local tag = release.tag_name
if not tag or tag == '' then
return nil
end
local baseUrl = string.format(config.rawBaseUrl, config.repository, tag)
return normalizeDescriptor(config, version, {
tag = tag,
baseUrl = baseUrl,
manifestUrl = baseUrl .. 'assets.json',
manifestSha256Url = baseUrl .. 'assets.json.sha256',
treeUrl = string.format('https://api.github.com/repos/%s/git/trees/%s?recursive=1', config.repository, tag),
archiveUrl = findReleaseArchive(release, version) or codeloadZipUrl(config.repository, tag)
})
end
local function findReleaseForVersion(releases, version)
local label = versionLabel(version):lower()
for _, release in ipairs(releases) do
local tag = tostring(release.tag_name or ''):lower()
local name = tostring(release.name or ''):lower()
if tag:find(label, 1, true) or name:find(label, 1, true) then
return release
end
end
return nil
end
local function overallProgress(index, total, fileProgress)
total = math.max(tonumber(total) or 1, 1)
index = math.max(tonumber(index) or 1, 1)
fileProgress = math.max(0, math.min(100, tonumber(fileProgress) or 0))
return clampPercent(((index - 1) + (fileProgress / 100)) * 100 / total)
end
local function stopDownloadBusy()
if activeDownload and activeDownload.busyEvent then
removeEvent(activeDownload.busyEvent)
activeDownload.busyEvent = nil
end
if activeDownload and activeDownload.busySpinEvent then
removeEvent(activeDownload.busySpinEvent)
activeDownload.busySpinEvent = nil
end
if activeDownload and activeDownload.window and activeDownload.window.busyIndicator then
activeDownload.window.busyIndicator:setRotation(0)
activeDownload.window.busyIndicator:setVisible(false)
end
end
local function renderDownloadStatus(status, percent, detail)
if not activeDownload or not activeDownload.window or not activeDownload.window.content then
return
end
local width = DOWNLOAD_WINDOW_WIDTH
if rootWidget then
width = math.min(width, math.max(260, rootWidget:getWidth() - 40))
end
activeDownload.window:setWidth(width)
activeDownload.window.content:setTextWrap(true)
activeDownload.window.content:setWidth(width - DOWNLOAD_TEXT_MARGIN)
if percent then
percent = clampPercent(percent)
activeDownload.window.content:setText(string.format('%s\n%d%%', status, percent))
elseif detail and detail ~= '' then
activeDownload.window.content:setText(status .. '\n' .. detail)
else
activeDownload.window.content:setText(status)
end
activeDownload.window.content:resizeToText()
activeDownload.window:setHeight(DOWNLOAD_WINDOW_HEIGHT)
if percent and activeDownload.window.busyIndicator then
activeDownload.window.busyIndicator:setRotation(0)
activeDownload.window.busyIndicator:setVisible(false)
end
if activeDownload.window.progressBar then
if percent then
updateDownloadBar(activeDownload.window, percent)
else
updateDownloadBar(activeDownload.window, 0)
end
end
end
local function setDownloadProgress(status, percent, detail)
stopDownloadBusy()
renderDownloadStatus(status, percent, detail)
end
local function scheduleDownloadStep(callback)
scheduleEvent(function()
if not activeDownload or activeDownload.canceled then
return
end
callback()
end, 50)
end
local function startBusyIndicatorSpin()
if not activeDownload or activeDownload.busySpinEvent then
return
end
local function spin()
if not activeDownload or not activeDownload.window or not activeDownload.window.busyIndicator then
return
end
activeDownload.busyRotation = ((activeDownload.busyRotation or 0) + BUSY_INDICATOR_ROTATION_STEP) % 360
activeDownload.window.busyIndicator:setRotation(activeDownload.busyRotation)
activeDownload.busySpinEvent = scheduleEvent(spin, BUSY_INDICATOR_ROTATION_DELAY)
end
spin()
end
local function setDownloadBusy(status, detail)
if not activeDownload then
return
end
activeDownload.busyStatus = status
activeDownload.busyDetail = detail
startBusyIndicatorSpin()
if activeDownload.busyEvent then
return
end
local frames = { '', '.', '..', '...' }
local function tick()
if not activeDownload or not activeDownload.window then
return
end
activeDownload.busyFrame = ((activeDownload.busyFrame or 0) % #frames) + 1
renderDownloadStatus((activeDownload.busyStatus or status) .. frames[activeDownload.busyFrame], nil, activeDownload.busyDetail)
updateDownloadBar(activeDownload.window, 0)
if activeDownload.window.busyIndicator then
activeDownload.window.busyIndicator:setVisible(true)
activeDownload.window.busyIndicator:raise()
end
activeDownload.busyEvent = scheduleEvent(tick, 350)
end
tick()
end
local function logArchiveHeartbeat(message)
if not activeDownload then
return
end
local now = g_clock.millis()
if activeDownload.lastArchiveHeartbeat and now - activeDownload.lastArchiveHeartbeat < 5000 then
return
end
activeDownload.lastArchiveHeartbeat = now
logInfo(message)
end
local function finishDownload(ok, message)
local callback = activeDownload and activeDownload.callback
if ok then
logInfo(message or 'Asset download finished.')
elseif message then
logError(message)
end
stopDownloadBusy()
if activeDownload and activeDownload.window then
activeDownload.window:destroy()
end
activeDownload = nil
if callback then
callback(ok, ok and message or userFacingError(message))
end
end
local function cancelDownload()
if activeDownload then
activeDownload.canceled = true
if activeDownload.operationId then
HTTP.cancel(activeDownload.operationId)
end
activeDownload.window = nil
end
finishDownload(false, tr('Download canceled.'))
end
local function verifyDownloadedSha256(downloadPath, expectedSha256)
if not expectedSha256 or expectedSha256 == '' then
return true
end
local ok, contents = pcall(function()
return g_resources.readFileContents('/downloads/' .. downloadPath)
end)
if not ok then
return false, contents
end
local actualSha256 = g_crypt.sha256(contents)
if actualSha256 ~= expectedSha256 then
return false, string.format('Invalid SHA-256 for %s. Expected %s, got %s.', downloadPath, expectedSha256, actualSha256)
end
return true
end
local function verifyInstalledSha256(destinationPath, expectedSha256, deleteOnMismatch)
if not expectedSha256 or expectedSha256 == '' then
return true
end
local actualSha256
if g_resources.fileSha256InWorkDir then
actualSha256 = g_resources.fileSha256InWorkDir(destinationPath)
else
actualSha256 = g_resources.fileSha256('/' .. destinationPath)
end
if actualSha256 ~= expectedSha256 then
if deleteOnMismatch ~= false then
g_resources.deleteFile('/' .. destinationPath)
end
return false, string.format('Invalid installed SHA-256 for %s. Expected %s, got %s.', destinationPath, expectedSha256, actualSha256)
end
return true
end
local function writeDownloadedFile(config, downloadPath, destinationPath, decompressLzma)
if shouldInstallInWorkDir(config) and g_resources.writeDownloadedFileToWorkDir then
return g_resources.writeDownloadedFileToWorkDir(downloadPath, destinationPath, decompressLzma == true)
end
return g_resources.writeDownloadedFile(downloadPath, destinationPath, decompressLzma == true)
end
local function extractDownloadedArchive(config, downloadPath, destinationPath, entryPrefix, stripPrefix)
if shouldInstallInWorkDir(config) and g_resources.extractDownloadedArchiveToWorkDir then
return g_resources.extractDownloadedArchiveToWorkDir(downloadPath, destinationPath, entryPrefix or '', stripPrefix == true)
end
return g_resources.extractDownloadedArchive(downloadPath, destinationPath, entryPrefix or '', stripPrefix == true)
end
local function installDownloadedFile(config, downloadPath, destinationPath, decompressLzma, expectedFileSha256, allowHashMismatch, allowHashMismatchReason)
if not writeDownloadedFile(config, downloadPath, destinationPath, decompressLzma) then
return false, 'Unable to write downloaded asset: ' .. destinationPath
end
local ok, hashError = verifyInstalledSha256(destinationPath, expectedFileSha256, not allowHashMismatch)
if not ok and allowHashMismatch and not hashError:find(HTTP_NOT_FOUND_SHA256, 1, true) then
logWarning(hashError .. ' Continuing because ' .. (allowHashMismatchReason or 'allowRawFallbackHashMismatch is enabled') .. '.')
return true
end
return ok, hashError
end
local function writeManifestHashIdentifier(config, version, sha256)
if not sha256 or sha256 == '' then
return true
end
local destinationPath = string.format('data/things/%d/assets.json.sha256', version)
if not writeInstallFile(config, destinationPath, sha256 .. '\n') then
return false, 'Unable to write asset hash identifier: ' .. destinationPath
end
logInfo(string.format('Asset hash identifier install path: %s.', physicalInstallPath(destinationPath)))
return true
end
local function installDownloadedArchive(config, downloadPath, destinationPath, entryPrefix, stripPrefix, expectedPath, expectedSha256)
if not extractDownloadedArchive(config, downloadPath, destinationPath, entryPrefix, stripPrefix) then
return false, 'Unable to extract downloaded archive: ' .. destinationPath
end
if expectedPath then
return verifyInstalledSha256(expectedPath, expectedSha256)
end
return true
end
local function shouldInstallManifestEntry(entry, version, config)
local localfile = entry.localfile or entry.file or entry.path
if type(localfile) ~= 'string' then
return nil
end
if startsWith(localfile, 'assets/') then
local relativePath = localfile:sub(#'assets/' + 1)
local archive = isArchivePath(localfile) or isArchivePath(entry.url)
local basePath = string.format('data/things/%d', version)
return {
sourcePath = localfile,
relativePath = relativePath,
destinationPath = archive and (basePath .. '/' .. parentPath(relativePath)) or (basePath .. '/' .. relativePath),
extractedFilePath = archive and not isArchivePath(localfile) and (basePath .. '/' .. localfile:sub(#'assets/' + 1)) or nil,
archive = archive,
sound = false
}
end
if config.installSounds ~= false and startsWith(localfile, 'sounds/') then
local relativePath = localfile:sub(#'sounds/' + 1)
local archive = isArchivePath(localfile) or isArchivePath(entry.url)
local basePath = string.format('data/sounds/%d', version)
return {
sourcePath = localfile,
relativePath = relativePath,
destinationPath = archive and (basePath .. '/' .. parentPath(relativePath)) or (basePath .. '/' .. relativePath),
extractedFilePath = archive and not isArchivePath(localfile) and (basePath .. '/' .. localfile:sub(#'sounds/' + 1)) or nil,
archive = archive,
sound = true
}
end
local archive = isArchivePath(localfile) or isArchivePath(entry.url)
if config.installPackagedFiles ~= false and archive and matchesAnyPrefix(localfile, config.packagedFilePrefixes) then
local destination = string.format(config.packagedFilesDestination or '', version)
local finalPath = isArchivePath(localfile) and stripArchiveExtension(localfile) or localfile
return {
sourcePath = localfile,
relativePath = localfile,
destinationPath = joinUrl(destination, parentPath(localfile)),
extractedFilePath = not isArchivePath(localfile) and joinUrl(destination, finalPath) or nil,
archive = true,
packaged = true
}
end
return nil
end
local function buildManifestDownload(entry, descriptor, selectedEntry, usePackedUrl)
local sourcePath = selectedEntry.sourcePath
local downloadUrl = joinUrl(descriptor.baseUrl, sourcePath)
local decompressLzma = false
local extractArchive = selectedEntry.archive == true
local expectedDownloadSha256
local expectedFileSha256
if usePackedUrl and entry.url then
sourcePath = entry.url
downloadUrl = joinUrl(descriptor.baseUrl, sourcePath)
expectedDownloadSha256 = entry.packedhash
extractArchive = isArchivePath(sourcePath)
if extractArchive then
expectedFileSha256 = selectedEntry.extractedFilePath and entry.unpackedhash or nil
elseif endsWith(sourcePath, '.lzma') and entry.unpack ~= false then
decompressLzma = true
expectedFileSha256 = entry.unpackedhash
else
expectedFileSha256 = entry.packedhash
end
else
extractArchive = isArchivePath(sourcePath)
if extractArchive then
expectedDownloadSha256 = entry.packedhash or entry.unpackedhash
expectedFileSha256 = selectedEntry.extractedFilePath and entry.unpackedhash or nil
elseif entry.unpack == false then
expectedDownloadSha256 = entry.packedhash
expectedFileSha256 = entry.packedhash
else
expectedDownloadSha256 = entry.unpackedhash or entry.packedhash
expectedFileSha256 = entry.unpackedhash or entry.packedhash
end
end
return {
url = downloadUrl,
sourcePath = sourcePath,
decompressLzma = decompressLzma,
extractArchive = extractArchive,
archiveEntryPrefix = entry.archiveEntryPrefix or entry.archivePrefix or '',
stripArchivePrefix = entry.stripArchivePrefix,
expectedDownloadSha256 = expectedDownloadSha256,
expectedFileSha256 = expectedFileSha256,
destinationPath = selectedEntry.destinationPath,
extractedFilePath = selectedEntry.extractedFilePath
}
end
local function installManifestEntries(config, descriptor, files, index, installed, total, callback)
if activeDownload and activeDownload.canceled then
return callback(false, tr('Download canceled.'))
end
local entry = files[index]
if not entry then
return callback(true, string.format('Installed %d assets.', installed))
end
local selectedEntry = shouldInstallManifestEntry(entry, descriptor.version, descriptor)
if not selectedEntry then
return installManifestEntries(config, descriptor, files, index + 1, installed, total, callback)
end
local preferPacked = descriptor.preferPackedManifestUrls
if preferPacked == nil then
preferPacked = config.preferPackedManifestUrls
end
local directDownload = buildManifestDownload(entry, descriptor, selectedEntry, preferPacked)
local fallbackDownload
if config.allowRawFallbackHashMismatch ~= false and not directDownload.extractArchive and not isLzmaPath(directDownload.sourcePath) then
directDownload.allowHashMismatch = true
directDownload.allowHashMismatchReason = 'allowRawFallbackHashMismatch is enabled'
end
if entry.url then
fallbackDownload = buildManifestDownload(entry, descriptor, selectedEntry, not preferPacked)
if fallbackDownload.sourcePath == directDownload.sourcePath then
fallbackDownload = nil
elseif config.allowRawFallbackHashMismatch ~= false and not isArchivePath(fallbackDownload.sourcePath) and not isLzmaPath(fallbackDownload.sourcePath) then
fallbackDownload.allowHashMismatch = true
fallbackDownload.allowHashMismatchReason = 'allowRawFallbackHashMismatch is enabled'
end
end
if directDownload.expectedFileSha256 and g_resources.fileSha256('/' .. directDownload.destinationPath) == directDownload.expectedFileSha256 then
return installManifestEntries(config, descriptor, files, index + 1, installed, total, callback)
end
local function downloadEntry(downloadInfo, nextFallback, retriesLeft)
local fileName = string.format('asset-downloads/%d/%04d-%s', descriptor.version, index, g_resources.getFileName(downloadInfo.sourcePath))
setDownloadProgress(string.format('Downloading assets for client %s', versionLabel(descriptor.version)), overallProgress(index, total, 0))
activeDownload.operationId = httpDownload(config, downloadInfo.url, fileName, function(path, checksum, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if activeDownload.canceled then
return callback(false, tr('Download canceled.'))
end
if err then
if retriesLeft > 0 then
return downloadEntry(downloadInfo, nextFallback, retriesLeft - 1)
end
if nextFallback then
if config.allowMissingPackedRawFallback ~= false and isNotFoundError(err) and isPackedToRawFallback(downloadInfo, nextFallback) then
nextFallback.allowHashMismatch = true
nextFallback.allowHashMismatchReason = 'the packed asset is missing and allowMissingPackedRawFallback is enabled'
end
logWarning(string.format('Download failed for %s: %s. Trying %s.', downloadInfo.sourcePath, err, nextFallback.sourcePath))
return downloadEntry(nextFallback, nil, config.retries or 0)
end
return callback(false, err)
end
local ok, hashError = verifyDownloadedSha256(path, downloadInfo.expectedDownloadSha256)
if not ok then
if nextFallback then
if config.allowMissingPackedRawFallback ~= false and isMissingPackedHashError(hashError) and isPackedToRawFallback(downloadInfo, nextFallback) then
nextFallback.allowHashMismatch = true
nextFallback.allowHashMismatchReason = 'the packed asset is missing and allowMissingPackedRawFallback is enabled'
end
logWarning(hashError .. ' Trying ' .. nextFallback.sourcePath .. '.')
return downloadEntry(nextFallback, nil, config.retries or 0)
end
if downloadInfo.allowHashMismatch and not hashError:find(HTTP_NOT_FOUND_SHA256, 1, true) then
logWarning(hashError .. ' Continuing because ' .. (downloadInfo.allowHashMismatchReason or 'allowRawFallbackHashMismatch is enabled') .. '.')
ok = true
else
return callback(false, hashError)
end
end
if not ok then
return callback(false, hashError)
end
if downloadInfo.extractArchive then
ok, hashError = installDownloadedArchive(
config,
path,
downloadInfo.destinationPath,
downloadInfo.archiveEntryPrefix,
downloadInfo.stripArchivePrefix,
downloadInfo.extractedFilePath,
downloadInfo.expectedFileSha256
)
else
ok, hashError = installDownloadedFile(config, path, downloadInfo.destinationPath, downloadInfo.decompressLzma, downloadInfo.expectedFileSha256, downloadInfo.allowHashMismatch, downloadInfo.allowHashMismatchReason)
end
if not ok then
if nextFallback then
logWarning(hashError .. ' Trying ' .. nextFallback.sourcePath .. '.')
return downloadEntry(nextFallback, nil, config.retries or 0)
end
return callback(false, hashError)
end
return installManifestEntries(config, descriptor, files, index + 1, installed + 1, total, callback)
end, function(progress, speed)
setDownloadProgress(string.format('Downloading assets for client %s', versionLabel(descriptor.version)), overallProgress(index, total, progress))
end)
end
downloadEntry(directDownload, fallbackDownload, config.retries or 0)
end
local function fetchManifestSha256(config, descriptor, callback)
if descriptor.manifestSha256 or not descriptor.manifestSha256Url then
return callback(descriptor.manifestSha256)
end
activeDownload.operationId = httpGet(config, descriptor.manifestSha256Url, function(data, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if err then
return callback(nil)
end
data = tostring(data or ''):match('^%s*(.-)%s*$')
callback(data ~= '' and data or nil)
end)
end
local function installFromManifest(config, descriptor, callback)
if not descriptor.manifestUrl then
return callback(false, 'No asset manifest URL configured.')
end
setDownloadProgress(string.format('Fetching assets manifest for client %s', versionLabel(descriptor.version)), 0)
activeDownload.operationId = httpGet(config, descriptor.manifestUrl, function(data, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if err then
return callback(false, err)
end
fetchManifestSha256(config, descriptor, function(expectedSha256)
local actualSha256 = g_crypt.sha256(data)
if expectedSha256 then
if actualSha256 ~= expectedSha256 then
local hashError = string.format('Invalid assets manifest SHA-256 for %s. Expected %s, got %s.', descriptor.manifestUrl, expectedSha256, actualSha256)
if descriptor.strictManifestSha256 or config.strictManifestSha256 then
return callback(false, hashError)
end
logWarning(hashError .. ' Continuing because strictManifestSha256 is disabled.')
end
end
local ok, manifest = pcall(function()
return json.decode(data)
end)
if not ok or type(manifest) ~= 'table' or type(manifest.files) ~= 'table' then
return callback(false, 'Invalid assets manifest.')
end
local wroteHashIdentifier, writeHashError = writeManifestHashIdentifier(config, descriptor.version, actualSha256)
if not wroteHashIdentifier then
return callback(false, writeHashError)
end
installManifestEntries(config, descriptor, manifest.files, 1, 0, #manifest.files, callback)
end)
end)
end
local function installFromArchive(config, descriptor, callback)
if not descriptor.archiveUrl then
return callback(false, 'No asset archive URL configured.')
end
local archiveName = string.format('asset-downloads/%d/%s', descriptor.version, g_resources.getFileName(descriptor.archiveUrl))
logInfo(string.format('Downloading asset archive for client %s from %s.', versionLabel(descriptor.version), descriptor.archiveUrl))
setDownloadBusy('Downloading files', string.format('Client %s archive', versionLabel(descriptor.version)))
activeDownload.operationId = httpDownload(config, descriptor.archiveUrl, archiveName, function(path, checksum, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if err then
return callback(false, err)
end
logInfo(string.format('Asset archive download completed for client %s. Verifying archive.', versionLabel(descriptor.version)))
setDownloadBusy('Verifying downloaded archive', string.format('Client %s', versionLabel(descriptor.version)))
local ok, hashError = verifyDownloadedSha256(path, descriptor.archiveSha256)
if not ok then
return callback(false, hashError)
end
local label = versionLabel(descriptor.version)
local thingsDestination = string.format('data/things/%d', descriptor.version)
local soundsDestination = string.format('data/sounds/%d', descriptor.version)
local function finishArchiveInstall()
setDownloadProgress('Finishing asset install', 100)
logInfo(string.format('Finished archive install for client %s.', label))
callback(true)
end
local function extractArchiveExtras()
if descriptor.installArchiveExtras == false or not descriptor.archiveExtraPrefixes then
return finishArchiveInstall()
end
local prefixes = descriptor.archiveExtraPrefixes
if type(prefixes) == 'string' then
prefixes = { prefixes }
end
local destination = descriptor.archiveExtrasDestination or ''
destination = string.format(destination, descriptor.version)
local function extractNextExtra(extraIndex)
local prefix = prefixes[extraIndex]
if not prefix then
return finishArchiveInstall()
end
logInfo(string.format('Extracting archive extras for client %s: %s. This can take a while.', label, prefix))
setDownloadBusy('Extracting files', string.format('Client %s extras: %s', label, prefix))
scheduleDownloadStep(function()
extractDownloadedArchive(config, path, destination, prefix, false)
logInfo(string.format('Finished archive extras for client %s: %s.', label, prefix))
logInfo(string.format('Archive extras install path: %s.', physicalInstallPath(destination)))
extractNextExtra(extraIndex + 1)
end)
end
extractNextExtra(1)
end
local function extractSoundAssets()
if descriptor.installSounds == false then
return extractArchiveExtras()
end
logInfo(string.format('Extracting sound assets for client %s. This can take a while.', label))
setDownloadBusy('Extracting files', string.format('Client %s sounds', label))
scheduleDownloadStep(function()
if extractDownloadedArchive(config, path, soundsDestination, descriptor.archiveSoundsPrefix or 'sounds', true) then
logInfo(string.format('Finished extracting sound assets for client %s.', label))
logInfo(string.format('Sound assets install path: %s.', physicalInstallPath(soundsDestination)))
else
logWarning(string.format('No sound assets were extracted for client %s.', label))
end
extractArchiveExtras()
end)
end
local function extractAssetHashIdentifier()
logInfo(string.format('Extracting asset hash identifier for client %s.', label))
extractDownloadedArchive(config, path, thingsDestination, 'assets.json.sha256', false)
extractSoundAssets()
end
logInfo(string.format('Extracting things assets for client %s. This can take a while.', label))
setDownloadBusy('Extracting files', string.format('Client %s assets', label))
scheduleDownloadStep(function()
if not extractDownloadedArchive(config, path, thingsDestination, descriptor.archiveThingsPrefix or 'assets', true) then
return callback(false, 'Unable to extract assets from archive.')
end
logInfo(string.format('Finished extracting things assets for client %s.', label))
logInfo(string.format('Things install path: %s.', physicalInstallPath(thingsDestination)))
extractAssetHashIdentifier()
end)
end, function(progress, speed)
setDownloadBusy('Downloading files', string.format('Client %s archive', versionLabel(descriptor.version)))
logArchiveHeartbeat('Archive download still in progress. GitHub did not provide a reliable total size, so the UI is in indeterminate mode.')
end)
end
local function collectPackagedFiles(descriptor, tree)
local packagedFiles = {}
if type(tree) ~= 'table' or type(tree.tree) ~= 'table' then
return packagedFiles
end
for _, item in ipairs(tree.tree) do
local path = item.path
if item.type == 'blob' and isArchivePath(path) and matchesAnyPrefix(path, descriptor.packagedFilePrefixes) then
packagedFiles[#packagedFiles + 1] = path
end
end
table.sort(packagedFiles)
return packagedFiles
end
local function installPackagedFileList(config, descriptor, files, index, callback)
local path = files[index]
if not path then
return callback(true)
end
if activeDownload and activeDownload.canceled then
return callback(false, tr('Download canceled.'))
end
local url = joinUrl(descriptor.baseUrl, path)
local downloadName = string.format('asset-downloads/%d/packaged-%04d-%s', descriptor.version, index, g_resources.getFileName(path))
logInfo(string.format('Downloading packaged file %d/%d for client %s: %s.', index, #files, versionLabel(descriptor.version), path))
setDownloadBusy('Downloading files', string.format('Client %s packaged files', versionLabel(descriptor.version)))
activeDownload.operationId = httpDownload(config, url, downloadName, function(downloadPath, checksum, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if err then
return callback(false, err)
end
local destination = string.format(descriptor.packagedFilesDestination or '', descriptor.version)
local destinationPath = joinUrl(destination, parentPath(path))
logInfo(string.format('Extracting packaged file %d/%d for client %s: %s.', index, #files, versionLabel(descriptor.version), path))
setDownloadBusy('Extracting files', string.format('Client %s packaged files', versionLabel(descriptor.version)))
scheduleDownloadStep(function()
local ok, extractError = installDownloadedArchive(config, downloadPath, destinationPath, '', false)
if not ok then
if descriptor.packagedFilesRequired then
return callback(false, extractError)
end
logWarning(string.format(
'Skipping optional packaged file %d/%d for client %s: %s (%s).',
index,
#files,
versionLabel(descriptor.version),
path,
extractError or 'unable to extract archive'
))
return installPackagedFileList(config, descriptor, files, index + 1, callback)
end
logInfo(string.format('Finished packaged file %d/%d for client %s: %s.', index, #files, versionLabel(descriptor.version), path))
logInfo(string.format('Packaged file install path: %s.', physicalInstallPath(destinationPath)))
installPackagedFileList(config, descriptor, files, index + 1, callback)
end)
end, function(progress, speed)
setDownloadBusy('Downloading files', string.format('Client %s packaged files', versionLabel(descriptor.version)))
logArchiveHeartbeat(string.format('Packaged file download still in progress (%d/%d): %s.', index, #files, path))
end)
end
local function installPackagedFiles(config, descriptor, callback)
if descriptor.installPackagedFiles == false or not descriptor.treeUrl or not descriptor.baseUrl then
return callback(true)
end
activeDownload.operationId = httpGetJSON(config, descriptor.treeUrl, function(data, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if err then
if descriptor.packagedFilesRequired then
return callback(false, err)
end
return callback(true)
end
local packagedFiles = collectPackagedFiles(descriptor, data)
if #packagedFiles == 0 then
return callback(true)
end
installPackagedFileList(config, descriptor, packagedFiles, 1, callback)
end)
end
local function installDescriptor(config, descriptor, callback)
local archiveAttempted = false
local function finishWithPackagedFiles(ok, message)
if not ok then
return callback(false, message)
end
installPackagedFiles(config, descriptor, function(packagedOk, packagedMessage)
callback(packagedOk, packagedMessage or message)
end)
end
local function archiveFallback(nextCallback)
archiveAttempted = true
installFromArchive(config, descriptor, nextCallback)
end
local function manifestFallback()
installFromManifest(config, descriptor, function(ok, message)
local allowArchiveFallback = descriptor.fallbackToArchiveOnManifestFailure
if allowArchiveFallback == nil then
allowArchiveFallback = config.fallbackToArchiveOnManifestFailure
end
if ok or not descriptor.archiveUrl or archiveAttempted or not allowArchiveFallback then
return finishWithPackagedFiles(ok, message)
end
logWarning((message or 'Asset manifest install failed.') .. ' Trying archive fallback.')
archiveFallback(finishWithPackagedFiles)
end)
end
local preferArchive = descriptor.preferArchive
if preferArchive == nil then
preferArchive = config.preferArchive
end
if preferArchive and descriptor.archiveUrl then
return archiveFallback(function(ok, message)
if ok or not descriptor.manifestUrl then
return finishWithPackagedFiles(ok, message)
end
logWarning((message or 'Archive asset install failed.') .. ' Falling back to asset manifest.')
manifestFallback()
end)
end
if descriptor.manifestUrl then
return manifestFallback()
end
if descriptor.archiveUrl then
return archiveFallback(finishWithPackagedFiles)
end
finishWithPackagedFiles(false, 'No assets source found.')
end
local function resolveFromCustomManifest(config, version, callback)
if not config.manifestUrl then
return callback(nil)
end
activeDownload.operationId = httpGetJSON(config, config.manifestUrl, function(data, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if err then
return callback(nil, err)
end
local descriptor = normalizeDescriptor(config, version, readVersionEntry(data, version))
callback(descriptor)
end)
end
local function resolveFromGitHubReleases(config, version, callback)
if not config.releasesUrl then
return callback(nil)
end
local cacheKey = tostring(config.releasesUrl or config.repository or '')
if releasesCache[cacheKey] then
local release = findReleaseForVersion(releasesCache[cacheKey], version)
return callback(release and descriptorFromRelease(config, version, release) or nil)
end
activeDownload.operationId = httpGetJSON(config, config.releasesUrl, function(data, err)
if not activeDownload then
return
end
activeDownload.operationId = nil
if err then
return callback(nil, err)
end
releasesCache[cacheKey] = data
local release = findReleaseForVersion(releasesCache[cacheKey], version)
callback(release and descriptorFromRelease(config, version, release) or nil)
end)
end
local function resolveDescriptor(config, version, callback)
resolveFromCustomManifest(config, version, function(descriptor)
if descriptor then
return callback(descriptor)
end
resolveFromGitHubReleases(config, version, callback)
end)
end
function isEnabled()
return cloneConfig().enabled ~= false
end
function isClientVersionInstalled(version)
version = tonumber(version)
if not version then
return false
end
if version >= 1281 then
return hasInstalledModernClientFiles(version)
end
return g_resources.fileExists(string.format('/data/things/%d/Tibia.dat', version)) and
g_resources.fileExists(string.format('/data/things/%d/Tibia.spr', version))
end
function getInstalledClientVersions()
local installed = {}
local paths = { '/data/things/' }
for _, path in ipairs(paths) do
for _, dirItem in ipairs(g_resources.listDirectoryFiles(path)) do
if tonumber(dirItem) then
installed[tostring(dirItem)] = true
end
end
end
return installed
end
function ensureClientVersion(version, callback)
version = tonumber(version)
if not version then
return callback(false, 'Invalid client version.')
end
local config = cloneConfig()
if config.enabled == false or isClientVersionInstalled(version) then
return callback(true)
end
if activeDownload then
return callback(false, 'Another asset download is already running.')
end
local promptBox
local message = string.format('Assets for client %s are not installed.\nDownload them now?', versionLabel(version))
local function startDownload()
if promptBox then
local box = promptBox
promptBox = nil
box:ok()
end
if activeDownload then
return
end
activeDownload = {
version = version,
callback = callback,
canceled = false
}
logInfo(string.format('Starting asset download for client %s.', versionLabel(version)))
activeDownload.window = createDownloadWindow()
connect(activeDownload.window, {
onCancel = cancelDownload
})
resolveDescriptor(config, version, function(descriptor, err)
if not descriptor then
return finishDownload(false, err or string.format('No assets source found for client %s.', versionLabel(version)))
end
installDescriptor(config, descriptor, function(ok, installError)
if not ok then
return finishDownload(false, installError or 'Unable to install client assets.')
end
if not hasModernClientFiles(version) then
return finishDownload(false, 'Assets were downloaded but the client files are still incomplete. Missing catalog-content.json, assets.json.sha256, or required catalog files.')
end
local markerPath = completeMarkerPath(version)
if not installFileExists(markerPath) then
markClientVersionInstalled(config, version)
if not installFileExists(markerPath) then
logWarning('Assets were downloaded but the install marker could not be written.')
end
end
logInfo(string.format('Client %s installed at: %s.', versionLabel(version), physicalInstallPath(string.format('data/things/%d', version))))
if config.installSounds ~= false then
logInfo(string.format('Client %s sounds installed at: %s.', versionLabel(version), physicalInstallPath(string.format('data/sounds/%d', version))))
end
if config.installArchiveExtras ~= false then
logInfo(string.format('Client %s extra files installed at: %s.', versionLabel(version), physicalInstallPath('bin')))
end
finishDownload(true)
end)
end)
end
local function cancelPrompt()
if promptBox then
local box = promptBox
promptBox = nil
box:cancel()
end
callback(false, tr('Assets are required for this client version.'))
end
promptBox = displayGeneralBox(tr('Missing Assets'), message, {
{ text = tr('Download'), callback = startDownload },
{ text = tr('Cancel'), callback = cancelPrompt }
}, startDownload, cancelPrompt)
end