module W3DHubLauncher class Worker DEFAULT_HEADERS = [ ["user-agent", W3DHubLauncher::USER_AGENT] ].freeze DEFAULT_NETWORK_TIMEOUT = 30 RESPONSE_SEPARATOR = "\04".freeze Response = Data.define(:status, :request_id, :result) attr_reader :w3dhub_api, :settings def initialize @queue = [] end def connect puts :connect @buffer = StringIO.new @socket = UNIXSocket.new(IPC_PATH) while (payload = @queue.shift) @socket.puts(payload) end rescue Errno::ENOENT sleep 1 retry end def connected? @socket && !@socket.closed? end def listen init_server end def init_server @requests = [] @tasks = [] @client = nil @settings = nil @game_servers = [] @host_pings = {} @w3dhub_api = W3DHubLauncher::W3DHubApi.new Async do |task| task.async do |subtask| UNIXServer.open(IPC_PATH) do |server| puts "IPC running!" while(socket = server.accept) @client = socket subtask.async do while(data = socket.gets) json = JSON.parse(data) query = Request::Query.new(type: json["type"].to_sym, request_id: json["request_id"], data: json["data"]) # pp [:server_incoming, data, query] if respond_to?(query.type) response = send(query.type, query) # pp [:server_to_client, response] payload = { status: response.status, request_id: response.request_id, data: response.result.data, error: response.result.error }.to_json socket.write(payload) socket.write(RESPONSE_SEPARATOR) socket.flush end end end end end ensure # manually delete "socket" File.delete(IPC_PATH) end # Handle getting and updating game server data task.async do # Wait for "activation" while (@settings.nil?) sleep 0.25 end game_server_hub_websocket end # Handle backend communication task.async do # Wait for "activation" while (@settings.nil?) sleep 0.25 end backend_websocket end # Handle getting ping times to servers task.async do ping_game_servers end # Handle Task requests task.async do while true @tasks.delete_if { |t| [Request::STATUS_ERROR, Request::STATUS_COMPLETE].include?(t.status) } if @tasks.first && @tasks.all? { |t| t.status == Request::STATUS_PENDING } @tasks.first.start(self) end sleep 0.25 end end end end def game_server_hub_websocket WebSocketClient.new.connect("wss://gsh.w3d.cyberarm.dev/api/websocket") do |websocket| websocket.on(:open) do # puts "CONNECTED" websocket.send( { "protocol_version": 1 }.to_json ) end websocket.on(:message) do |message| # puts "MESSAGE" # pp message hash = JSON.parse(message) case hash["type"] when "ping" websocket.send({ "type":"pong", "data":"" }.to_json) when "pong" when "server_list" @game_servers = hash["data"].map { |server| Worker::Api::GameServer.new(server) } broadcast(:game_server_list, data: hash["data"]) when "server_added", "server_updated" existing_game_server = @game_servers.find { |s| s.id == hash["data"]["id"] } game_server = Worker::Api::GameServer.new(hash["data"]) if existing_game_server # replace @game_servers[@game_servers.index(existing_game_server)] = game_server else # add @game_servers << game_server end broadcast(:"game_#{hash["type"]}", data: hash["data"]) when "server_removed" game_server = @game_servers.find { |s| s.id == hash["data"]["id"] } @game_servers.delete(game_server) if game_server broadcast(:"game_#{hash["type"]}", data: hash["data"]) when "server_details" # TODO end end websocket.on(:close) do # puts "CLOSED" sleep 5 game_server_hub_websocket end websocket.on(:error) do |error| puts "ERROR: #{error}" puts error.backtrace sleep 5 game_server_hub_websocket end end end def backend_websocket end def ping_game_servers socket = Socket.open(Socket::AF_INET, Socket::SOCK_DGRAM, Socket::IPPROTO_ICMP) socket.setsockopt(:SOCKET, :TIMESTAMP, true) Sync do |task| loop do echo_requests = {} # Send pings to each unique server host @game_servers.map(&:address).uniq.each do |server_address| address = Socket.sockaddr_in(0, server_address) sequence_id = Digest::SHA256.hexdigest("#{server_address}-#{Time.now.iso8601}") # ICMP Echo Request, ICMP Code, , , , Data msg = [8, 0, 0, 0, sequence_id].flatten.pack("C2n2A64") echo_requests[sequence_id] = { send_time: Time.now, address: server_address, replied: false } socket.send(msg, 0, address) end # Receive replies until timeout task.with_timeout(3) do while(echo_requests.values.any? { |v| v[:replied] == false }) response, sender_address, flags, *controls = socket.recvmsg(256) _type, _code, _identifier, _sequence, data = response.unpack("C2n2A64") if (request = echo_requests[data])# && request[:address] == sender_address. request[:replied] = true round_trip_time = ((controls.last.timestamp - request[:send_time]) * 1000.0).round @host_pings[request[:address]] = round_trip_time # puts "#{request[:address]}: #{round_trip_time}ms" # else # # packet not for us, or it got mangled in transit. end end rescue Async::TimeoutError # puts "Timed out waiting for: #{echo_requests.values.select { |v| v[:replied] == false }.map { |v| v[:address] }.join(', ')}" end broadcast(:game_server_pings, data: @host_pings) sleep 5 end end ensure socket&.close end def broadcast(type, data:) payload = { type: :eventbus, request_id: -1, data: { type: type, data: data } }.to_json @client&.write(payload) @client&.write(RESPONSE_SEPARATOR) @client&.flush end def message_requester(request_id:, status:, data:, error: nil) payload = { status: status, request_id: request_id, data: data, error: error }.to_json @client&.write(payload) @client&.write(RESPONSE_SEPARATOR) @client&.flush end # Send request to server def request(query) # pp [:client_request, query] payload = { type: query.type, request_id: query.request_id, data: query.data }.to_json raise "UNKNOWN REQUEST: #{query}" unless respond_to?(query.type) if connected? @socket.puts(payload) else @queue << payload end end def service data = @socket.read_nonblock(1_048_576) # 1 MB @buffer << data @buffer.rewind payloads = @buffer.readlines(RESPONSE_SEPARATOR, chomp: true) @buffer.reopen(@buffer.string[@buffer.pos..@buffer.length]) @buffer.pos = @buffer.length payloads.each do |payload| # pp [:CLIENT, data] json = JSON.parse(payload) request = W3DHubLauncher::Worker::Request.requests.find { |r| r.request_id == json["request_id"] } # pp [json, request] if request.nil? && json["request_id"] && json["type"] == "eventbus" CyberarmEngine::Window.instance&.add_to_queue(proc { CyberarmEngine::EventBus.publish(json["data"]["type"], json["data"]["data"]) }) else CyberarmEngine::Window.instance&.add_to_queue(proc { request.handle_event( json["status"], CyberarmEngine::Result.new(data: json["data"], error: json["error"]) ) }) end rescue JSON::ParserError => e puts "This should never happen!?!?!" pp data, payload end rescue Errno::EWOULDBLOCK end def deliver_response(result, query) response = Response.new(result.okay? ? Request::STATUS_COMPLETE : Request::STATUS_ERROR, query.request_id, result) # pp response response end def create_directory(path) pathname = Pathname.new(path) unless Dir.exist?(pathname.dirname) FileUtils.mkdir_p(pathname.dirname) end end # # ------------ query / request handlers ------------ # def fetch_url(query) result = CyberarmEngine::Result.new method = query.data["method"] url = query.data["url"] headers = query.data["headers"] || DEFAULT_HEADERS body = query.data["body"] Sync do |task| task.with_timeout(DEFAULT_NETWORK_TIMEOUT) do Async::HTTP::Internet.send(method, url, headers, body) do |response| if response.success? result.data = response.read end # rescue StandardError => e # result.error = e end # rescue Async::TimeoutError # result.error = e end end deliver_response(result, query) end def download_url(query) result = CyberarmEngine::Result.new method = query.data["method"] url = query.data["url"] path = query.data["path"] headers = query.data["headers"] || DEFAULT_HEADERS body = query.data["body"] create_directory(path) Sync do |task| task.with_timeout(DEFAULT_NETWORK_TIMEOUT) do Async::HTTP::Internet.send(method, url, headers, body) do |response| if response.success? content_length = response.headers["content-length"] || 0 total_downloaded_bytes = 0 File.open(path, "wb") do |file| response.each do |chunk| file.write(chunk) downloaded_bytes = chunk.length total_downloaded_bytes += downloaded_bytes progress_result = CyberarmEngine::Result.new(data: { downloaded_bytes: downloaded_bytes, total_downloaded_bytes: total_downloaded_bytes, content_length: content_length }) message_requester(request_id: query.request_id, status: Request::STATUS_IN_PROGRESS, data: progress_result.data, error: progress_result.error) end end result.data = true end # rescue StandardError => e # result.error = e end # rescue Async::TimeoutError # result.error = e end end deliver_response(result, query) end def ico_to_png(query) result = CyberarmEngine::Result.new begin ico = ICO.new(file: query.data["ico_path"]) ico.save(ico.images.max_by(&:width), query.data["png_path"]) result.data = { path: query.data["png_path"] } rescue => e result.error = e end deliver_response(result, query) end def server_map_image(query) result = CyberarmEngine::Result.new # TODO: Ideally battleview would host readily accessible map previews server = @game_servers.find { |s| s.address == query.data["server"]["address"] && s.port == query.data["server"]["port"] } return deliver_response(result, query) unless server # is application installed to source data from? application = @settings.application_installed?(server.game, server.channel) return deliver_response(result, query) unless application save_path = server.map_preview_image_path if File.exist?(save_path) result.data = { image_path: save_path } return deliver_response(result, query) end map_mix = Dir.glob("#{application.installation_path}/**/#{query.data["server"]["map"]}")&.first return deliver_response(result, query) unless map_mix mix = W3DHubLauncher::WWMix.new(path: map_mix) return deliver_response(result, query) unless mix.load entry = nil canvas = nil entry = mix.entries.find { |e| e.name.match?(/_map_preview\.png/i) } entry ||= mix.entries.find { |e| e.name.match?(/_map_preview\.dds/i) } entry ||= mix.entries.find { |e| e.name.match?(/screenshot\.png/i) } entry ||= mix.entries.find { |e| e.name.match?(/screenshot\.dds/i) } entry ||= mix.entries.find { |e| e.name.match?(/_map\.png/i) } entry ||= mix.entries.find { |e| e.name.match?(/_map\.dds/i) } return deliver_response(result, query) unless entry return deliver_response(result, query) unless entry.read if entry.name.match?(/\.dds/i) begin dds = W3DHubLauncher::DDS.new(io: StringIO.new(entry.blob)) image_data = dds.images.first canvas = ChunkyPNG::Canvas.from_rgba_stream(image_data.width, image_data.height, image_data.data) canvas.save(save_path) rescue StandardError => e result.error = e return deliver_response(result, query) end elsif entry.name.match?(/\.png/i) # canvas = ChunkyPNG::Canvas.from_blob(entry.blob) File.binwrite(save_path, entry.blob) end result.data = { image_path: save_path } deliver_response(result, query) end def dns_resolution(query) result = CyberarmEngine::Result.new domains = [ "w3dhub-api.w3d.cyberarm.dev", "s3.w3d.cyberarm.dev", "secure.w3dhub.com" ] domains.each do |domain| Resolv.getaddress(domain) rescue StandardError => e result.error = "Failed to resolve: #{domain}" end result.data = true if result.error.nil? deliver_response(result, query) end def w3dhub_api_call(query) result = @w3dhub_api.send(query.data["call"], *(query.data["arguments"] || [])) deliver_response(result, query) end def load_settings(query) result = CyberarmEngine::Result.new path = "#{W3DHubLauncher::CONFIG_PATH}/settings.json" begin if File.exist?(path) && File.size(path).positive? json = File.read(path) @settings = Api::Settings.new(JSON.parse(json)) result.data = json else result.error = RuntimeError.new("Launcher settings file does not exist or is empty.") end rescue => e result.error = e end deliver_response(result, query) end def update_settings(query) result = CyberarmEngine::Result.new begin @settings = Api::Settings.new(JSON.parse(query.data)) FileUtils.mkdir_p(W3DHubLauncher::CONFIG_PATH) File.write(W3DHubLauncher::SETTINGS_FILE_PATH, query.data) if @settings&.account&.signed_in? @w3dhub_api.access_token = @settings.account.access_token end result.data = query.data rescue => e result.error = e end deliver_response(result, query) end def servers(query) hash = { "method" => "get", "url" => "https://gsh.w3d.cyberarm.dev/listings/getAll/v2?statusLevel=2", "headers" => DEFAULT_HEADERS, "body" => query.data["body"] } # inject data into query query = Request::Query.new(query.type, query.request_id, hash) response = fetch_url(query) if response.result.okay? @game_servers = JSON.parse(response.result.data).map { |server| Worker::Api::GameServer.new(server) } end deliver_response(response.result, query) end def task_install_application(query) application = @w3dhub_api.applications.find { |app| app.id == query.data["app_id"] } channel = application.channels.find { |chan| chan.id == query.data["channel_id"] } @tasks << Task::InstallApplication.new( request_id: query.request_id, application: application, channel: channel, installed_version: nil, target_version: channel.version ) Response.new(Request::STATUS_PENDING, query.request_id, CyberarmEngine::Result.new(data: true)) end def task_update_application(query) application = @w3dhub_api.applications.find { |app| app.id == query.data["app_id"] } channel = application.channels.find { |chan| chan.id == query.data["channel_id"] } settings_application = @settings.application_installed?(application.id, channel.id) @tasks << Task::UpdateApplication.new( request_id: query.request_id, application: application, channel: channel, installed_version: settings_application.version, target_version: channel.version ) Response.new(Request::STATUS_PENDING, query.request_id, CyberarmEngine::Result.new(data: true)) end def task_repair_application(query) application = @w3dhub_api.applications.find { |app| app.id == query.data["app_id"] } channel = application.channels.find { |chan| chan.id == query.data["channel_id"] } settings_application = @settings.application_installed?(application.id, channel.id) @tasks << Task::RepairApplication.new( request_id: query.request_id, application: application, channel: channel, installed_version: settings_application.version, target_version: settings_application.version ) pp @tasks.last Response.new(Request::STATUS_PENDING, query.request_id, CyberarmEngine::Result.new(data: true)) end def task_move_application(query) end def task_uninstall_application(query) end def unlink_application(query) result = CyberarmEngine::Result.new application = @settings.application_installed?(query.data["app_id"], query.data["channel_id"]) if application @settings.remove_application(application) query = Request::Query.new(query.type, query.request_id, @settings.to_json) return update_settings(query) end deliver_response(result, query) end end end