mirror of
https://github.com/mehah/otclient
synced 2026-08-15 16:29:06 -04:00
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.
1586 lines
55 KiB
Lua
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
|