mirror of
https://github.com/cyberarm/w3d_hub_linux_launcher.git
synced 2026-03-21 19:56:14 +00:00
Initial work on reimplementing Task to be Ractor/sub-Process safe
This commit is contained in:
@@ -23,9 +23,7 @@ class W3DHub
|
||||
# unpack packages
|
||||
# install dependencies (e.g. visual C runtime)
|
||||
|
||||
installer = Installer.new(app_id, channel)
|
||||
|
||||
@tasks.push(installer)
|
||||
@tasks.push(Installer.new(context: task_context(app_id, channel, "version")))
|
||||
end
|
||||
|
||||
def update(app_id, channel)
|
||||
@@ -33,7 +31,7 @@ class W3DHub
|
||||
|
||||
return false unless installed?(app_id, channel)
|
||||
|
||||
updater = Updater.new(app_id, channel)
|
||||
updater = Updater.new(Installer.new(context: task_context(app_id, channel, "version")))
|
||||
|
||||
@tasks.push(updater)
|
||||
end
|
||||
@@ -98,6 +96,18 @@ class W3DHub
|
||||
Process.spawn(exe)
|
||||
end
|
||||
|
||||
def task_context(app_id, channel, version)
|
||||
Task::Context.new(
|
||||
SecureRandom.hex,
|
||||
"games",
|
||||
app_id,
|
||||
channel,
|
||||
version,
|
||||
"",
|
||||
""
|
||||
)
|
||||
end
|
||||
|
||||
def repair(app_id, channel)
|
||||
logger.info(LOG_TAG) { "Repair Installation Request: #{app_id}-#{channel}" }
|
||||
|
||||
@@ -110,7 +120,7 @@ class W3DHub
|
||||
# unpack packages
|
||||
# install dependencies (e.g. visual C runtime) if appropriate
|
||||
|
||||
@tasks.push(Repairer.new(app_id, channel))
|
||||
@tasks.push(Repairer.new(context: task_context(app_id, channel, "version")))
|
||||
end
|
||||
|
||||
def uninstall(app_id, channel)
|
||||
@@ -125,7 +135,7 @@ class W3DHub
|
||||
title: "Uninstall #{game.name}?",
|
||||
message: "Are you sure you want to uninstall #{game.name} (#{channel})?",
|
||||
accept_callback: proc {
|
||||
@tasks.push(Uninstaller.new(app_id, channel))
|
||||
@tasks.push(Uninstaller.new(context: task_context(app_id, channel, "version")))
|
||||
}
|
||||
)
|
||||
end
|
||||
@@ -598,6 +608,39 @@ class W3DHub
|
||||
app.channels.detect { |g| g.id.to_s == channel_id.to_s }
|
||||
end
|
||||
|
||||
def handle_task_event(event)
|
||||
# ONLY CALL on MAIN Ractor
|
||||
raise "Something has gone horribly wrong!" unless Ractor.current == Ractor.main
|
||||
|
||||
task = @tasks.find { |t| t.context.task_id == event.task_id }
|
||||
return unless task # FIXME: This is probably a fatal error
|
||||
|
||||
case event.type
|
||||
when Task::EVENT_FAILURE
|
||||
window.push_state(
|
||||
W3DHub::States::MessageDialog,
|
||||
type: event.data[:type],
|
||||
title: event.data[:title],
|
||||
message: event.data[:message]
|
||||
)
|
||||
|
||||
States::Interface.instance&.hide_application_taskbar
|
||||
@tasks.delete(task)
|
||||
when Task::EVENT_START
|
||||
task.started! # mark ApplicationManager's version of Task as :running
|
||||
States::Interface.instance&.show_application_taskbar
|
||||
when Task::EVENT_SUCCESS
|
||||
States::Interface.instance&.hide_application_taskbar
|
||||
@tasks.delete(task)
|
||||
when Task::EVENT_PROGRESS
|
||||
:FIXME
|
||||
when Task::EVENT_PACKAGE_LIST
|
||||
:FIXME
|
||||
when Task::EVENT_PACKAGE_STATUS
|
||||
:FIXME
|
||||
end
|
||||
end
|
||||
|
||||
# No application tasks are being done
|
||||
def idle?
|
||||
!busy?
|
||||
@@ -624,9 +667,9 @@ class W3DHub
|
||||
def task?(type, app_id, channel)
|
||||
@tasks.find do |t|
|
||||
t.type == type &&
|
||||
t.app_id == app_id &&
|
||||
t.release_channel == channel &&
|
||||
[ :not_started, :running, :paused ].include?(t.state)
|
||||
t.context.app_id == app_id &&
|
||||
t.context.channel_id == channel &&
|
||||
[ :not_started, :running, :paused ].include?(t.state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,816 +3,218 @@ class W3DHub
|
||||
class Task
|
||||
LOG_TAG = "W3DHub::ApplicationManager::Task".freeze
|
||||
|
||||
class FailFast < RuntimeError
|
||||
end
|
||||
# Task failed
|
||||
EVENT_FAILURE = -1
|
||||
# Task started, show application taskbar
|
||||
EVENT_START = 0
|
||||
# Task completed successfully
|
||||
EVENT_SUCCESS = 1
|
||||
# Task progress
|
||||
EVENT_PROGRESS = 2
|
||||
# List of packages this task will be working over
|
||||
EVENT_PACKAGE_LIST = 3
|
||||
# Update a package's status: verifying, downloading, unpacking, patching
|
||||
EVENT_PACKAGE_STATUS = 4
|
||||
|
||||
include CyberarmEngine::Common
|
||||
Context = Data.define(
|
||||
:task_id,
|
||||
:app_type,
|
||||
:app_id,
|
||||
:channel_id,
|
||||
:version,
|
||||
:target_path,
|
||||
:temp_path
|
||||
)
|
||||
|
||||
attr_reader :app_id, :release_channel, :application, :channel, :target_version,
|
||||
:manifests, :packages, :files, :wine_prefix, :status
|
||||
MessageEvent = Data.define(
|
||||
:task_id,
|
||||
:type,
|
||||
:subtype,
|
||||
:data # { message: "Complete", progress: 0.1 }
|
||||
)
|
||||
|
||||
def initialize(app_id, release_channel)
|
||||
@app_id = app_id
|
||||
@release_channel = release_channel
|
||||
attr_reader :context, :state
|
||||
|
||||
@task_state = :not_started # :not_started, :running, :paused, :halted, :complete, :failed
|
||||
def initialize(context:)
|
||||
@context = context
|
||||
|
||||
@application = Store.applications.games.find { |g| g.id == app_id }
|
||||
@channel = @application.channels.find { |c| c.id == release_channel }
|
||||
@state = :not_started
|
||||
|
||||
@target_version = type == :repairer ? Store.settings[:games][:"#{app_id}_#{@channel.id}"][:installed_version] : @channel.current_version
|
||||
# remember all case insensitive file paths
|
||||
@cache_file_paths = {}
|
||||
|
||||
@packages_to_download = []
|
||||
@total_bytes_to_download = -1
|
||||
@bytes_downloaded = -1
|
||||
|
||||
@manifests = []
|
||||
@files = []
|
||||
@packages = []
|
||||
@deleted_files = [] # TODO: remove removed files
|
||||
|
||||
@wine_prefix = nil
|
||||
|
||||
@status = Status.new(application: @application, channel: channel) { update_interface_task_status }
|
||||
|
||||
setup
|
||||
@failure = false
|
||||
@failure_reason = ""
|
||||
end
|
||||
|
||||
def setup
|
||||
end
|
||||
|
||||
def fail!(reason: "")
|
||||
@failure = true
|
||||
|
||||
Ractor.current.send(
|
||||
MessageEvent.new(
|
||||
context.task_id,
|
||||
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def failed?
|
||||
@failure
|
||||
end
|
||||
|
||||
def send_status_update(type)
|
||||
end
|
||||
|
||||
def send_package_list()
|
||||
end
|
||||
|
||||
def send_task_result
|
||||
Ractor.current.send(
|
||||
MessageEvent.new(
|
||||
context.task_id,
|
||||
@failure ? EVENT_FAILURE : EVENT_SUCCESS,
|
||||
nil,
|
||||
@failure_reason
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def type
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def state
|
||||
@task_state
|
||||
end
|
||||
|
||||
# Start task, inside its own thread
|
||||
# FIXME: Ruby 3 has parallelism now: Use a Ractor to do work on a seperate core to
|
||||
# prevent the UI from locking up while doing computation heavy work, i.e building
|
||||
# list of packages to download
|
||||
def start
|
||||
@task_state = :running
|
||||
execute_task
|
||||
|
||||
Thread.new do
|
||||
# Sync do
|
||||
begin
|
||||
status = execute_task
|
||||
rescue FailFast
|
||||
# no-op
|
||||
rescue StandardError, Errno::EACCES => e
|
||||
status = false
|
||||
@task_failure_reason = e.message[0..512]
|
||||
|
||||
logger.error(LOG_TAG) { "Task #{type.inspect} failed for #{@application.name}" }
|
||||
logger.error(LOG_TAG) { e }
|
||||
end
|
||||
|
||||
# Force free some bytes
|
||||
GC.compact if GC.respond_to?(:compact)
|
||||
GC.start
|
||||
|
||||
@task_state = :failed unless status
|
||||
@task_state = :complete unless @task_state == :failed
|
||||
|
||||
hide_application_taskbar if @task_state == :failed
|
||||
send_message_dialog(:failure, "#{type.to_s.capitalize} Task failed for #{@application.name}", @task_failure_reason) if @task_state == :failed && !@fail_silently
|
||||
# end
|
||||
end
|
||||
send_task_result
|
||||
end
|
||||
|
||||
# returns true on success and false on failure
|
||||
def execute_task
|
||||
end
|
||||
|
||||
# Suspend operation, if possible
|
||||
def pause
|
||||
@task_state = :paused if pauseable?
|
||||
end
|
||||
|
||||
# Halt operation, if possible
|
||||
def stop
|
||||
@task_state = :halted if stoppable?
|
||||
end
|
||||
|
||||
def pauseable?
|
||||
false
|
||||
end
|
||||
|
||||
def stoppable?
|
||||
false
|
||||
end
|
||||
|
||||
def complete?
|
||||
@task_state == :complete
|
||||
end
|
||||
|
||||
def failed?
|
||||
@task_state == :failed
|
||||
end
|
||||
|
||||
def normalize_path(path, base_path)
|
||||
path = path.to_s.gsub("\\", "/")
|
||||
return "#{base_path}/#{path}" if W3DHub.windows? # Windows is easy, or annoying, depending how you look at it...
|
||||
|
||||
constructed_path = base_path
|
||||
lowercase_full_path = "#{base_path}/#{path}".downcase.strip.freeze
|
||||
|
||||
accepted_parts = 0
|
||||
split_path = path.split("/")
|
||||
split_path.each do |segment|
|
||||
Dir.glob("#{constructed_path}/*").each do |part|
|
||||
next unless "#{constructed_path}/#{segment}".downcase == part.downcase
|
||||
|
||||
# Handle edge case where a file with the same name is in a higher directory
|
||||
next if File.file?(part) && part.downcase.strip != lowercase_full_path
|
||||
|
||||
constructed_path = part
|
||||
accepted_parts += 1
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# Find file if it exists else use provided path as cased
|
||||
if constructed_path.downcase.strip == lowercase_full_path
|
||||
constructed_path
|
||||
elsif accepted_parts.positive?
|
||||
"#{constructed_path}/#{split_path[accepted_parts..].join('/')}"
|
||||
else
|
||||
"#{base_path}/#{path}" # File doesn't exist, case doesn't matter.
|
||||
end
|
||||
end
|
||||
|
||||
def failure_reason
|
||||
@task_failure_reason || ""
|
||||
end
|
||||
|
||||
def fail!(reason = "")
|
||||
@task_state = :failed
|
||||
@task_failure_reason = reason.to_s
|
||||
|
||||
hide_application_taskbar
|
||||
|
||||
raise FailFast, @task_failure_reason
|
||||
end
|
||||
|
||||
def fail_silently!
|
||||
@fail_silently = true
|
||||
end
|
||||
###########################
|
||||
## High level task steps ##
|
||||
###########################
|
||||
|
||||
# Quick checks before network and computational work starts
|
||||
def fail_fast
|
||||
# Have write permissions to target directory
|
||||
path = Cache.install_path(@application, @channel)
|
||||
path = Store.settings[:app_install_dir] unless File.exist?(path)
|
||||
def fail_fast!
|
||||
# can read/write to destination
|
||||
|
||||
begin
|
||||
File.write("#{path}/___can_write.wlh", "")
|
||||
File.delete("#{path}/___can_write.wlh")
|
||||
|
||||
Dir.mkdir("#{path}/__can_write")
|
||||
Dir.rmdir("#{path}/__can_write")
|
||||
rescue Errno::EACCES
|
||||
fail!("FAIL FAST: Cannot write to #{path}")
|
||||
end
|
||||
|
||||
# FIXME: Check that there is enough disk space
|
||||
|
||||
# TODO: Is missing wine/proton really a failure condition?
|
||||
# Wine present?
|
||||
if W3DHub.unix?
|
||||
wine_present = W3DHub.command("which #{Store.settings[:wine_command]}")
|
||||
fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed.\n\n"\
|
||||
"Will be unable to launch game.\n\nCheck wine options in launcher's settings.") unless wine_present
|
||||
end
|
||||
fail!("FAIL FAST: Insufficient disk space available.") unless disk_space_available?
|
||||
end
|
||||
|
||||
def run_on_main_thread(block)
|
||||
Store.main_thread_queue << block
|
||||
end
|
||||
|
||||
def send_message_dialog(type, title, message)
|
||||
run_on_main_thread(
|
||||
proc do
|
||||
window.push_state(W3DHub::States::MessageDialog, type: type, title: title, message: message)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
def update_interface_task_status
|
||||
run_on_main_thread(
|
||||
proc do
|
||||
States::Interface.instance&.interface_task_update_pending = self
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
def show_application_taskbar
|
||||
run_on_main_thread(
|
||||
proc do
|
||||
States::Interface.instance&.show_application_taskbar
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
def hide_application_taskbar
|
||||
run_on_main_thread(
|
||||
proc do
|
||||
States::Interface.instance&.hide_application_taskbar
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
###############
|
||||
# Tasks/Steps #
|
||||
###############
|
||||
|
||||
def fetch_manifests
|
||||
@status.operations.clear
|
||||
@status.label = "Downloading #{@application.name}..."
|
||||
@status.value = "Fetching manifests..."
|
||||
@status.progress = 0.0
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
@status.step = :fetching_manifests
|
||||
|
||||
if fetch_manifest("games", app_id, "manifest.xml", @target_version)
|
||||
manifest = load_manifest("games", app_id, "manifest.xml", @target_version)
|
||||
@manifests << manifest
|
||||
|
||||
until(manifest.full?)
|
||||
if fetch_manifest("games", app_id, "manifest.xml", manifest.base_version)
|
||||
manifest = load_manifest("games", app_id, "manifest.xml", manifest.base_version)
|
||||
manifests << manifest
|
||||
else
|
||||
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{manifest.base_version}")
|
||||
return []
|
||||
end
|
||||
end
|
||||
else
|
||||
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{@target_version}")
|
||||
return []
|
||||
end
|
||||
|
||||
@manifests
|
||||
result
|
||||
end
|
||||
|
||||
def build_package_list
|
||||
@status.operations.clear
|
||||
@status.label = "Downloading #{@application.name}..."
|
||||
@status.value = "Building package list..."
|
||||
@status.progress = 0.0
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
@status.step = :build_package_list
|
||||
|
||||
# Process manifest game files in OLDEST to NEWEST order so we can simply remove preceeding files from the array that aren't needed
|
||||
@manifests.reverse.each do |manifest|
|
||||
logger.info(LOG_TAG) { "#{manifest.game}-#{manifest.type}: #{manifest.version} (#{manifest.base_version})" }
|
||||
|
||||
manifest.files.each do |file|
|
||||
if file.removed? # No package data
|
||||
@files.delete_if { |f| f.name.casecmp?(file.name) }
|
||||
@deleted_files.push(file)
|
||||
next
|
||||
end
|
||||
|
||||
@files.delete_if { |f| f.name.casecmp?(file.name) } unless file.patch?
|
||||
|
||||
# If file has been recreated in a newer patch, don't delete it;
|
||||
# A full file package will exist for it so it will get completely replaced.
|
||||
@deleted_files.delete_if { |f| f.name.casecmp?(file.name) }
|
||||
|
||||
@files.push(file)
|
||||
end
|
||||
|
||||
# TODO: Dependencies
|
||||
end
|
||||
|
||||
@files.each do |file|
|
||||
next if packages.detect do |pkg|
|
||||
pkg.category == "games" &&
|
||||
pkg.subcategory == @app_id &&
|
||||
pkg.name.to_s.casecmp?(file.package.to_s) &&
|
||||
pkg.version == file.version
|
||||
end
|
||||
|
||||
package = Api::Package.new({ category: "games", subcategory: @app_id, name: file.package, version: file.version })
|
||||
|
||||
package.is_patch = file if file.patch?
|
||||
|
||||
packages.push(package)
|
||||
end
|
||||
|
||||
@packages = packages
|
||||
end
|
||||
|
||||
def verify_files
|
||||
@status.operations.clear
|
||||
@status.label = "Downloading #{@application.name}..."
|
||||
@status.value = "Verifying installed files..."
|
||||
@status.progress = 0.0
|
||||
|
||||
@status.step = :verify_files
|
||||
|
||||
path = Cache.install_path(@application, @channel)
|
||||
accepted_files = {}
|
||||
rejected_files = []
|
||||
|
||||
file_count = manifests.map { |m| m.files.count }.sum
|
||||
processed_files = 0
|
||||
|
||||
folder_exists = File.directory?(path)
|
||||
|
||||
# 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.
|
||||
@files.reverse.each do |file|
|
||||
break unless folder_exists
|
||||
|
||||
file_path = normalize_path(file.name, path)
|
||||
|
||||
processed_files += 1
|
||||
@status.progress = processed_files.to_f / file_count
|
||||
|
||||
next if file.removed_since
|
||||
next if accepted_files.key?(file_path)
|
||||
|
||||
unless File.exist?(file_path)
|
||||
rejected_files << { file: file, manifest_version: file.version }
|
||||
logger.info(LOG_TAG) { "[#{file.version}] File missing: #{file_path}" }
|
||||
next
|
||||
end
|
||||
|
||||
digest = Digest::SHA256.new
|
||||
f = File.open(file_path)
|
||||
|
||||
while (chunk = f.read(32_000_000))
|
||||
digest.update(chunk)
|
||||
end
|
||||
|
||||
f.close
|
||||
|
||||
logger.info(LOG_TAG) { file.inspect } if file.checksum.nil?
|
||||
|
||||
if digest.hexdigest.upcase == file.checksum.upcase
|
||||
accepted_files[file_path] = file.version
|
||||
logger.info(LOG_TAG) { "[#{file.version}] Verified file: #{file_path}" }
|
||||
else
|
||||
rejected_files << { file: file, manifest_version: file.version }
|
||||
logger.info(LOG_TAG) { "[#{file.version}] File failed Verification: #{file_path}" }
|
||||
end
|
||||
end
|
||||
|
||||
logger.info(LOG_TAG) { "#{rejected_files.count} missing or corrupt files" }
|
||||
|
||||
selected_packages = []
|
||||
selected_packages_hash = {}
|
||||
|
||||
rejected_files.each do |hash|
|
||||
next if selected_packages_hash["#{hash[:file].package}_#{hash[:manifest_version]}"]
|
||||
|
||||
package = @packages.find { |pkg| pkg.name.casecmp?(hash[:file].package) && pkg.version == hash[:manifest_version] }
|
||||
|
||||
if package
|
||||
selected_packages_hash["#{hash[:file].package}_#{hash[:manifest_version]}"] = true
|
||||
selected_packages << package
|
||||
else
|
||||
raise "missing package: #{hash[:file].package}:#{hash[:manifest_version]} in fetched packages list!"
|
||||
end
|
||||
end
|
||||
|
||||
# Removed packages that don't need to be fetched or processed
|
||||
@packages.delete_if { |package| !selected_packages.find { |pkg| pkg == package } } if folder_exists
|
||||
|
||||
@packages
|
||||
end
|
||||
|
||||
def fetch_packages
|
||||
return if @packages.empty?
|
||||
|
||||
hashes = @packages.map do |pkg|
|
||||
{
|
||||
category: pkg.category,
|
||||
subcategory: pkg.subcategory,
|
||||
name: "#{pkg.name}.zip",
|
||||
version: pkg.version
|
||||
}
|
||||
end
|
||||
|
||||
package_details = Api.package_details(hashes, @channel.source || :w3dhub)
|
||||
|
||||
unless package_details
|
||||
fail!("Failed to fetch package details")
|
||||
return
|
||||
end
|
||||
|
||||
[package_details].flatten.each do |rich|
|
||||
if rich.error?
|
||||
fail!("Failed to retrieve package details! (#{rich.category}:#{rich.subcategory}:#{rich.name}:#{rich.version})\nError: #{rich.error.gsub("-", " ").capitalize}")
|
||||
return
|
||||
end
|
||||
|
||||
package = @packages.find do |pkg|
|
||||
pkg.category.to_s.casecmp?(rich.category.to_s) &&
|
||||
pkg.subcategory.to_s.casecmp?(rich.subcategory.to_s) &&
|
||||
"#{pkg.name}.zip".casecmp?(rich.name) &&
|
||||
pkg.version == rich.version
|
||||
end
|
||||
|
||||
package.instance_variable_set(:"@name", rich.name)
|
||||
package.instance_variable_set(:"@size", rich.size)
|
||||
package.instance_variable_set(:"@checksum", rich.checksum)
|
||||
package.instance_variable_set(:"@checksum_chunk_size", rich.checksum_chunk_size)
|
||||
package.instance_variable_set(:"@checksum_chunks", rich.checksum_chunks)
|
||||
end
|
||||
|
||||
@packages_to_download = []
|
||||
|
||||
@status.label = "Downloading #{@application.name}..."
|
||||
@status.value = "Verifying local packages..."
|
||||
@status.progress = 0.0
|
||||
|
||||
package_details.each do |pkg|
|
||||
@status.operations[:"#{pkg.checksum}"] = Status::Operation.new(
|
||||
label: pkg.name,
|
||||
value: "Pending...",
|
||||
progress: 0.0
|
||||
)
|
||||
end
|
||||
|
||||
@status.step = :prefetch_verifying_packages
|
||||
|
||||
package_details.each_with_index.each do |pkg, i|
|
||||
operation = @status.operations[:"#{pkg.checksum}"]
|
||||
|
||||
if verify_package(pkg)
|
||||
operation.value = "Verified"
|
||||
operation.progress = 1.0
|
||||
else
|
||||
@packages_to_download << pkg
|
||||
|
||||
operation.value = "#{W3DHub.format_size(pkg.custom_partially_valid_at_bytes)} / #{W3DHub.format_size(pkg.size)}"
|
||||
operation.progress = pkg.custom_partially_valid_at_bytes.to_f / pkg.size
|
||||
end
|
||||
|
||||
@status.progress = i.to_f / package_details.count
|
||||
|
||||
update_interface_task_status
|
||||
end
|
||||
|
||||
@status.operations.delete_if { |key, o| o.progress >= 1.0 }
|
||||
|
||||
@status.step = :fetch_packages
|
||||
|
||||
@total_bytes_to_download = @packages_to_download.sum { |pkg| pkg.size - pkg.custom_partially_valid_at_bytes }
|
||||
@bytes_downloaded = 0
|
||||
|
||||
pool = Pool.new(workers: Store.settings[:parallel_downloads])
|
||||
|
||||
@packages_to_download.each do |pkg|
|
||||
pool.add_job Pool::Job.new( proc {
|
||||
package_bytes_downloaded = pkg.custom_partially_valid_at_bytes
|
||||
|
||||
package_fetch(pkg) do |chunk, remaining_bytes, total_bytes|
|
||||
@bytes_downloaded += chunk.to_s.length
|
||||
package_bytes_downloaded += chunk.to_s.length
|
||||
|
||||
@status.value = "#{W3DHub.format_size(@bytes_downloaded)} / #{W3DHub.format_size(@total_bytes_to_download)}"
|
||||
@status.progress = @bytes_downloaded.to_f / @total_bytes_to_download
|
||||
|
||||
operation = @status.operations[:"#{pkg.checksum}"]
|
||||
operation.value = "#{W3DHub.format_size(package_bytes_downloaded)} / #{W3DHub.format_size(pkg.size)}"
|
||||
operation.progress = package_bytes_downloaded.to_f / pkg.size # total_bytes
|
||||
|
||||
update_interface_task_status
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
pool.manage_pool
|
||||
end
|
||||
|
||||
def verify_packages
|
||||
end
|
||||
|
||||
def unpack_packages
|
||||
path = Cache.install_path(@application, @channel)
|
||||
logger.info(LOG_TAG) { "Unpacking packages in '#{path}'..." }
|
||||
Cache.create_directories(path, true)
|
||||
|
||||
@status.operations.clear
|
||||
@status.label = "Installing #{@application.name}..."
|
||||
@status.value = "Unpacking..."
|
||||
@status.progress = 0.0
|
||||
|
||||
@packages.each do |pkg|
|
||||
# FIXME: can't add a new key into hash during iteration (RuntimeError)
|
||||
@status.operations[:"#{pkg.checksum}"] = Status::Operation.new(
|
||||
label: pkg.name,
|
||||
value: "Pending...",
|
||||
progress: 0.0
|
||||
)
|
||||
end
|
||||
|
||||
@status.step = :unpacking
|
||||
|
||||
i = -1
|
||||
@packages.each do |package|
|
||||
i += 1
|
||||
|
||||
status = if package.custom_is_patch
|
||||
@status.operations[:"#{package.checksum}"].value = "Patching..."
|
||||
@status.operations[:"#{package.checksum}"].progress = Float::INFINITY
|
||||
@status.progress = i.to_f / packages.count
|
||||
update_interface_task_status
|
||||
|
||||
apply_patch(package, path)
|
||||
else
|
||||
@status.operations[:"#{package.checksum}"].value = "Unpacking..."
|
||||
@status.operations[:"#{package.checksum}"].progress = Float::INFINITY
|
||||
@status.progress = i.to_f / packages.count
|
||||
update_interface_task_status
|
||||
|
||||
unpack_package(package, path)
|
||||
end
|
||||
|
||||
if status
|
||||
@status.operations[:"#{package.checksum}"].value = package.custom_is_patch ? "Patched" : "Unpacked"
|
||||
@status.operations[:"#{package.checksum}"].progress = 1.0
|
||||
|
||||
update_interface_task_status
|
||||
else
|
||||
logger.info(LOG_TAG) { "COMMAND FAILED!" }
|
||||
fail!("Failed to unpack #{package.name}")
|
||||
|
||||
break
|
||||
end
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def remove_deleted_files
|
||||
return unless @deleted_files.size.positive?
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
logger.info(LOG_TAG) { "Removing dead files..." }
|
||||
result
|
||||
end
|
||||
|
||||
@deleted_files.each do |file|
|
||||
logger.info(LOG_TAG) { " #{file.name}" }
|
||||
def verify_files
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
path = Cache.install_path(@application, @channel)
|
||||
file_path = normalize_path(file.name, path)
|
||||
result
|
||||
end
|
||||
|
||||
File.delete(file_path) if File.exist?(file_path)
|
||||
def fetch_packages
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
logger.info(LOG_TAG) { " removed." }
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def verify_packages
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def unpack_packages
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def create_wine_prefix
|
||||
if W3DHub.unix? && @wine_prefix
|
||||
# TODO: create a wine prefix if configured
|
||||
@status.operations.clear
|
||||
@status.label = "Installing #{@application.name}..."
|
||||
@status.value = "Creating wine prefix..."
|
||||
@status.progress = 0.0
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
@status.step = :create_wine_prefix
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def install_dependencies
|
||||
# TODO: install dependencies
|
||||
@status.operations.clear
|
||||
@status.label = "Installing #{@application.name}..."
|
||||
@status.value = "Installing dependencies..."
|
||||
@status.progress = 0.0
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
@status.step = :install_dependencies
|
||||
result
|
||||
end
|
||||
|
||||
def write_paths_ini
|
||||
path = Cache.install_path(@application, @channel)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
File.open(normalize_path("data/paths.ini", path), "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=#{@application.uses_ren_folder?}")
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def mark_application_installed
|
||||
Store.application_manager.installed!(self)
|
||||
|
||||
@status.operations.clear
|
||||
@status.label = "Installed #{@application.name}"
|
||||
@status.value = ""
|
||||
@status.progress = 1.0
|
||||
|
||||
@status.step = :mark_application_installed
|
||||
|
||||
logger.info(LOG_TAG) { "#{@app_id} has been installed." }
|
||||
end
|
||||
|
||||
#############
|
||||
# Functions #
|
||||
#############
|
||||
|
||||
def fetch_manifest(category, subcategory, name, version, &block)
|
||||
# Check for and integrity of local manifest
|
||||
|
||||
package = nil
|
||||
array = Api.package_details([{ category: category, subcategory: subcategory, name: name, version: version }], @channel.source || :w3dhub)
|
||||
if array.is_a?(Array)
|
||||
package = array.first
|
||||
else
|
||||
fail!("Failed to fetch manifest package details! (#{category}:#{subcategory}:#{name}:#{version})")
|
||||
return false
|
||||
end
|
||||
|
||||
if package.error?
|
||||
fail!("Failed to retrieve manifest package details! (#{category}:#{subcategory}:#{name}:#{version})\nError: #{package.error.gsub("-", " ").capitalize}")
|
||||
return false
|
||||
end
|
||||
|
||||
if File.exist?(Cache.package_path(category, subcategory, name, version))
|
||||
verified = verify_package(package)
|
||||
|
||||
# download manifest if not valid
|
||||
verified ? true : package_fetch(package)
|
||||
else
|
||||
# download manifest if not cached
|
||||
package_fetch(package)
|
||||
end
|
||||
end
|
||||
|
||||
def package_fetch(package, &block)
|
||||
logger.info(LOG_TAG) { "Downloading: #{package.category}:#{package.subcategory}:#{package.name}-#{package.version}" }
|
||||
|
||||
status_okay = Api.package(package) do |chunk, remaining_bytes, total_bytes|
|
||||
block&.call(chunk, remaining_bytes, total_bytes)
|
||||
end
|
||||
|
||||
return status_okay if status_okay
|
||||
|
||||
fail!("Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})")
|
||||
false
|
||||
end
|
||||
|
||||
def verify_package(package, &block)
|
||||
logger.info(LOG_TAG) { "Verifying: #{package.category}:#{package.subcategory}:#{package.name}-#{package.version}" }
|
||||
|
||||
digest = Digest::SHA256.new
|
||||
path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
|
||||
|
||||
return false unless File.exist?(path)
|
||||
|
||||
operation = @status.operations[:"#{package.checksum}"]
|
||||
operation&.value = "Verifying..."
|
||||
|
||||
file_size = File.size(path)
|
||||
logger.info(LOG_TAG) { " File size: #{file_size}" }
|
||||
chunk_size = package.checksum_chunk_size
|
||||
chunks = package.checksum_chunks.size
|
||||
|
||||
File.open(path) do |f|
|
||||
i = -1
|
||||
package.checksum_chunks.each do |chunk_start, checksum|
|
||||
i += 1
|
||||
operation&.progress = i.to_f / chunks
|
||||
update_interface_task_status
|
||||
|
||||
chunk_start = Integer(chunk_start.to_s)
|
||||
|
||||
read_length = chunk_size
|
||||
read_length = file_size - chunk_start if chunk_start + chunk_size > file_size
|
||||
|
||||
break if (file_size - chunk_start).negative?
|
||||
|
||||
f.seek(chunk_start)
|
||||
|
||||
chunk = f.read(read_length)
|
||||
digest.update(chunk)
|
||||
|
||||
if Digest::SHA256.new.hexdigest(chunk).upcase == checksum.upcase
|
||||
valid_at = chunk_start + read_length
|
||||
# logger.debug(LOG_TAG) { " Passed chunk: #{chunk_start}" } # Only enable when deep diving to find a bug (VERBOSE)
|
||||
# package.partially_valid_at_bytes = valid_at
|
||||
package.partially_valid_at_bytes = chunk_start
|
||||
else
|
||||
logger.info(LOG_TAG) { " FAILED chunk: #{chunk_start}" }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
digest.hexdigest.upcase == package.checksum.upcase
|
||||
end
|
||||
|
||||
def load_manifest(category, subcategory, name, version)
|
||||
Manifest.new(category, subcategory, name, version)
|
||||
end
|
||||
|
||||
def unpack_package(package, path)
|
||||
logger.info(LOG_TAG) { " #{package.name}:#{package.version}" }
|
||||
package_path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
|
||||
|
||||
logger.info(LOG_TAG) { " Unpacking package \"#{package_path}\" in \"#{path}\"" }
|
||||
|
||||
return unzip(package_path, path)
|
||||
end
|
||||
|
||||
def apply_patch(package, path)
|
||||
logger.info(LOG_TAG) { " #{package.name}:#{package.version}" }
|
||||
package_path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
|
||||
temp_path = "#{Store.settings[:package_cache_dir]}/temp"
|
||||
manifest_file = package.custom_is_patch
|
||||
|
||||
Cache.create_directories(temp_path, true)
|
||||
|
||||
logger.info(LOG_TAG) { " Unpacking patch \"#{package_path}\" in \"#{temp_path}\"" }
|
||||
unzip(package_path, temp_path)
|
||||
|
||||
file_path = normalize_path(manifest_file.name, path)
|
||||
temp_file_path = normalize_path(manifest_file.name, temp_path)
|
||||
|
||||
logger.info(LOG_TAG) { " Loading #{temp_file_path}.patch..." }
|
||||
patch_mix = W3DHub::WWMix.new(path: "#{temp_file_path}.patch")
|
||||
unless patch_mix.load
|
||||
raise patch_mix.error_reason
|
||||
end
|
||||
patch_entry = patch_mix.entries.find { |e| e.name.casecmp?(".w3dhub.patch") || e.name.casecmp?(".bhppatch") }
|
||||
patch_entry.read
|
||||
|
||||
patch_info = JSON.parse(patch_entry.blob, symbolize_names: true)
|
||||
|
||||
logger.info(LOG_TAG) { " Loading #{file_path}..." }
|
||||
target_mix = W3DHub::WWMix.new(path: "#{file_path}")
|
||||
unless target_mix.load
|
||||
raise target_mix.error_reason
|
||||
end
|
||||
|
||||
logger.info(LOG_TAG) { " Removing files..." } if patch_info[:removedFiles].size.positive?
|
||||
patch_info[:removedFiles].each do |file|
|
||||
logger.debug(LOG_TAG) { " #{file}" }
|
||||
target_mix.entries.delete_if { |e| e.name.casecmp?(file) }
|
||||
end
|
||||
|
||||
logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive?
|
||||
patch_info[:updatedFiles].each do |file|
|
||||
logger.debug(LOG_TAG) { " #{file}" }
|
||||
|
||||
patch = patch_mix.entries.find { |e| e.name.casecmp?(file) }
|
||||
target = target_mix.entries.find { |e| e.name.casecmp?(file) }
|
||||
|
||||
if target
|
||||
target_mix.entries[target_mix.entries.index(target)] = patch
|
||||
else
|
||||
target_mix.entries << patch
|
||||
end
|
||||
end
|
||||
|
||||
logger.info(LOG_TAG) { " Writing updated #{file_path}..." } if patch_info[:updatedFiles].size.positive?
|
||||
temp_mix_path = "#{temp_path}/#{File.basename(file_path)}"
|
||||
temp_mix = W3DHub::WWMix.new(path: temp_mix_path)
|
||||
target_mix.entries.each { |e| temp_mix.add_entry(entry: e) }
|
||||
unless temp_mix.save
|
||||
raise temp_mix.error_reason
|
||||
end
|
||||
|
||||
# Overwrite target mix with temp mix
|
||||
FileUtils.mv(temp_mix_path, file_path)
|
||||
|
||||
FileUtils.remove_dir(temp_path)
|
||||
##########################
|
||||
## Supporting functions ##
|
||||
##########################
|
||||
|
||||
# pestimistically estimate required disk space to:
|
||||
# download, unpack/patch, and install.
|
||||
def disk_space_available?
|
||||
true
|
||||
end
|
||||
|
||||
def unzip(package_path, path)
|
||||
stream = Zip::InputStream.new(File.open(package_path))
|
||||
def fetch_package(version, name)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
while (entry = stream.get_next_entry)
|
||||
# Normalize the path to handle case-insensitivity consistently
|
||||
file_path = normalize_path(entry.name, path)
|
||||
result
|
||||
end
|
||||
|
||||
dir_path = File.dirname(file_path)
|
||||
unless dir_path.end_with?("/.") || Dir.exist?(dir_path)
|
||||
FileUtils.mkdir_p(dir_path)
|
||||
end
|
||||
def verify_package(version, name)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
File.open(file_path, "wb") do |f|
|
||||
i = entry.get_input_stream
|
||||
result
|
||||
end
|
||||
|
||||
while (chunk = i.read(32_000_000)) # Read up to ~32 MB per chunk
|
||||
f.write chunk
|
||||
end
|
||||
end
|
||||
end
|
||||
def unpack_package(version, name)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
return true
|
||||
result
|
||||
end
|
||||
|
||||
# Apply all patches for a particular MIX file at once
|
||||
def apply_patches(package)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def apply_patch(target_mix, patch_mix)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def unzip(package_path)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,9 +8,7 @@ class W3DHub
|
||||
end
|
||||
|
||||
def execute_task
|
||||
show_application_taskbar
|
||||
|
||||
fail_fast
|
||||
fail_fast!
|
||||
return false if failed?
|
||||
|
||||
fetch_manifests
|
||||
@@ -46,9 +44,6 @@ class W3DHub
|
||||
mark_application_installed
|
||||
return false if failed?
|
||||
|
||||
sleep 1
|
||||
hide_application_taskbar
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user