mirror of
https://github.com/cyberarm/w3d_hub_linux_launcher.git
synced 2026-09-19 14:53:49 +00:00
525 lines
16 KiB
Ruby
525 lines
16 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?
|
|
apply_patch(manifest_file, package)
|
|
else
|
|
unzip(package_path)
|
|
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
|
|
|
|
def apply_patch(manifest_file, package)
|
|
Tempfile.create do |f|
|
|
package_path = package_cache_path(package)
|
|
|
|
puts "unpacking patch..."
|
|
unzip(package_path, f.path)
|
|
|
|
puts "reading patch data.."
|
|
patch_mix = W3DHubLauncher::WWMix.new(path: f.path)
|
|
raise patch_mix.error_reason unless patch_mix.load
|
|
|
|
patch_entry = patch_mix.entries.find { |e| e.name.casecmp?(".w3dhub.patch") || e.name.casecmp?(".bhppatch") }
|
|
patch_entry.read
|
|
# "remove" patch meta file from patch before copying patch data
|
|
patch_mix.entries.delete(patch_entry)
|
|
|
|
patch_info = JSON.parse(patch_entry.blob)
|
|
|
|
puts "loading target mix metadata... (#{normalize_path(manifest_file.name)})"
|
|
target_mix = W3DHubLauncher::WWMix.new(path: normalize_path(manifest_file.name))
|
|
raise target_mix.error_reason unless target_mix.load
|
|
|
|
patch_info["removedFiles"].each do |file|
|
|
puts "removing file from target: #{file}"
|
|
target_mix.entries.delete_if { |e| e.name.casecmp?(file) }
|
|
end
|
|
|
|
patch_info["updatedFiles"].each do |file|
|
|
puts "adding/updating file from target: #{file}"
|
|
patch_mix.entries.each do |entry|
|
|
target_mix.add_entry(entry: entry, replace: true)
|
|
end
|
|
end
|
|
|
|
Tempfile.create do |temp|
|
|
temp_mix = W3DHubLauncher::WWMix.new(path: temp.path, encrypted: target_mix.encrypted?)
|
|
target_mix.entries.each { |e| temp_mix.add_entry(entry: e, replace: true) }
|
|
raise temp_mix.error_reason unless temp_mix.save
|
|
|
|
puts "copying file..."
|
|
FileUtils.cp(temp.path, normalize_path(manifest_file.name))
|
|
end
|
|
end
|
|
end
|
|
|
|
def unzip(zip_path, output_path = nil)
|
|
stream = Zip::InputStream.new(File.open(zip_path))
|
|
|
|
while(entry = stream.get_next_entry)
|
|
file_path = output_path || normalize_path(entry.name)
|
|
|
|
next if ignored_file?(file_path)
|
|
|
|
pp file_path
|
|
|
|
create_directory(File.dirname(file_path)) unless output_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
|
|
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
|