18 Commits

Author SHA1 Message Date
53e7a72ceb Added initial support for extracting map preview images from game level mixes that include a screenshot or overhead map 2026-09-18 22:21:20 -05:00
0a1909a84c SDL2 -> SDL3 :party: 2026-09-18 22:19:55 -05:00
b7739de136 Add DDS loading library 2026-09-18 22:19:19 -05:00
17614f4416 Support 'play now' server selection 2026-09-18 15:25:22 -05:00
f8ceb69402 Fix up app filters on server browser, make it possible to join server from server browser 2026-09-18 13:51:46 -05:00
6707f260c9 Initial support for importing games 2026-09-18 09:28:39 -05:00
56699bb644 Very crude initial support for starting app from launcher 2026-09-17 22:53:14 -05:00
09ef9d613f Tweak margins of game news and game events so that the top and right edges align nicely 2026-09-17 10:01:37 -05:00
b505f22b8c Applications can now be marked as installed, status bar can now show real data 2026-09-16 14:26:22 -05:00
b551312751 Initial work on patching mixes 2026-09-15 22:06:41 -05:00
1d3164da72 Write paths.ini, fix a few issues 2026-09-14 18:42:13 -05:00
73a9a80a34 Unpacking packages (without patching) now works! 2026-09-14 17:26:37 -05:00
7adb23d71f Manifest package verification now works and partial package downloads (resumed downloads) 2026-09-14 15:40:21 -05:00
4ba71b760a Package selection and downloading functional 2026-09-14 14:21:40 -05:00
db0dd9fc7c More work on Task towards getting app installation workin' 2026-09-12 19:41:27 -05:00
2cf8146553 Initial work on manifest fetching and parsing 2026-09-11 23:55:04 -05:00
af438e35bc Fixed news item titles clipped on if wrapped to second line 2026-09-11 15:59:13 -05:00
2f07cde109 Server Details panel now shows real data 2026-09-11 15:57:00 -05:00
24 changed files with 1438 additions and 270 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
pkg/
*.json
*.xml
data/cache/*
!data/cache/.gitkeep
data/logs/*

View File

@@ -1,16 +1,16 @@
GEM
remote: https://gem.coop/
specs:
async (2.45.1)
async (2.46.0)
console (~> 1.29)
fiber-annotation
io-event (~> 1.21)
async-http (0.103.0)
async-http (0.105.0)
async (>= 2.35.1)
async-pool (~> 0.12)
io-endpoint (~> 0.18)
io-stream (~> 0.14)
protocol-http (~> 0.66)
protocol-http (~> 0.72)
protocol-http1 (~> 0.41)
protocol-http2 (~> 0.28)
protocol-url (~> 0.2)
@@ -23,7 +23,7 @@ GEM
protocol-websocket (~> 0.17)
base64 (0.3.0)
chunky_png (1.4.0)
console (1.37.0)
console (1.38.0)
fiber-annotation
fiber-local (~> 1.1)
json
@@ -40,21 +40,22 @@ GEM
ffi (~> 1.12)
io-endpoint (0.18.0)
openssl (>= 3.3.0)
io-event (1.21.1)
io-event (1.22.0)
io-stream (0.14.0)
openssl (>= 3.3)
ircparser (1.0.0)
json (3.0.1)
json (3.0.2)
logger (1.7.0)
openssl (4.0.2)
protocol-hpack (1.5.1)
protocol-http (0.71.0)
protocol-http (0.72.0)
protocol-http1 (0.41.0)
protocol-http (~> 0.68)
protocol-http2 (0.28.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.62)
protocol-rack (0.22.1)
protocol-rack (0.23.0)
console (~> 1.0)
io-stream (>= 0.10)
protocol-http (~> 0.58)
rack (>= 1.0)
@@ -88,13 +89,13 @@ DEPENDENCIES
sdl3-bindings
CHECKSUMS
async (2.45.1) sha256=95849f7432eca535146c69f3078dab3c7228d0075ed71f48733f3e585dd7c267
async-http (0.103.0) sha256=137e3d5d419f7d72d5fa33cf04f30232e79c57b690025a650b8f9e48ece8bc41
async (2.46.0) sha256=34f984733dbf63a997376346451ec995e858cacc3105e86ccfeaf8877b4f490b
async-http (0.105.0) sha256=55191969d5cf61be5fbc2e0aadd51d77085318ba192a5ff5e12e3aba0826906a
async-pool (0.12.0) sha256=5580493dc024bd704dab2ed0bf1307100722e49cbb825c7965f8501194f58506
async-websocket (0.30.1) sha256=54bb8a8f184e4aa64434c7a78ecc55850a67a3d1dcd02e3ae787376e2c673936
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe
console (1.37.0) sha256=8093b286c2595a063849c098594fee5155ecc7967d36f3aa8cbe7569c8f3efd7
console (1.38.0) sha256=35b82f59130e95e9be6b832d9e539c8b3988fc192e4a8e9a86ab4922f44ad5de
cyberarm_engine (0.25.1) sha256=6f851a2ed0a32bdd25fc619172b546a8e26eefeb13a62c509f226337170d0efe
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
@@ -103,17 +104,17 @@ CHECKSUMS
fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1
gosu (1.5.0-x86_64-linux)
io-endpoint (0.18.0) sha256=720d186bcd584e8ba490bfc91dab332cc3c5ffee848b5b03894555cb275151aa
io-event (1.21.1) sha256=b3088f89ad67edf9be9fc2f48a5fae0790e92862487551eeb337b49098eb99e6
io-event (1.22.0) sha256=4936d38dfcf2291d897707fd94690f1a63fdb1ea3ab4fa63c6739fc6c61e1534
io-stream (0.14.0) sha256=07d9b13ea2066ccbbc761fff8d69ad5a31e47561d31e02cc271f3ed9c1a8f1cd
ircparser (1.0.0) sha256=0d4598f691172e2ad078207c50ba62c0d3428ae841836b0a682e6cd01d62878d
json (3.0.1) sha256=c80f74a3570c3a310cd41c526904e7ef8363abdece4432f31fe97d812bc4a39e
json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2
protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91
protocol-http (0.71.0) sha256=38577bf3df0bf0c3f7ee7e21b43fcb4bd7624e36f697dbe9c3eea344f14bd9ba
protocol-http (0.72.0) sha256=b153474785979495b9f2df78f0fdc2e02edc7e92c1f7ea00425eed73bd02194c
protocol-http1 (0.41.0) sha256=9863874a1228948a896c3e7ea16d13a680c8f3ed50706007c78b7033018b0ea3
protocol-http2 (0.28.0) sha256=b7c6195dcfa80ec2484670e9fd8fc21024cc654a391aa0792433aff1188c1691
protocol-rack (0.22.1) sha256=1185d245927ef9849a603700d6991ca353bc89724fbf98efa4a4333ed62a9fc3
protocol-rack (0.23.0) sha256=63eec3341b20565b3dd155345fd1426ecbbed322cdc8a6accc993905388c03d7
protocol-url (0.19.0) sha256=3f6b9f6aa742a259a0b3610fbbd9685dcd9506adf6734f428ff96259faabfb21
protocol-websocket (0.21.2) sha256=cbe35be8e045597b221874fba5928d7cd2483884126e3e36faece4741dc88eb7
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7

68
lib/application_helper.rb Normal file
View File

@@ -0,0 +1,68 @@
module W3DHubLauncher
class ApplicationHelper
def self.wine_command(app)
# FIXME: account for the cursed whitespace and wrap in quotes
format("%s ", MemCache[:settings]&.preferences&.wine_command || "wine")
end
def self.wine_prefix(app)
# TODO: fallback from app specific prefix to launcher prefix to global prefix
# format("%s ", app&.wine_prefix_path || MemCache[:settings]&.preferences&.wine_prefix_path || "")
""
end
def self.run(app, *args)
exe_path = app.id == "ecw" ? format("%s/game500.exe", app.installation_path) : format("%s/game.exe", app.installation_path)
env = {}
unless wine_prefix(app).to_s.empty?
env["WINEPREFIX"] = wine_prefix(app).to_s
end
env["DXVK_HUD"] = "full"
pid = Process.spawn(
env,
"#{wine_command(app)}#{exe_path} -launcher #{args.join(' ')}"
)
Process.detach(pid)
end
def self.join_server(app, server, password: nil, token: nil, multi: false)
parameters = []
parameters << format("+connect %s:%s", server.address, server.port)
parameters << format("+netplayername \"%s\"", "cyberarm")
parameters << format("+password \"%s\"", password) if password
parameters << format("+userkey \"%s\"", token) if token
parameters << "+multi" if multi
run(
app,
parameters.join(" ")
)
end
# select "best" server to join
def self.play_now_server(app)
channel = MemCache[:applications].find { |appl| appl.id == app.id }&.channels&.find { |c| c.id == app.channel }
return nil unless channel
server_options = MemCache[:servers].select do |server|
server.game == app.id &&
server.channel == channel.server_channel &&
!server.password? &&
server.player_count < server.max_players
end.sort_by do |s|
[s.player_count, -s.ping]
end.reverse
# try to find server with lowest ping and matching app version
found_server = server_options.find { |s| s.version == app.version }
# try to find server with lowest ping and undefined version
found_server ||= server_options.find { |s| s.version == Worker::Api::GameServer::NO_OR_DEFAULT_VERSION }
found_server
end
end
end

View File

@@ -12,7 +12,7 @@ module W3DHubLauncher
LIBRARIES = [
Item.new("Ruby", "Programming language. A Programmer's Best Friend", "https://ruby-lang.org", "BSD 2-Clause", "https://www.ruby-lang.org/en/about/license.txt"),
Item.new("gosu", "Light-weight game library", "https://libgosu.org", "MIT", "https://github.com/gosu/gosu/blob/master/COPYING"),
Item.new("SDL2", "Simple DirectMedia Layer", "https://libsdl.org", "MIT", "https://github.com/libsdl-org/SDL/blob/SDL2/LICENSE.txt"),
Item.new("SDL3", "Simple DirectMedia Layer", "https://libsdl.org", "zlib", "https://github.com/libsdl-org/SDL/blob/main/LICENSE.txt"),
Item.new("MojoAL", "OpenAL sound library implementation in a single C file", "https://icculus.org/mojoAL/", "MIT", "https://github.com/icculus/mojoAL/blob/main/LICENSE.txt"),
Item.new("async", "Asynchronous event-driven reactor for Ruby", "https://github.com/socketry/async", "MIT", "https://github.com/socketry/async/blob/main/license.md"),

280
lib/dds.rb Normal file
View File

@@ -0,0 +1,280 @@
require "stringio"
module W3DHubLauncher
class DDS
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-pixelformat
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dx-graphics-dds-pguide
# https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-file-layout-for-textures
DXT1 = 827611204
DXT2 = 844388420
DXT3 = 861165636
DXT4 = 877942852
DXT5 = 894720068
DDSD_CAPS = 0x1
DDSD_HEIGHT = 0x2
DDSD_WIDTH = 0x4
DDSD_PITCH = 0x8
DDSD_PIXELFORMAT = 0x1000
DDSD_MIPMAPCOUNT = 0x20_000
DDSD_LINEARSIZE = 0x80_000
DDSD_DEPTH = 0x800_000
DDSCAPS_COMPLEX = 0x8
DDSCAPS_MIPMAP = 0x400_000
DDSCAPS_TEXTURE = 0x1000
DDSCAPS2_CUBEMAP = 0x200
DDSCAPS2_CUBEMAP_POSITIVEX = 0x400
DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800
DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000
DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000
DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000
DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000
DDSCAPS2_VOLUME = 0x200_000
DDPF_ALPHAPIXELS = 0x1
DDPF_ALPHA = 0x2
DDPF_FOURCC = 0x4
DDPF_RGB = 0x40
DDPF_YUV = 0x200
DDPF_LUMINANCE = 0x20_000
Header = Struct.new(
:byte_size, :flags, :height, :width, :pitch_or_linear_size,
:depth, :mipmap_count, :reserved1, :pixel_format,
:caps, :caps2, :caps3, :caps4, :reserved2
)
HeaderX10 = Struct.new(:value)
PixelFormat = Struct.new(
:byte_size, :flags, :four_cc, :rgb_bit_count,
:red_bit_mask, :green_bit_mask, :blue_bit_mask, :alpha_bit_mask
)
Image = Struct.new(:data, :width, :height)
attr_reader :header, :images
def initialize(path: nil, io: nil, shallow: true)
if path
@data = StringIO.new(File.binread(path))
elsif io
@data = io
else
raise "Nah man."
end
@shallow = shallow
@images = []
decode_header
decode_images
end
# typedef struct {
# DWORD dwSize;
# DWORD dwFlags;
# DWORD dwHeight;
# DWORD dwWidth;
# DWORD dwPitchOrLinearSize;
# DWORD dwDepth;
# DWORD dwMipMapCount;
# DWORD dwReserved1[11];
# DDS_PIXELFORMAT ddspf;
# DWORD dwCaps;
# DWORD dwCaps2;
# DWORD dwCaps3;
# DWORD dwCaps4;
# DWORD dwReserved2;
# } DDS_HEADER;
def decode_header
@data.pos = 0
raise "File is not a DDS" unless read_u32 == 0x20534444 # magic byte "DDS"
size = read_u32
flags = read_u32
height = read_u32
width = read_u32
pitch_or_linear_size = read_u32
depth = read_u32
mipmap_count = read_u32
reserved1 = 11.times.map { read_u32 }
pixel_format = read_pixelformat
caps = read_u32
caps2 = read_u32
caps3 = read_u32
caps4 = read_u32
reserved2 = read_u32
@header = Header.new(
size, flags, height, width, pitch_or_linear_size, depth, mipmap_count,
reserved1, pixel_format, caps, caps2, caps3, caps4, reserved2
)
@header.freeze
raise "Invalid dds header size: #{size}, expected 124" unless size == 124
raise "Invalid pixel format header size: #{pixel_format.byte_size}, expected 32" unless pixel_format.byte_size == 32
end
def decode_images
pixel_format = @header.pixel_format
raise "Unsupported dds format: #{pixel_format.four_cc}" unless (pixel_format.four_cc == DXT1 || pixel_format.four_cc == DXT3 || pixel_format.four_cc == DXT5)
raise "Unsupported pixel format: #{pixel_format.flags}" unless pixel_format.flags == DDPF_FOURCC
width = @header.width
height = @header.height
mipmap_count = [1, @header.mipmap_count].max
mipmap_count = 1 if @shallow
block_bytes = 8
block_bytes = 16 if pixel_format.four_cc == DXT3 || pixel_format.four_cc == DXT5
data_offset = @header.byte_size + 4
mipmap_count.times do
data_length = [4, width].max / 4 * [4, height].max / 4 * block_bytes
@data.pos = data_offset
image = Image.new(
to_rgba(@data.read(data_length), width, height, pixel_format.four_cc == DXT1),
width,
height
)
@images << image
data_offset += data_length
width /= 2
height /= 2
end
end
def read_u32
@data.read(4).unpack1("L")
end
# struct DDS_PIXELFORMAT {
# DWORD dwSize;
# DWORD dwFlags;
# DWORD dwFourCC;
# DWORD dwRGBBitCount;
# DWORD dwRBitMask;
# DWORD dwGBitMask;
# DWORD dwBBitMask;
# DWORD dwABitMask;
# };
def read_pixelformat
size = read_u32
flags = read_u32
four_cc = read_u32
rgb_bit_count = read_u32
red_bit_mask = read_u32
green_bit_mask = read_u32
blue_bit_mask = read_u32
alpha_bit_mask = read_u32
PixelFormat.new(
size, flags, four_cc, rgb_bit_count,
red_bit_mask, green_bit_mask, blue_bit_mask, alpha_bit_mask
)
end
# https://github.com/kchapelier/decode-dxt/
def to_rgba(data, width, height, dxt1 = false)
conversion_started = Gosu.milliseconds
data = StringIO.new(data)
rgba = Array.new(width * height * 4, 0) # StringIO.new("\0" * (width * height * 4), "wb") #
width_4 = (width / 4) | 0
height_4 = (height / 4) | 0
stride = 4
height_4.times do |h|
width_4.times do |w|
a, b, index = data.read(8).unpack("vvV")
color_values = interpolate_color_values(a, b, dxt1)
color_indices = index
4.times do |y|
4.times do |x|
pixel_index = (3 - x) + (y * 4)
rgba_index = (h * 4 + 3 - y) * width * 4 + (w * 4 + x) * 4
color_index = (color_indices >> (2 * (15 - pixel_index))) & 0x03
rgba[rgba_index] = color_values[color_index * 4]
rgba[rgba_index + 1] = color_values[color_index * 4 + 1]
rgba[rgba_index + 2] = color_values[color_index * 4 + 2]
rgba[rgba_index + 3] = color_values[color_index * 4 + 3]
end
end
end
end
puts "Inital set complete after: #{Gosu.milliseconds - conversion_started}ms"
v = rgba.pack("C*") #.map { |v| v.chr }.join
puts "type set complete after: #{Gosu.milliseconds - conversion_started}ms"
v
end
def interpolate_color_values(a, b, dxt1 = false)
first_color = convert_565_byte_to_rgb(a)
second_color = convert_565_byte_to_rgb(b)
color_values = [
first_color[0], first_color[1], first_color[2], 255,
second_color[0], second_color[1], second_color[2], 255
]
if (dxt1 && a <= b)
color_values.push(
(first_color[0] + second_color[0]) / 2,
(first_color[1] + second_color[1]) / 2,
(first_color[2] + second_color[2]) / 2,
255,
0,
0,
0,
0
)
else
color_values.push(
lerp(first_color[0], second_color[0], 1 / 3),
lerp(first_color[1], second_color[1], 1 / 3),
lerp(first_color[2], second_color[2], 1 / 3),
255,
lerp(first_color[0], second_color[0], 2 / 3),
lerp(first_color[1], second_color[1], 2 / 3),
lerp(first_color[2], second_color[2], 2 / 3),
255
)
end
color_values
end
def convert_565_byte_to_rgb(byte)
[
((byte >> 11) & 31) * (255 / 31),
((byte >> 5) & 63) * (255 / 63),
(byte & 31) * (255 / 31)
]
end
def lerp(a, b, r)
a * (1 - r) + b * r
end
end
end

View File

@@ -40,12 +40,18 @@ module W3DHubLauncher
def present_item(item)
unless item.url.empty?
link item.name, tip: item.url, margin_left: PADDING, font: FONT_BOLD
link item.name, tip: item.url, margin_left: PADDING, font: FONT_BOLD do
SDL.OpenURL(item.url)
end
else
tagline item.name, margin_left: PADDING
end
para item.description, margin_left: LARGE_PADDING
link item.license, tip: item.license_url, margin_left: PADDING + LARGE_PADDING unless item.license.empty?
unless item.license.empty?
link format("%s license", item.license), tip: item.license_url, margin_left: PADDING + LARGE_PADDING do
SDL.OpenURL(item.license_url)
end
end
end
end
end

View File

@@ -0,0 +1,77 @@
module W3DHubLauncher
class Dialog
class ImportApplication < W3DHubLauncher::Dialog
def setup
super
@application = @options[:application]
@channel = @options[:channel]
stack(width: 1.0, max_width: 600, height: 1.0, max_height: 600, h_align: :center, v_align: :center, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0xee_63452c) do
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0) do
banner "Import Application", width: 1.0, text_align: :center
end
stack(width: 1.0, fill: true, scroll: true, margin_left: PADDING, margin_right: PADDING, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_BLACK) do
title format("%s (%s)", @application.name, @channel.name)
caption "Path to application executable:"
@path_input = edit_line "", width: 1.0
button "Browse..." do
Gosu.choose_file do |file_list|
pp file_list
unless file_list.empty?
@path_input.value = file_list.first
end
end
end
@path_input.subscribe(:changed) do |e, value|
found = false
if File.directory?(File.dirname(value))
Dir.glob("#{File.dirname(value)}/game*.exe").each do |file|
if File.exist?(file)
found = true
break
end
end
end
@import_button.enabled = found
end
end
flow(width: 1.0, padding: PADDING, background_nine_slice: NINE_SLICE_ROUNDED_BOTTOM, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0) do
button "Close" do
pop_state
end
flow(fill: true)
@import_button = button "Import", enabled: false, **CTA_BUTTON_THEME do
pop_state
app = Worker::Api::Settings::Application.create(
id: @application.id,
channel: @channel.id,
version: @channel.version,
installation_path: File.dirname(@path_input.value),
wine_prefix_path: "",
launch_command: "%COMMAND%",
timestamp: Time.now.to_i
)
MemCache[:settings].applications << app
Worker::Api.update_settings(MemCache[:settings])
# FIXME: broadcast app installed on CyberarmEngine::EventBus
end
end
end
end
end
end
end

View File

@@ -3,7 +3,7 @@ module W3DHubLauncher
BLACK_IMAGE = Gosu.render(64, 64, retro: true) { Gosu.draw_rect(0, 0, 32, 32, Gosu::Color::BLACK) }
WHITE_IMAGE = Gosu.render(64, 64, retro: true) { Gosu.draw_rect(0, 0, 32, 32, Gosu::Color::WHITE) }
def safe_get_image(path, retro: false)
def safe_get_image(path, fallback_path: "#{ROOT_PATH}/media/default.png", retro: false)
raise RuntimeError, "Images may only be loaded from the main thread!" unless Thread.current == Thread.main
begin
@@ -12,8 +12,11 @@ module W3DHubLauncher
pp e
end
path = "#{ROOT_PATH}/media/default.png"
return get_image(path, retro: retro) if File.exist?(path)
begin
return get_image(fallback_path, retro: retro) if File.exist?(fallback_path)
rescue RuntimeError => e
pp e
end
WHITE_IMAGE
end

View File

@@ -49,12 +49,12 @@ module W3DHubLauncher
end
# game events and news container
stack(fill: true, height: 1.0, margin_left: LARGE_PADDING, scroll: true) do
@event_container = stack(width: 1.0, padding: PADDING, margin_left: HALF_PADDING, margin_right: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0x88_26a269) do
stack(fill: true, height: 1.0, margin_left: PADDING, scroll: true) do
@event_container = stack(width: 1.0, padding: PADDING, margin_left: PADDING, margin_bottom: PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0x88_26a269) do
end
# news container
@news_container = flow(width: 1.0, margin_top: PADDING) do
@news_container = flow(width: 1.0) do
end
# "dynamically" adjust news item widths
@@ -156,8 +156,7 @@ module W3DHubLauncher
end
def populate_game_info
# FIXME: We should be able to query an api to figure out the installation state, instead of explictly writing it out like this...
application = MemCache[:settings].applications.find { |app| app.id == @current_app.id }
application = MemCache[:settings].application_installed?(@current_app, @current_channel)
# pp application
@game_info_container.clear do
@@ -184,23 +183,53 @@ module W3DHubLauncher
populate_game_info
end
end
flow(width: 1.0, height: 60) do
if application
button "JOIN", tip: "Join most populated or lowest ping server", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME
button safe_get_image("#{ROOT_PATH}/media/icons/singleplayer.png"), tip: "Single player", image_height: 1.0, background_nine_slice: NINE_SLICE_SQUARE, **CTA_BUTTON_THEME
button safe_get_image("#{ROOT_PATH}/media/icons/gear.png"), tip: "Options", image_height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME
button "JOIN", tip: "Join most populated or lowest ping server", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME do |btn|
server = ApplicationHelper.play_now_server(application)
if server
ApplicationHelper.join_server(application, server)
else
puts "No server available."
end
end
button safe_get_image("#{ROOT_PATH}/media/icons/singleplayer.png"), tip: "Single player", image_height: 1.0, background_nine_slice: NINE_SLICE_SQUARE, **CTA_BUTTON_THEME do
ApplicationHelper.run(application)
end
button safe_get_image("#{ROOT_PATH}/media/icons/gear.png"), tip: "Options", image_height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME do |btn|
menu(parent: btn) do
menu_item("v#{application.version}", enabled: false, disabled: { color: 0xdd_ffffff }, tip: "Installed application version")
stack(width: 1.0, height: 2, background: 0xff_bbbbbb)
menu_item("Installation Folder", tip: "Open application installation folder:\n#{application.installation_path}")
menu_item("Screenshots Folder", tip: "Open application installation folder:\n#{Dir.home}/Documents/W3D Hub/#{application.id}-#{application.channel}/Screenshots")
stack(width: 1.0, height: 2, background: 0xff_bbbbbb)
menu_item("Application Settings", tip: "Edit application launch options")
menu_item("Repair Installation", tip: "Attempt to fix and repair installation")
menu_item("Check for Updates", tip: "Manually check for application updates")
stack(width: 1.0, height: 2, background: 0xff_bbbbbb)
menu_item("Move Installation", tip: "Move application installation to a different directory or disk")
menu_item("Unlink Installation", tip: "The application will be removed from the launcher\nbut the application's files will not be touched")
menu_item("Uninstall", tip: "Uninstall application and delete files")
end.show
end
elsif @current_app.servicable?
# pp @current_app
button "Import", enabled: false, tip: "Import existing application installation", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME
button "Download", tip: "Download and install application", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME do
Worker::Api.install_application(@current_app.id, @current_channel.id)
button "Import", tip: "Import existing application installation", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME do |btn|
dialog(Dialog::ImportApplication, application: @current_app, channel: @current_channel)
end
button "Download", tip: "Download and install application", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME do |btn|
btn.enabled = false
Worker::Api.install_application(@current_app.id, @current_channel.id) do |status, result|
handle_task_update(status, result)
end
end
else
button "Import", enabled: false, tip: "Import existing application installation", fill: true, height: 1.0, **CTA_BUTTON_THEME
end
end
# FIXME: the reported version should be the _installed_ version, not the current channel version
inscription "Version: #{@current_channel.version}", margin_top: PADDING, tip: "Installed version" if application
end
end
@@ -226,8 +255,8 @@ module W3DHubLauncher
@news_container.clear do
app_news.each do |item|
stack(width: 1.0 / news_item_width_ratio, height: 345, aspect_ratio: 1, margin_left: HALF_PADDING, margin_right: HALF_PADDING, margin_bottom: PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
stack(width: 1.0, height: 1.0 / 3, padding: PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
stack(width: 1.0 / news_item_width_ratio, height: 345, aspect_ratio: 1, margin_left: PADDING, margin_bottom: PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
stack(width: 1.0, height: 1.0 / 2.5, padding: PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
para item.timestamp.strftime("%B %e, %Y") #"September 29, 2026"
tagline item.title
end
@@ -251,6 +280,39 @@ module W3DHubLauncher
def news_item_width_ratio
(@news_container.width / 400.0).round.clamp(1..10)
end
def handle_task_update(result, status)
pp [result, status]
status_bar = current_state.application_task_status_bar_container
status_bar_title = find_element_by_tag(status_bar, :status_bar_title)
status_bar_label = find_element_by_tag(status_bar, :status_bar_label)
status_bar_progress = find_element_by_tag(status_bar, :status_bar_progress)
data = result.data
case status
when Worker::Request::STATUS_COMPLETE
status_bar.hide
MemCache[:settings].applications << Worker::Api::Settings::Application.new(data["application"])
Worker::Api.update_settings(MemCache[:settings])
populate_game_info
when Worker::Request::STATUS_IN_PROGRESS
status_bar.show
status_bar_title.value = data["status"]["title"]
status_bar_label.value = data["status"]["label"]
status_bar_progress.value = data["status"]["fraction"]
when Worker::Request::STATUS_ERROR
status_bar.hide
populate_game_info
# FIXME: Present error to player
end
end
end
end
end

View File

@@ -16,15 +16,16 @@ module W3DHubLauncher
w.subscribe(:clicked_left_mouse_button) do
@games_filter.clear
@app_filters_container.children.each { |c| c.enabled = true }
populate_server_list
end
end
flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do
@app_filters_container = flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do
MemCache[:applications].each do |app|
next unless app.game?
button(safe_get_image("#{CACHE_PATH}/icon_#{app.id}.png"), style_class: [:app_icon_button], tip: app.name) do |btn|
button(safe_get_image("#{CACHE_PATH}/icon_#{app.id}.png"), style_class: [:app_icon_button], tag: :"app_filter_#{app.id}", tip: app.name) do |btn|
if shift_down?
@games_filter << app.id
@games_filter.uniq!
@@ -33,6 +34,10 @@ module W3DHubLauncher
@games_filter << app.id
end
buttons = btn.parent.children
filters = @games_filter.map { |f| :"app_filter_#{f}" }
buttons.each { |e| e.enabled = !filters.include?(e.style.tag) }
btn.enabled = false
populate_server_list
@@ -70,7 +75,7 @@ module W3DHubLauncher
(MemCache[:servers] || []).select { |s| @games_filter.empty? ? true : @games_filter.include?(s.game) }.sort_by { |s| [s.player_count, -s.ping] }.reverse.each do |server|
app = MemCache[:applications].find { |a| a.id == server.game }
widget(width: 1.0, height: 56 + PADDING, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, margin_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: server.channel == "release" ? ALPHA_GRAY : 0xaa_c64600, hover: { background_nine_slice_color: 0xff_5e5c64 } , active: { background_nine_slice_color: 0xaa_5e5c64 }) do
widget(width: 1.0, height: 56 + PADDING, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, margin_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: server.channel == "release" ? ALPHA_GRAY : 0xaa_c64600, hover: { background_nine_slice_color: 0xff_5e5c64 } , active: { background_nine_slice_color: 0xaa_5e5c64 }) do |w|
# app icon container
image(safe_get_image("#{CACHE_PATH}/icon_#{app.id}.png"), tip: app.name, width: 56, height: 1.0, margin_left: HALF_PADDING)
@@ -108,6 +113,10 @@ module W3DHubLauncher
end
stack(width: 8, height: server.ping_score_ratio, v_align: :center, min_height: 8, background_nine_slice: NINE_SLICE_ROUNDED_SMALL, background_nine_slice_from_edge: NINE_SLICE_EDGE_SMALL, background_nine_slice_color: server.ping_score_color)
end
w.subscribe(:clicked_left_mouse_button) do
@parent.populate_server_information(server)
end
end
end
end

View File

@@ -1,6 +1,8 @@
module W3DHubLauncher
class States
class Interface < W3DHubLauncher::State
attr_reader :application_task_status_bar_container
def setup
super
@@ -30,9 +32,9 @@ module W3DHubLauncher
# stack(fill: true)
# navigation container
flow(width: 1.0) do
link("GAMES", text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::Games) }
link("SERVERS", text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::ServerBrowser) }
flow(width: 1.0, fill: true) do
link("GAMES",height: 1.0, text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::Games) }
link("SERVERS",height: 1.0, text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::ServerBrowser) }
stack(fill: true)
image safe_get_image("#{ROOT_PATH}/media/icons/import.png"), height: 40, color: 0xff_bbbbbb, tip: "Downloads" do |img|
dialog(Dialog::Downloads)
@@ -40,31 +42,31 @@ module W3DHubLauncher
image safe_get_image("#{ROOT_PATH}/media/icons/information.png"), height: 40, color: 0xff_bbbbbb, tip: "Notifications"
end
# application task status bar container
stack(width: 1.0, fill: true, margin_left: PADDING) do
@application_task_status_bar_container = stack(width: 1.0, fill: true, margin_left: PADDING, visible: false) do
flow(width: 1.0) do
para "Updating Red Alert: A Path Beyond (Release)"
para "Updating Red Alert: A Path Beyond (Release)", tag: :status_bar_title
stack(fill: true)
para "Fetching manifests..."
para "Fetching manifests...", tag: :status_bar_label
end
progress(width: 1.0, fraction: 0.75)
progress(width: 1.0, fraction: 0.0, tag: :status_bar_progress)
end
end
# self account container
flow(width: 400, height: 80, margin_left: LARGE_PADDING) do
@account_container = flow(width: 400, height: 80, margin_left: LARGE_PADDING) do
# self avatar container
stack(width: 80, height: 1.0, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png"))) do |i|
stack(width: 80, height: 1.0, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png")), tag: :account_avatar) do |i|
i.subscribe(:clicked_left_mouse_button) do
dialog(Dialog::Account)
end
# self online state container
stack(width: 20, height: 20, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269)
stack(width: 20, height: 20, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269, tag: :account_online_state)
end
stack(fill: true, height: 1.0, margin_left: HALF_PADDING) do
flow(fill: true)
# self name
caption "cyberarm", font: FONT_BLACK, text_wrap: :none
caption "cyberarm", font: FONT_BLACK, text_wrap: :none, width: 1.0, tag: :account_name
# self set online state
link "Online ▼", text_size: 18 do |l|
menu(parent: l) do
@@ -88,136 +90,6 @@ module W3DHubLauncher
# server details container
@server_details_container = stack(width: 400, height: 1.0, margin_left: LARGE_PADDING, padding: PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
tagline "SERVER NAME HERE SERVER NAME HERE SERVER NAME HERE", text_wrap: :none
image safe_get_image("#{ROOT_PATH}/media/default_map_preview.png"), width: 1.0, tip: "MAP NAME APPEARS HERE", margin_bottom: PADDING
flow(width: 1.0) do
stack(fill: true)
button "JOIN SERVER", **CTA_BUTTON_THEME, width: 1.0
stack(fill: true)
end
stack(width: 1.0, fill: true, scroll: true, margin_top: PADDING) do
# misc. server details
stack(width: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
flow(width: 1.0) do
stack(width: 1.0 / 3) do
caption "Map", text_wrap: :none
inscription "TheBacon.mix", text_wrap: :none
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Ping", text_wrap: :none
inscription "9945ms", text_wrap: :none
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Players", text_wrap: :none
inscription "127/127", text_wrap: :none
end
end
flow(width: 1.0) do
stack(width: 1.0 / 3) do
caption "Next Map", text_wrap: :none
inscription "Bacon.mix", text_wrap: :none
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Time", text_wrap: :none
inscription "00:00:00", text_wrap: :none
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Time Left", text_wrap: :none
inscription "00:00:00", text_wrap: :none
end
end
flow(width: 1.0) do
stack(width: 1.0 / 3) do
caption "Region", text_wrap: :none
inscription "North America", text_wrap: :none
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Channel", text_wrap: :none
inscription "Release", text_wrap: :none
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Version", text_wrap: :none
inscription "3.8.1.0", text_wrap: :none
end
end
end
# team data
2.times do |i|
stack(width: 1.0, margin_top: PADDING, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
tagline "TEAM #{i}", text_wrap: :none
flow(width: 1.0) do
stack(width: 1 / 4.0) do
caption "Score", text_wrap: :none
inscription "450,000", text_wrap: :none
end
stack(width: 1 / 4.0) do
caption "Kills", text_wrap: :none
inscription "4,500", text_wrap: :none
end
stack(width: 1 / 4.0) do
caption "Deaths", text_wrap: :none
inscription "450", text_wrap: :none
end
stack(width: 1 / 4.0) do
caption "Players", text_wrap: :none
inscription "45/127", text_wrap: :none
end
end
flow(width: 1.0, height: 2, background: 0xaa_000000)
flow(width: 1.0) do
stack(fill: true) do
caption "Name", text_wrap: :none
end
stack(width: 1 / 7.0) do
caption "Score", text_wrap: :none
end
stack(width: 1 / 8.0) do
caption "Kills", text_wrap: :none
end
stack(width: 1 / 6.0) do
caption "Deaths", text_wrap: :none
end
stack(width: 1 / 8.0) do
caption "Ping", text_wrap: :none
end
stack(width: 1 / 6.0) do
caption "Time", text_wrap: :none
end
end
24.times do |i|
flow(width: 1.0, background: i.odd? ? 0 : 0xaa_000000) do
stack(fill: true, margin_right: HALF_PADDING) do
inscription ["[Dragon]rufeng", "PXD2000", "SteelGhost", "Winter_Spyder", "ChopBam", "cyberarm", "ChAoS", "Silverlight", "moonsense715test", "Name Goes Here Yall"].sample, text_wrap: :none
end
stack(width: 1 / 7.0) do
inscription "5,232", text_wrap: :none
end
stack(width: 1 / 8.0) do
inscription "341", text_wrap: :none
end
stack(width: 1 / 6.0) do
inscription "123", text_wrap: :none
end
stack(width: 1 / 8.0) do
inscription "431", text_wrap: :none
end
stack(width: 1 / 6.0) do
inscription "00:00:00", text_wrap: :none
end
end
end
end
end
end
end
# battleview/friends container
@@ -284,6 +156,205 @@ module W3DHubLauncher
@server_details_container.show
end
def populate_server_information(server)
return unless @server_details_container.visible?
# FIXME: check server channel against application channel's `server_channel`
application = MemCache[:settings].applications.find { |app| app.id == server.game && app.channel == server.channel }
# channel = MemCache[:applications].find { |appl| appl.id == application&.id }&.channels&.find { |c| c.id == application&.channel }
@server_details_container.clear do
tagline server.name, width: 1.0, text_wrap: :none, tip: server.name
map_image_container = stack(width: 1.0, height: (@server_details_container.content_width / 640.0) * 360, background_image: safe_get_image(server.map_preview_image_path, fallback_path: "#{ROOT_PATH}/media/default_map_preview.png")) do
caption(
server.current_map,
text_border: false,
text_border_size: 1,
text_border_color: Gosu::Color::RED,
text_shadow: false,
width: 1.0,
height: 1.0,
padding_bottom: PADDING,
text_align: :center,
text_v_align: :bottom,
tip: server.current_map
)
end
unless File.exist?(server.map_preview_image_path)
Worker::Api.server_map_image(server) do |result, status|
# If the requested image is still the needed image?
next unless @server_details_container.children.include?(map_image_container)
next unless result.okay?
post_process_map_preview_image(result.data["image_path"])
map_image_container.background_image = safe_get_image(result.data["image_path"])
end
end
flow(width: 1.0, margin_top: PADDING) do
button "JOIN SERVER", **CTA_BUTTON_THEME, width: 1.0, enabled: !application.nil?, tip: application.nil? ? "Application not installed" : "" do
ApplicationHelper.join_server(application, server)
end
end
stack(width: 1.0, fill: true, scroll: true, margin_top: PADDING) do
# misc. server details
stack(width: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
flow(width: 1.0) do
stack(width: 1.0 / 3) do
caption "Map", text_wrap: :none, tip: "Map", width: 1.0
inscription server.current_map, text_wrap: :none, width: 1.0, tip: server.current_map
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
display_ping = server.ping == Worker::Api::GameServer::BAD_OR_UNKNOWN_PING ? "?" : format("%dms", server.ping)
caption "Ping", text_wrap: :none, tip: "Ping", width: 1.0
inscription display_ping, text_wrap: :none, width: 1.0, tip: display_ping
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Players", text_wrap: :none, tip: "Players", width: 1.0
inscription format("%d/%d", server.player_count, server.max_players), text_wrap: :none, width: 1.0, tip: format("%d/%d", server.player_count, server.max_players)
end
end
flow(width: 1.0) do
stack(width: 1.0 / 3) do
caption "Next Map", text_wrap: :none, tip: "Next Map", width: 1.0
inscription server.next_map, text_wrap: :none, width: 1.0, tip: server.next_map
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Time", text_wrap: :none, tip: "Time", width: 1.0
inscription "00:00:00", text_wrap: :none, width: 1.0, tip: "00:00:00"
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Time Left", text_wrap: :none, tip: "Time Left", width: 1.0
inscription server.match_remaining_time, text_wrap: :none, width: 1.0, tip: server.match_remaining_time
end
end
flow(width: 1.0) do
stack(width: 1.0 / 3) do
caption "Region", text_wrap: :none, tip: "Region", width: 1.0
inscription server.region, text_wrap: :none, width: 1.0, tip: server.region
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Channel", text_wrap: :none, tip: "Channel", width: 1.0
inscription server.channel, text_wrap: :none, width: 1.0, tip: server.channel
end
stack(width: 1.0 / 3, margin_left: HALF_PADDING) do
caption "Version", text_wrap: :none, tip: "Version", width: 1.0
inscription server.version, text_wrap: :none, width: 1.0, tip: server.version
end
end
end
# team data
server.teams.each do |team|
# ignore empty, non-standard teams
next if !team.id.between?(0, 1) && team.players.empty?
stack(width: 1.0, margin_top: PADDING, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
tagline team.name, text_wrap: :none, tip: team.name
flow(width: 1.0) do
stack(width: 1 / 4.0) do
caption "Score", text_wrap: :none, tip: "Score"
inscription team.score, text_wrap: :none, tip: team.score
end
stack(width: 1 / 4.0, margin_left: HALF_PADDING) do
caption "Kills", text_wrap: :none, tip: "Kills"
inscription team.kills, text_wrap: :none, tip: team.kills
end
stack(width: 1 / 4.0, margin_left: HALF_PADDING) do
caption "Deaths", text_wrap: :none, tip: "Deaths"
inscription team.deaths, text_wrap: :none, tip: team.deaths
end
stack(width: 1 / 4.0, margin_left: HALF_PADDING) do
caption "Players", text_wrap: :none, tip: "Players"
inscription format("%d/%d", team.players.size, server.max_players), text_wrap: :none, tip: format("%d/%d", team.players.size, server.max_players)
end
end
next if team.players.empty?
flow(width: 1.0, height: 2, background: 0xaa_000000)
flow(width: 1.0) do
stack(fill: true, margin_right: HALF_PADDING) do
inscription "Name", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Name"
end
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
inscription "Score", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Score"
end
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
inscription "Kills", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Kills"
end
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
inscription "Deaths", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Deaths"
end
# stack(width: 1 / 8.0, margin_right: HALF_PADDING) do
# inscription "Ping", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Ping"
# end
# stack(width: 1 / 6.0) do
# inscription "Time", width: 1.0, text_wrap: :none, font: FONT_BOLD, tip: "Time"
# end
end
team.players.sort_by(&:score).reverse.each_with_index do |player, i|
flow(width: 1.0, background: i.odd? ? 0 : 0xaa_000000) do
stack(fill: true, margin_right: HALF_PADDING) do
inscription player.name, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.name
end
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
inscription player.score, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.score
end
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
inscription player.kills, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.kills
end
stack(width: 1 / 5.0, margin_right: HALF_PADDING) do
inscription player.deaths, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.deaths
end
# stack(width: 1 / 8.0, margin_right: HALF_PADDING) do
# inscription player.ping, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.ping
# end
# stack(width: 1 / 5.0) do
# inscription player.time, font: FONT_MONO, width: 1.0, text_wrap: :none, tip: player.time
# end
end
end
end
end
end
end
end
# Get them rounded corners!
def post_process_map_preview_image(path)
image = Gosu::Image.new(path)
target_width = 640
target_height= 360
nine_slice = CyberarmEngine::BackgroundNineSlice.new(
image_path: NINE_SLICE_ROUNDED,
mode: :stretch,
width: target_width,
height: target_height,
left: NINE_SLICE_EDGE,
right: NINE_SLICE_EDGE,
top: NINE_SLICE_EDGE,
bottom: NINE_SLICE_EDGE,
render_mode: :multiply
)
composite = Gosu.render(target_width, target_height) do
image_scale = [target_width / image.width.to_f, target_height / image.height.to_f].max
image.draw_rot(target_width / 2, target_height / 2, 0, 0, 0.5, 0.5, image_scale, image_scale)
nine_slice.draw
end
composite.save(path)
end
def button_up(id)
super

View File

@@ -8,7 +8,7 @@ module W3DHubLauncher
Response = Data.define(:status, :request_id, :result)
attr_reader :w3dhub_api
attr_reader :w3dhub_api, :settings
def initialize
end
@@ -35,7 +35,7 @@ module W3DHubLauncher
@client = nil
@settings = 0# Settings.new
@settings = nil
@game_servers = []
@host_pings = {}
@@ -140,6 +140,15 @@ module W3DHubLauncher
@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]
@@ -292,6 +301,66 @@ module W3DHubLauncher
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
@@ -325,6 +394,7 @@ module W3DHubLauncher
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.")

View File

@@ -16,6 +16,10 @@ module W3DHubLauncher
Worker::Request.new(:ico_to_png, { ico_path: ico_path, png_path: png_path }, &block)
end
def self.server_map_image(server, &block)
Worker::Request.new(:server_map_image, { server: { address: server.address, port: server.port, map: server.current_map } }, &block)
end
def self.dns_resolution(&block)
Worker::Request.new(:dns_resolution, "", &block)
end

View File

@@ -68,7 +68,7 @@ module W3DHubLauncher
def extended_data(key, default)
v = @extended_data&.find { |d| d.name == key }&.value
if v.nil?
v = @_app.extended_data(key, value)
v = @_app.extended_data(key, default)
return default if v.nil?
end

View File

@@ -4,6 +4,7 @@ module W3DHubLauncher
# Designed to work with cyberarm's Game Server Hub service data, may work with W3D Hub's service with degraded metadata.
class GameServer
BAD_OR_UNKNOWN_PING = 1_000_000
NO_OR_DEFAULT_VERSION = "838".freeze
attr_reader :id, :game, :channel, :address, :port, :region,
:name, :password, :current_map, :player_count, :max_players,
@@ -38,15 +39,17 @@ module W3DHubLauncher
# status player data
data["status"]["players"].each do |player_data|
team = @teams.find { |t| t.id == player_data["id"] }
team = @teams.find { |t| t.id == player_data["team"] }
next unless team
team.players << Player.new(player_data)
end
# Cyberarm GSH extras
@version = data["version"] || "838"
@version = data["version"] || NO_OR_DEFAULT_VERSION
@next_map = data["status"]["nextmap"] || ""
@version = Gem::Version.new(@version) if @version.split(".").size == 4
end
# TODO: implement :)
@@ -86,6 +89,10 @@ module W3DHubLauncher
0xff_3d3846
end
end
def map_preview_image_path
format("%s/map_preview_%s.png", W3DHubLauncher::CACHE_PATH, Digest::SHA256.hexdigest("#{@game}_#{@channel}_#{@current_map}"))
end
end
class Team

View File

@@ -10,8 +10,8 @@ module W3DHubLauncher
@game = root["game"]
@type = root["type"]
@version = root["version"]
@base_version = root["baseVersion"]
@version = root["version"] ? Gem::Version.new(root["version"]) : nil
@base_version = root["baseVersion"] ? Gem::Version.new(root["baseVersion"]) : nil
@files = []
@dependencies = []
@@ -49,7 +49,7 @@ module W3DHubLauncher
@name = @data["name"]
@checksum = @data["checksum"]
@package = @data["package"]
@removed_since = @data["removedsince"]
@removed_since = @data["removedsince"] ? Gem::Version.new(@data["removedsince"]) : nil
xml.elements.each("Patch") do |patch|
@patch = true
@@ -63,7 +63,7 @@ module W3DHubLauncher
end
end
@version = version
@version = version ? Gem::Version.new(version) : nil
end
def removed?

View File

@@ -1,12 +1,12 @@
require "json"
require "digest/sha2"
module W3DHubBackend
module W3DHubLauncher
class Worker
class Api
class LegacyManifestPackage
attr_reader :category, :subcategory, :version, :name, :error, :file_size,
:checksum_chunk_size, :sha256_checksum, :checksum_chunks
:checksum_chunk_size, :sha256_checksum, :checksum_chunks, :download_url
def initialize(hash)
@category = hash["category"]
@@ -15,43 +15,48 @@ module W3DHubBackend
@name = hash["name"]
@error = hash["error"]
@file_size = Integer(hash["size"] || 0)
@checksum_chunk_size = Integer(hash["checksum-size"] || 0)
@checksum_chunk_size = Integer(hash["checksum-chunk-size"] || 0)
@sha256_checksum = hash["checksum"]
@checksum_chunks = hash["checksum-chunks"]
@download_url = hash["download_url"]
end
def error?
@error
end
def version?
@version != Gem::Version.new("0.0.0.0")
end
# checksum whole file and file chunks until a mismatch occurs or the whole file is verified.
def verify_file(filename)
return false unless File.exist?(filename) && !File.directory?(filename)
file_size = File.size(filename)
checksum_chunks = {}
overall_checksum = ""
overall_digest = Digest::SHA256.new
chunk_digest = Digest::SHA256.new
File.open(filename, "rb") do |f|
f.pos = 0
offset = 0
last_valid_offset = 0
while (chunk = f.read(@checksum_chunk_size))
overall_digest << chunk
checksum_chunks[offset] = chunk_digest.update(chunk).hexdigest.upcase
# return last valid chunk on invalid chunk
return last_valid_offset unless @checksum_chunks[offset.to_s] == chunk_digest.update(chunk).hexdigest.upcase
last_valid_offset = offset
offset += @checksum_chunk_size
chunk_digest.reset
end
end
overall_checksum = overall_digest.hexdigest.upcase
# FIXME: Make this a nice Data struct object
[overall_checksum, checksum_chunks, file_size]
# return boolean after completely digesting file
@sha256_checksum == overall_digest.hexdigest.upcase
end
end
end

View File

@@ -34,6 +34,13 @@ module W3DHubLauncher
}.to_json(options)
end
def application_installed?(application, channel)
app_id = application.is_a?(String) ? application : application.id
channel_id = channel.is_a?(String) ? channel : channel.id
applications.find { |app| app.id == app_id && app.channel == channel_id }
end
# User explictly set options
class Preferences
SCHEMA = 0
@@ -74,17 +81,18 @@ module W3DHubLauncher
SCHEMA = 0
attr_reader :id, :channel
attr_accessor :version, :installation_path, :wine_prefix_path, :launch_command
attr_accessor :version, :installation_path, :wine_prefix_path, :launch_command, :timestamp
def self.create(id:, channel:, version:, installation_path:, wine_prefix_path:, launch_command:)
def self.create(id:, channel:, version:, installation_path:, wine_prefix_path:, launch_command:, timestamp:)
hash = {
"schema" => SCHEMA,
"id" => id,
"channel" => channel,
"version" => Gem::Version.new(version),
"version" => version,
"installation_path" => installation_path,
"wine_prefix_path" => wine_prefix_path,
"launch_command" => launch_command,
"timestamp" => timestamp
}
self.new(hash)
@@ -99,6 +107,7 @@ module W3DHubLauncher
@installation_path = data["installation_path"]
@wine_prefix_path = data["wine_prefix_path"]
@launch_command = data["launch_command"]
@timestamp = Time.at(data["timestamp"])
end
def to_json(options = {})
@@ -109,7 +118,8 @@ module W3DHubLauncher
version: @version.to_s,
installation_path: @installation_path,
wine_prefix_path: @wine_prefix_path,
launch_command: @launch_command
launch_command: @launch_command,
timestamp: @timestamp.to_i
}.to_json(options)
end
end

View File

@@ -3,12 +3,6 @@ module W3DHubLauncher
class Request
Query = Data.define(:type, :request_id, :data)
FETCH_URL = 0
DOWNLOAD_URL = 1
W3DHUB_API_CALL = 10
LAUNCHER_SETTINGS = 1000
LAUNCHER_UPDATE_SETTINGS = 1001
STATUS_ERROR = -1 # request has failed
STATUS_PENDING = 0 # request has not yet started
STATUS_OK = 1 # request completed successfully
@@ -54,9 +48,10 @@ module W3DHubLauncher
def handle_event(event, data)
# pp [event, data]
@callback&.call(data, event)
case event
when STATUS_ERROR, STATUS_COMPLETE
@callback&.call(data)
Request.requests.delete(self)
end
end

View File

@@ -1,5 +1,10 @@
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:)
@@ -11,6 +16,19 @@ module W3DHubLauncher
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
@@ -18,67 +36,526 @@ module W3DHubLauncher
end
def start(worker)
@worker = worker
@worker = worker
@installation_directory ||= application_target_installation_directory
update_status(label: "Starting...", fraction: 0.0)
execute
end
def abort_task!(reason = "")
puts reason
message_requester(Worker::Request::STATUS_ERROR, data: nil, error: 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)
result = fetch_manifest(version)
if result.okay?
pp result
update_status(label: "Fetching manifests...", fraction: 0.0)
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
update_status(label: "Building package list...", fraction: 0.0)
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
update_status(label: "Verifying files...", fraction: 0.0)
@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
update_status(label: "Downloading packages...", fraction: 0.0)
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
update_status(label: "Installing...", fraction: 0.0)
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
update_status(label: "Removing files...", fraction: 0.0)
@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
app = Worker::Api::Settings::Application.create(
id: @application.id,
channel: @channel.id,
version: @target_version.to_s,
installation_path: @installation_directory,
wine_prefix_path: "",
launch_command: "%COMMAND%",
timestamp: Time.now.to_i
)
message_requester(Worker::Request::STATUS_COMPLETE, data: { application: app })
puts "APPLICATION: #{@application.name} #{@application.id}:#{@channel.id}:#{@target_version} installed."
end
def mark_application_uninstalled
message_requester(Worker::Request::STATUS_COMPLETE, data: {})
end
# helper functions
def message_requester(status = Worker::Request::STATUS_IN_PROGRESS, data:, error: nil)
@worker.message_requester(request_id: @request_id, status: status, data: data, error: error)
end
def update_status(label:, fraction:)
message_requester(data: { status: { title: "TASK #{@application.name} (#{@channel.name})", label: label, fraction: fraction } } )
end
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
pp [:error, result]
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)
@worker.w3dhub_api.fetch_package_details([{category: "games", subcategory: @application.id, name: "manifest.xml", version: 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
# def execute_task
# show_application_taskbar
fail_fast
return false if failed?
# fail_fast
# return false if failed?
fetch_manifests
return false if failed?
# fetch_manifests
# return false if failed?
build_package_list
return false if failed?
# build_package_list
# return false if failed?
remove_deleted_files
return false if failed?
# remove_deleted_files
# return false if failed?
verify_files
return false if failed?
# verify_files
# return false if failed?
fetch_packages
return false if failed?
# fetch_packages
# return false if failed?
verify_packages
return false if failed?
# verify_packages
# return false if failed?
unpack_packages
return false if failed?
# unpack_packages
# return false if failed?
create_wine_prefix
return false if failed?
# create_wine_prefix
# return false if failed?
install_dependencies
return false if failed?
# install_dependencies
# return false if failed?
write_paths_ini
return false if failed?
# write_paths_ini
# return false if failed?
mark_application_installed
return false if failed?
# mark_application_installed
# return false if failed?
sleep 1
hide_application_taskbar
# sleep 1
# hide_application_taskbar
true
end
# true
# end

View File

@@ -8,18 +8,34 @@ module W3DHubLauncher
# TODO:
# fail_fast
# get case-insensitive file list
index_installation_directory
# fetch manifests for currently installed version
# AND the target version so we can detect files that need to be removed
# between versions
manifests_result = fetch_manifests
abort_task!(manifests_result) unless manifests_result.okay?
fetch_manifests
package_list_result = build_package_list(manifests_result)
abort_task!(package_list_result) unless package_list_result.okay?
# remove_deleted_files
# build list of packages we'll need to download
build_package_list
# verify our pre-existing files for presence and validity
verify_files
# download required packages to get missing or out of date files
fetch_packages
# unpack packages and patch files to reach target version
install_packages
# delete files that are no longer part of the application (and whose presence may break the application)
remove_deleted_files
# tell the application to behive
write_paths_ini
# store application install state and notify frontend/ui that the application is ready for use!
mark_application_installed
end
end
end

View File

@@ -16,7 +16,7 @@ module W3DHubLauncher
@http_clients = {}
end
def headers(form_encoded: false)
def headers(form_encoded: false, range: nil)
array = [
["user-agent", W3DHubLauncher::USER_AGENT],
["accept", "application/json"],
@@ -24,6 +24,7 @@ module W3DHubLauncher
array << ["content-type", "application/x-www-form-urlencoded"] if form_encoded
array << ["authorization", "Bearer #{@access_token}"] if @access_token
array << ["range", "bytes=#{range}-"] if range
# pp array
@@ -61,27 +62,28 @@ module W3DHubLauncher
result = CyberarmEngine::Result.new
Sync do |task|
task.with_timeout(API_TIMEOUT) do
Async::HTTP::Internet.send(method, url, headers, body) do |response|
if response.success?
content_length = response.headers["content-length"] || 0
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
total_downloaded_bytes = 0
range = Integer(headers.find { |key, value| key == "range" }&.last&.split("=")&.last&.split("-")&.first || 0)
File.open(path, range.positive? ? "r+b" : "wb") do |file|
file.pos = range if range.positive?
block&.call(downloaded_bytes, total_downloaded_bytes, content_length)
end
response.each do |chunk|
file.write(chunk)
downloaded_bytes = chunk.length
total_downloaded_bytes += downloaded_bytes
block&.call(downloaded_bytes, total_downloaded_bytes, content_length)
end
result.data = true
end
rescue StandardError => e
result.error = e
result.data = true
end
rescue StandardError => e
result.error = e
rescue Async::TimeoutError
result.error = e
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -11,6 +11,7 @@ require "logger"
require "digest"
require "pathname"
require "fileutils"
require "tempfile"
require "async"
require "async/http/internet/instance"
@@ -39,11 +40,14 @@ require_relative "lib/dialogs/about"
require_relative "lib/dialogs/downloads"
require_relative "lib/dialogs/server"
require_relative "lib/dialogs/account"
require_relative "lib/dialogs/import_application"
require_relative "lib/states/boot"
require_relative "lib/states/interface"
require_relative "lib/window"
require_relative "lib/application_helper"
require_relative "lib/ico"
require_relative "lib/dds"
require_relative "lib/ww_mix"
require_relative "lib/worker"