Files
w3dhub_linux_launcher/lib/worker/task.rb

475 lines
14 KiB
Ruby

module W3DHubLauncher
class Task
# ignore *.meta and paths.ini files
IGNORED_FILES = [/\A.+\.meta\z/i, /\A.+\/paths\.ini\z/i].freeze
IndexedFile = Data.define(:type, :path)
attr_reader :request_id, :application, :channel, :installed_version, :target_version
def initialize(request_id:, application:, channel:, installed_version:, target_version:)
@request_id = request_id
@application = application
@channel = channel
@installed_version = installed_version
@target_version = target_version
pp self
# caching mechinism for file path normalization
@file_index = {}
# manifests!
@manifests = {}
# files that must exist for a valid installation
@manifest_files = []
# files that should be removed
@deleted_manifest_files = []
# files needed to make installation valid for target version
@required_manifest_files = []
# packages to verify and/or download
@packages = []
setup
end
def setup
end
def start(worker)
@worker = worker
@installation_directory ||= application_target_installation_directory
execute
end
def abort_task!(reason = "")
puts reason
raise reason
end
# high level methods
def index_installation_directory
return unless File.directory?(@installation_directory)
Dir.glob("#{@installation_directory}/**/**").each do |path|
add_to_file_index(path)
end
end
def fetch_manifests(version = @target_version)
while (manifest = fetch_manifest(version))
@manifests[version] = manifest
version = manifest.base_version
break unless version
break if manifest.full?
end
end
def build_package_list
channel_manifests = []
version = @target_version
while(manifest = @manifests[version])
channel_manifests << manifest
break if manifest.full?
version = manifest.base_version
end
files = []
deleted_files = []
channel_manifests.reverse.each do |manifest|
manifest.files.each do |file|
if file.removed?
files.delete_if { |f| f.name.casecmp?(file.name) }
deleted_files << file
next
end
# remove files that are total replacements, not patches
files.delete_if { |f| f.name.casecmp?(file.name) } unless file.patch?
# exclude ignored files from consideration
files.delete_if { |f| ignored_file?(f.name) }
# add file to file list
files << file
end
end
@manifest_files = files
@deleted_manifest_files = deleted_files
end
def verify_files
@required_manifest_files = @manifest_files.clone
# Process manifest game files in NEWEST to OLDEST order so that we don't erroneously flag
# valid files as invalid due to an OLDER version of the file being checked FIRST.
@manifest_files.reverse.each do |file|
break unless File.directory?(@installation_directory)
# skip irrelevant older versions of files
next unless @required_manifest_files.include?(file)
file_path = normalize_path(file.name)
next unless File.exist?(file_path) # && !File.directory?(file_path)
puts "verifying #{file_path}..."
checksum = Digest::SHA256.file(file_path).hexdigest.upcase
if checksum == file.checksum
@required_manifest_files.delete(file)
# remove irrelevant older versions of the file from consideration
@required_manifest_files.delete_if { |f| f.name.casecmp?(file.name) && f.version < file.version }
end
end
end
def fetch_packages
required_packages = {}
@required_manifest_files.each do |file|
required_packages[file.version] ||= []
next if required_packages[file.version].find { |pkg| pkg.casecmp?(file.package) }
required_packages[file.version] << file.package
end
packages = required_packages.map do |version, packages|
packages.map do |package_name|
{
category: @application.category,
subcategory: @application.id,
name: format("%s.zip", package_name),
version: version.to_s
}
end
end.flatten
return if packages.empty?
result = @worker.w3dhub_api.fetch_package_details(packages)
unless result.okay?
abort_task!("Failed to fetch required package details!")
end
response = JSON.parse(result.data)
manifest_packages = response["packages"]&.map { |item| Worker::Api::LegacyManifestPackage.new(item) }
if (failed_packages = manifest_packages.select(&:error?)) && !failed_packages.empty?
abort_task!("Failed to retrieve packages details for: #{failed_packages.map { |pkg| "#{pkg.name}:#{pkg.version}: #{pkg.error}"}.join(', ') }")
end
@packages = manifest_packages
manifest_packages.each do |pkg|
file_path = package_cache_path(pkg)
unless File.directory?(File.dirname(file_path))
puts "creating directory: #{File.dirname(file_path)}"
create_directory(File.dirname(file_path))
end
partially_valid_at = 0
state = pkg.verify_file(file_path)
if state.is_a?(Integer)
puts "partially valid at: #{state} bytes (#{file_path})" # 20971520
partially_valid_at = state
else
if state == true # completely verified, skip download!
puts "skipping #{file_path}"
next
end
end
result = if pkg.download_url
puts "downloading #{pkg.download_url} to #{file_path}"
@worker.w3dhub_api.download(pkg.download_url, path: file_path, headers: @worker.w3dhub_api.headers(range: partially_valid_at))
else
# TODO xD
@worker.w3dhub_api.fetch_package("TODO")
end
abort_task!("Failed to download required package: #{pkg.name}:#{pkg.version} (#{result.error})") unless result.okay?
end
end
def install_packages
create_directory(@installation_directory)
processed_packages = {}
@required_manifest_files.each do |manifest_file|
package = @packages.find do |pkg|
pkg.name.casecmp?("#{manifest_file.package}.zip") && manifest_file.version == pkg.version
end
package_path = package_cache_path(package)
next if processed_packages[package_path]
if manifest_file.patch?
else
stream = Zip::InputStream.new(File.open(package_path))
while(entry = stream.get_next_entry)
file_path = normalize_path(entry.name)
next if ignored_file?(file_path)
pp file_path
create_directory(File.dirname(file_path))
File.open(file_path, "wb") do |f|
entry_stream = entry.get_input_stream
while(chunk = entry_stream.read(4_194_304))
f.write(chunk)
end
end
add_to_file_index(file_path)
end
end
processed_packages[package_path] = manifest_file
end
end
def remove_deleted_files
@deleted_manifest_files.each do |manifest_file|
file_path = normalize_path(manifest_file.name)
if File.exist?(file_path) && !File.directory?(file_path)
puts "Removing file: #{file_path}"
File.delete(file_path)
remove_from_file_index(file_path)
end
end
end
def write_paths_ini
File.open(normalize_path("data/paths.ini"), "w") do |file|
file.puts("[paths]")
file.puts("RegBase=W3D Hub")
file.puts("RegClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
file.puts("RegFDS=#{@application.category}\\#{@application.id}-#{@channel.id}-server")
file.puts("FileBase=W3D Hub");
file.puts("FileClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
file.puts("FileFDS=#{@application.category}\\#{@application.id}-#{@channel.id}-server")
file.puts("UseRenFolder=#{@channel.extended_data("usesRenFolder", false)}")
end
end
# updated, and moved applications will overwrite existing application data in settings
def mark_application_installed
puts "APPLICATION: #{@application.name} #{@application.id}:#{@channel.id}:#{@target_version} installed."
end
def mark_application_uninstalled
end
# helper functions
def application_target_installation_directory
target_application = @worker.settings.applications.find { |app| app.id == @application.id && app.channel == @channel.id }
if target_application
target_application.installation_path
else
format("%s/%s/%s", @worker.settings.preferences.application_installation_directory, @application.id, @channel.id)
end
end
def package_cache_path(package)
directory = @worker.settings.preferences.launcher_package_cache_directory
if package.version?
format("%s/%s/%s/%s", directory, @application.id, package.version.to_s, package.name)
else
format("%s/%s/%s", directory, @application.id, package.name)
end
end
def ignored_file?(path)
IGNORED_FILES.any? { |regex| path.match?(regex) }
end
def create_directory(path)
path.gsub!("\\", "/")
# directory doesn't exists, create it!
unless File.directory?(path)
FileUtils.mkdir_p(path)
add_directory_to_file_index(path)
end
end
# add EXISTING file or directory to index
def add_to_file_index(path)
path.gsub!("\\", "/")
@file_index[path.downcase] = IndexedFile.new(File.directory?(path) ? :directory : :file, path)
end
# add EXISTING directory to file index, walking up the directory tree until existing directory entries are found
# NOTE: this will **NOT** "scan" the directory for new directories or files
def add_directory_to_file_index(path)
path.gsub!("\\", "/")
segments = path.split("/")
sub_path = path
until (file_index = @file_index[sub_path.downcase])
@file_index[sub_path.downcase]
segments.pop
break if segments.empty?
sub_path = segments.join("/")
end
end
def remove_from_file_index(path)
path.gsub!("\\", "/")
@file_index.delete(path.downcase)
end
# And behold, the backslashes were slain and the path deemed sane!
def normalize_path(base_path)
base_path = base_path.gsub("\\", "/")
path = "#{@installation_directory}/#{base_path}".gsub("\\", "/")
# try to find exact, existing match
index_file = @file_index[path.downcase]
return index_file.path if index_file
# try to find directory structure
if (index_directory = @file_index[File.dirname(path).downcase])
return format("%s/%s", index_directory.path, File.basename(path))
end
# walk directories to see if we have a partial path match
sub_paths = base_path.split("/")
sub_paths.pop # drop file name from sub path reconstruction
unless sub_paths.empty?
partial_index_directory = nil
sub_path = format("%s/%s", @installation_directory, sub_paths.shift).downcase
while (partial_index_directory = @file_index[sub_path.downcase])
sub_path = format("%s/%s", sub_path, sub_paths.shift)
end
if partial_index_directory
if sub_paths.empty? # we've found the path!
return format("%s/%s", partial_index_directory.path, File.basename(base_path))
else # we has a directory or two that don't exist, downcase them, and then head out to the pub!
return format("%s/%s/%s", partial_index_directory.path, sub_paths.map(&:downcase).join("/"), File.basename(base_path))
end
end
end
# path doesn't exist, convert directories to lower-case, and use provided file case
if File.basename(path) == base_path # base_path doesn't have a sub directory
format("%s/%s", File.dirname(path), File.basename(path))
else # base_path has sub directory/directories
format("%s/%s/%s", @installation_directory, File.dirname(base_path).downcase, File.basename(path))
end
end
def fetch_manifest(version)
result = @worker.w3dhub_api.fetch_package_details([
{
category: "games",
subcategory: @application.id,
name: "manifest.xml",
version: version
}
])
return result unless result.okay?
response = JSON.parse(result.data)
manifest_package = response["packages"]&.map { |item| Worker::Api::LegacyManifestPackage.new(item) }&.first
if manifest_package.nil? || manifest_package.error?
pp manifest_package
abort_task!("Failed to fetch package details for manifest #{version}")
end
# FIXME: check if locally downloaded manifest is still valid, if present.
file_path = package_cache_path(manifest_package)
create_directory(File.dirname(file_path))
result = if manifest_package.download_url
@worker.w3dhub_api.download(manifest_package.download_url, path: file_path)
else
# TODO xD
@worker.w3dhub_api.fetch_package("TODO")
end
if result.okay?
return Worker::Api::LegacyManifest.new(package_cache_path(manifest_package))
else
pp result
abort_task!("Failed to fetch manifest #{version}")
end
end
end
end
# def execute_task
# show_application_taskbar
# fail_fast
# return false if failed?
# fetch_manifests
# return false if failed?
# build_package_list
# return false if failed?
# remove_deleted_files
# return false if failed?
# verify_files
# return false if failed?
# fetch_packages
# return false if failed?
# verify_packages
# return false if failed?
# unpack_packages
# return false if failed?
# create_wine_prefix
# return false if failed?
# install_dependencies
# return false if failed?
# write_paths_ini
# return false if failed?
# mark_application_installed
# return false if failed?
# sleep 1
# hide_application_taskbar
# true
# end