mirror of
https://github.com/cyberarm/w3d_hub_linux_launcher.git
synced 2026-09-19 14:53:49 +00:00
Compare commits
61 Commits
6da57fef70
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e7a72ceb | |||
| 0a1909a84c | |||
| b7739de136 | |||
| 17614f4416 | |||
| f8ceb69402 | |||
| 6707f260c9 | |||
| 56699bb644 | |||
| 09ef9d613f | |||
| b505f22b8c | |||
| b551312751 | |||
| 1d3164da72 | |||
| 73a9a80a34 | |||
| 7adb23d71f | |||
| 4ba71b760a | |||
| db0dd9fc7c | |||
| 2cf8146553 | |||
| af438e35bc | |||
| 2f07cde109 | |||
| 5092c828a7 | |||
| 21e0888f8e | |||
| 132d39d3a4 | |||
| 7a5667a53a | |||
| 79dfa963c2 | |||
| b0fefb9f7f | |||
| 0693733179 | |||
| b52f6e2183 | |||
| 915e643a98 | |||
| 6823f9bade | |||
| 8c14ecffc1 | |||
| a7ae460446 | |||
| 969a82a262 | |||
| efc9c2f150 | |||
| 9ad43f61b6 | |||
| ccea626852 | |||
| b589a3dc1b | |||
| 8333c7b00d | |||
| 7f716a57ca | |||
| 7524d01347 | |||
| a4dcd302c1 | |||
| 8dd9b44cb0 | |||
| 60672ef9c3 | |||
| aeaf0b377b | |||
| ad0b9dbe7b | |||
| 1f33e125f6 | |||
| ba055897bc | |||
| baec1e1e18 | |||
| 59d884aad1 | |||
| d77408484a | |||
| c6ee9eab31 | |||
| 02cbd215f5 | |||
| 675ab3b1a8 | |||
| 0eb188f72e | |||
| c8bb271d82 | |||
| f1d6bbe390 | |||
| 56bb6a59e2 | |||
| 9d0a99de6e | |||
| e97e5cc862 | |||
| dc9e737587 | |||
| 30776857a2 | |||
| 43f8af42ec | |||
| 33de050cd2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
pkg/
|
||||
*.json
|
||||
*.xml
|
||||
data/cache/*
|
||||
!data/cache/.gitkeep
|
||||
data/logs/*
|
||||
|
||||
20
Gemfile
20
Gemfile
@@ -1,3 +1,23 @@
|
||||
source "https://gem.coop"
|
||||
|
||||
# "standard lib" gems
|
||||
gem "base64"
|
||||
gem "rexml"
|
||||
gem "logger"
|
||||
gem "json"
|
||||
|
||||
# "game" library gem
|
||||
gem "cyberarm_engine"
|
||||
gem "sdl3-bindings"
|
||||
gem "gosu", '1.5.0' # temporary, manually built, stand-in for SDL3 supporting gosu
|
||||
|
||||
# networking libs
|
||||
gem "async"
|
||||
gem "async-http"
|
||||
gem "async-websocket"
|
||||
|
||||
# misc. libs
|
||||
gem "digest-crc"
|
||||
gem "ircparser"
|
||||
gem "rubyzip"
|
||||
gem "chunky_png"
|
||||
|
||||
115
Gemfile.lock
115
Gemfile.lock
@@ -1,20 +1,127 @@
|
||||
GEM
|
||||
remote: https://gem.coop/
|
||||
specs:
|
||||
async (2.46.0)
|
||||
console (~> 1.29)
|
||||
fiber-annotation
|
||||
io-event (~> 1.21)
|
||||
async-http (0.105.0)
|
||||
async (>= 2.35.1)
|
||||
async-pool (~> 0.12)
|
||||
io-endpoint (~> 0.18)
|
||||
io-stream (~> 0.14)
|
||||
protocol-http (~> 0.72)
|
||||
protocol-http1 (~> 0.41)
|
||||
protocol-http2 (~> 0.28)
|
||||
protocol-url (~> 0.2)
|
||||
async-pool (0.12.0)
|
||||
async (>= 2.0)
|
||||
async-websocket (0.30.1)
|
||||
async-http (~> 0.76)
|
||||
protocol-http (~> 0.34)
|
||||
protocol-rack (~> 0.7)
|
||||
protocol-websocket (~> 0.17)
|
||||
base64 (0.3.0)
|
||||
chunky_png (1.4.0)
|
||||
console (1.38.0)
|
||||
fiber-annotation
|
||||
fiber-local (~> 1.1)
|
||||
json
|
||||
cyberarm_engine (0.25.1)
|
||||
gosu (~> 1.1)
|
||||
gosu (1.4.6)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
fiber-annotation (0.2.0)
|
||||
fiber-local (1.1.0)
|
||||
fiber-storage
|
||||
fiber-storage (1.0.1)
|
||||
gosu (1.5.0-x86_64-linux)
|
||||
ffi (~> 1.12)
|
||||
io-endpoint (0.18.0)
|
||||
openssl (>= 3.3.0)
|
||||
io-event (1.22.0)
|
||||
io-stream (0.14.0)
|
||||
openssl (>= 3.3)
|
||||
ircparser (1.0.0)
|
||||
json (3.0.2)
|
||||
logger (1.7.0)
|
||||
openssl (4.0.2)
|
||||
protocol-hpack (1.5.1)
|
||||
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.23.0)
|
||||
console (~> 1.0)
|
||||
io-stream (>= 0.10)
|
||||
protocol-http (~> 0.58)
|
||||
rack (>= 1.0)
|
||||
protocol-url (0.19.0)
|
||||
protocol-websocket (0.21.2)
|
||||
protocol-http (~> 0.2)
|
||||
rack (3.2.7)
|
||||
rake (13.4.2)
|
||||
rexml (3.4.4)
|
||||
rubyzip (3.6.0)
|
||||
sdl3-bindings (1.0.4)
|
||||
ffi (~> 1.16)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
async
|
||||
async-http
|
||||
async-websocket
|
||||
base64
|
||||
chunky_png
|
||||
cyberarm_engine
|
||||
digest-crc
|
||||
gosu (= 1.5.0)
|
||||
ircparser
|
||||
json
|
||||
logger
|
||||
rexml
|
||||
rubyzip
|
||||
sdl3-bindings
|
||||
|
||||
CHECKSUMS
|
||||
cyberarm_engine (0.25.1)
|
||||
gosu (1.4.6)
|
||||
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.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
|
||||
fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03
|
||||
fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06
|
||||
fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1
|
||||
gosu (1.5.0-x86_64-linux)
|
||||
io-endpoint (0.18.0) sha256=720d186bcd584e8ba490bfc91dab332cc3c5ffee848b5b03894555cb275151aa
|
||||
io-event (1.22.0) sha256=4936d38dfcf2291d897707fd94690f1a63fdb1ea3ab4fa63c6739fc6c61e1534
|
||||
io-stream (0.14.0) sha256=07d9b13ea2066ccbbc761fff8d69ad5a31e47561d31e02cc271f3ed9c1a8f1cd
|
||||
ircparser (1.0.0) sha256=0d4598f691172e2ad078207c50ba62c0d3428ae841836b0a682e6cd01d62878d
|
||||
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.72.0) sha256=b153474785979495b9f2df78f0fdc2e02edc7e92c1f7ea00425eed73bd02194c
|
||||
protocol-http1 (0.41.0) sha256=9863874a1228948a896c3e7ea16d13a680c8f3ed50706007c78b7033018b0ea3
|
||||
protocol-http2 (0.28.0) sha256=b7c6195dcfa80ec2484670e9fd8fc21024cc654a391aa0792433aff1188c1691
|
||||
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
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695
|
||||
sdl3-bindings (1.0.4) sha256=326f497c30e6a5cab402e6178403b30c9df4a11a4e109e08c5da6e920c7bb55b
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.8
|
||||
|
||||
14
LICENSE.md
14
LICENSE.md
@@ -1,7 +1,15 @@
|
||||
Copyright (c) 2021 cyberarm
|
||||
Copyright (c) 2021-2026 cyberarm
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
68
lib/application_helper.rb
Normal file
68
lib/application_helper.rb
Normal 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
|
||||
30
lib/attribution.rb
Normal file
30
lib/attribution.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
module W3DHubLauncher
|
||||
module Attribution
|
||||
Item = Data.define(:name, :description, :url, :license, :license_url)
|
||||
|
||||
SPECIAL_THANKS = [
|
||||
Item.new("W3D Hub", "Creating awesome games and mods for free", "https://w3dhub.com", "", ""),
|
||||
Item.new("Yukihiro \"matz\" Matsumoto", "Creating the Ruby language", "https://matz.rubyist.net", "", ""),
|
||||
Item.new("Samuel \"ioquatix\" Williams", "Creating and maintaining the async libraries for Ruby", "https://www.codeotaku.com", "", ""),
|
||||
Item.new("Kenney", "Creating awesome game assets and releasing them completely for free\nThe launcher uses their UI Icon pack", "https://kenney.nl", "CC0", "https://creativecommons.org/publicdomain/zero/1.0/"),
|
||||
]
|
||||
|
||||
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("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"),
|
||||
Item.new("async-http", "Asynchronous http(s) client/server for Ruby", "https://github.com/socketry/async-http", "MIT", "https://github.com/socketry/async-http/blob/main/license.md"),
|
||||
Item.new("async-websocket", "Asynchronous websockets for Ruby", "https://github.com/socketry/async-websocket", "MIT", "https://github.com/socketry/async-websocket/blob/main/license.md"),
|
||||
Item.new("rubyzip", "Ruby library for reading and writing zip files", "https://github.com/rubyzip/rubyzip", "BSD 2-Clause", "https://github.com/rubyzip/rubyzip/blob/main/LICENSE.md"),
|
||||
Item.new("digest-crc", "Ruby library for reading and writing zip files", "https://github.com/postmodern/digest-crc", "MIT", "https://github.com/postmodern/digest-crc/blob/main/LICENSE.txt"),
|
||||
Item.new("ircparser", "Ruby parser for the IRCv3 message format", "https://codeberg.org/sadiepowell/ircparser-ruby", "MIT", "https://codeberg.org/sadiepowell/ircparser-ruby#license"),
|
||||
|
||||
Item.new("base64", "Ruby library for encoding and decoding base64", "https://github.com/ruby/base64", "BSD 2-Clause", "https://github.com/ruby/base64/blob/master/COPYING"),
|
||||
Item.new("rexml", "Ruby library for parsing XML", "https://github.com/ruby/rexml", "BSD 2-Clause", "https://github.com/ruby/rexml/blob/master/LICENSE.txt"),
|
||||
Item.new("logger", "Ruby logging library", "https://github.com/ruby/logger", "BSD 2-Clause", "https://github.com/ruby/logger/blob/master/COPYING"),
|
||||
]
|
||||
end
|
||||
end
|
||||
12
lib/constants.rb
Normal file
12
lib/constants.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module W3DHubLauncher
|
||||
DIRECTORY_NAME = "w3d-hub-linux-launcher".freeze
|
||||
|
||||
ROOT_PATH = Dir.pwd
|
||||
CONFIG_PATH = "#{Dir.home}/.config/#{DIRECTORY_NAME}".freeze
|
||||
CACHE_PATH = "#{Dir.home}/.cache/#{DIRECTORY_NAME}".freeze
|
||||
DEFAULT_PACKAGE_CACHE_PATH = "#{CACHE_PATH}/packages".freeze
|
||||
DEFAULT_APPLICATIONS_PATH = "#{Dir.home}/.local/share/#{DIRECTORY_NAME}/applications".freeze
|
||||
|
||||
IPC_PATH = "/tmp/w3dhub_linux_launcher.sock"
|
||||
USER_AGENT = "#{NAME} v#{VERSION}".freeze
|
||||
end
|
||||
280
lib/dds.rb
Normal file
280
lib/dds.rb
Normal 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
|
||||
9
lib/dialog.rb
Normal file
9
lib/dialog.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module W3DHubLauncher
|
||||
class Dialog < CyberarmEngine::Dialog
|
||||
def setup
|
||||
theme(THEME)
|
||||
|
||||
background TRANSLUCENT_GRAY
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,43 +1,55 @@
|
||||
module W3DHubLauncher
|
||||
module Dialog
|
||||
class About < CyberarmEngine::Dialog
|
||||
class Dialog
|
||||
class About < W3DHubLauncher::Dialog
|
||||
def setup
|
||||
# application name and version
|
||||
# authorship
|
||||
# special thanks
|
||||
# used gems and libraries
|
||||
# useful links
|
||||
theme(THEME)
|
||||
background 0xee_222222
|
||||
super
|
||||
|
||||
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: 0x88_000000) do
|
||||
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0xaa_000000) do
|
||||
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 "About", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, scroll: true, padding_left: PADDING, padding_right: PADDING) do
|
||||
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 NAME
|
||||
para "© 2026 cyberarm", margin_left: PADDING
|
||||
link "MIT licence", margin_left: PADDING
|
||||
tagline format("v%s (%s)", VERSION, VERSION_NAME), margin_left: PADDING
|
||||
para "© 2021 - #{Time.now.year} cyberarm", margin_left: PADDING
|
||||
link "MIT license", margin_left: PADDING
|
||||
|
||||
title "Thanks", margin_top: LARGE_PADDING
|
||||
10.times do
|
||||
tagline "NAME", margin_left: PADDING
|
||||
para "REASON", margin_left: LARGE_PADDING
|
||||
title "Special Thanks", margin_top: LARGE_PADDING
|
||||
W3DHubLauncher::Attribution::SPECIAL_THANKS.each do |item|
|
||||
present_item(item)
|
||||
end
|
||||
|
||||
title "Libraries", margin_top: LARGE_PADDING
|
||||
10.times do
|
||||
tagline "LIBRARY", margin_left: PADDING
|
||||
para "LEGAL TEXT", margin_left: LARGE_PADDING
|
||||
link "ABREV licence", margin_left: LARGE_PADDING
|
||||
title "Software / Libraries", margin_top: LARGE_PADDING
|
||||
W3DHubLauncher::Attribution::LIBRARIES.each do |item|
|
||||
present_item(item)
|
||||
end
|
||||
end
|
||||
|
||||
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_BOTTOM, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0xaa_000000) do
|
||||
button "Close" do
|
||||
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
|
||||
flow(fill: true)
|
||||
|
||||
button "Close", **CTA_BUTTON_THEME do
|
||||
pop_state
|
||||
end
|
||||
|
||||
flow(fill: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def present_item(item)
|
||||
unless item.url.empty?
|
||||
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
|
||||
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
|
||||
|
||||
25
lib/dialogs/account.rb
Normal file
25
lib/dialogs/account.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
module W3DHubLauncher
|
||||
class Dialog
|
||||
class Account < W3DHubLauncher::Dialog
|
||||
def setup
|
||||
super
|
||||
|
||||
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: ALPHA_BLACK) do
|
||||
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
banner "Account", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, scroll: true, padding_left: PADDING, padding_right: PADDING) do
|
||||
button "Sign In", tip: "https://sso.w3d.cyberarm.dev" do
|
||||
# use SSO service
|
||||
pop_state
|
||||
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: ALPHA_GRAY) do
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
38
lib/dialogs/downloads.rb
Normal file
38
lib/dialogs/downloads.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module W3DHubLauncher
|
||||
class Dialog
|
||||
class Downloads < W3DHubLauncher::Dialog
|
||||
def setup
|
||||
super
|
||||
|
||||
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: ALPHA_BLACK) do
|
||||
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
banner "Downloads", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, scroll: true, padding_left: PADDING, padding_right: PADDING) do
|
||||
50.times do
|
||||
stack(width: 1.0, margin_top: HALF_PADDING, margin_bottom: HALF_PADDING) do
|
||||
flow(width: 1.0) do
|
||||
para "PACKAGE_NAME.mix"
|
||||
flow(fill: true)
|
||||
para ["Downloading...", "Pending...", "Done.", "Patching...", "Unpacking..."].sample
|
||||
end
|
||||
progress(width: 1.0, fraction: rand)
|
||||
end
|
||||
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: ALPHA_GRAY) do
|
||||
flow(fill: true)
|
||||
|
||||
button "Close" do
|
||||
pop_state
|
||||
end
|
||||
|
||||
flow(fill: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
77
lib/dialogs/import_application.rb
Normal file
77
lib/dialogs/import_application.rb
Normal 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
|
||||
38
lib/dialogs/server.rb
Normal file
38
lib/dialogs/server.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module W3DHubLauncher
|
||||
class Dialog
|
||||
class Server < W3DHubLauncher::Dialog
|
||||
def setup
|
||||
super
|
||||
|
||||
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: ALPHA_BLACK) do
|
||||
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY) do
|
||||
banner "Downloads", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, scroll: true, padding_left: PADDING, padding_right: PADDING) do
|
||||
50.times do
|
||||
stack(width: 1.0, margin_top: HALF_PADDING, margin_bottom: HALF_PADDING) do
|
||||
flow(width: 1.0) do
|
||||
para "PACKAGE_NAME.mix"
|
||||
flow(fill: true)
|
||||
para ["Downloading...", "Pending...", "Done.", "Patching...", "Unpacking..."].sample
|
||||
end
|
||||
progress(width: 1.0, fraction: rand)
|
||||
end
|
||||
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: ALPHA_GRAY) do
|
||||
flow(fill: true)
|
||||
|
||||
button "Close" do
|
||||
pop_state
|
||||
end
|
||||
|
||||
flow(fill: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
36
lib/gosu_ext.rb
Normal file
36
lib/gosu_ext.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
module Gosu
|
||||
# ! nonblocking
|
||||
def self.choose_file(&block)
|
||||
callback_id = "gc_protect_#{Gosu.milliseconds}"
|
||||
|
||||
callback = lambda do |user_data, file_list, filter|
|
||||
data = nil
|
||||
|
||||
if file_list
|
||||
data = file_list.read_array_of_string
|
||||
end
|
||||
|
||||
block.call(data)
|
||||
|
||||
W3DHubLauncher::MemCache.delete(callback_id)
|
||||
end
|
||||
|
||||
W3DHubLauncher::MemCache[callback_id] = callback
|
||||
|
||||
SDL.ShowOpenFileDialog(
|
||||
callback,
|
||||
nil,
|
||||
CyberarmEngine::Window.instance.sdl_window,
|
||||
nil,
|
||||
0,
|
||||
W3DHubLauncher::CACHE_PATH,
|
||||
false
|
||||
)
|
||||
end
|
||||
|
||||
def self.choose_folder()
|
||||
end
|
||||
|
||||
def self.save_file()
|
||||
end
|
||||
end
|
||||
@@ -3,17 +3,26 @@ 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)
|
||||
return get_image(path, retro: retro) if File.exist?(path)
|
||||
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
|
||||
|
||||
path = "./media/default.png"
|
||||
return get_image(path, retro: retro) if File.exist?(path)
|
||||
begin
|
||||
return get_image(path, retro: retro) if File.exist?(path)
|
||||
rescue RuntimeError => e
|
||||
pp e
|
||||
end
|
||||
|
||||
begin
|
||||
return get_image(fallback_path, retro: retro) if File.exist?(fallback_path)
|
||||
rescue RuntimeError => e
|
||||
pp e
|
||||
end
|
||||
|
||||
WHITE_IMAGE
|
||||
end
|
||||
|
||||
def rounded_avatar(image)
|
||||
circle = get_image("./media/ui/circle.png")
|
||||
circle = get_image("#{ROOT_PATH}/media/ui/circle.png")
|
||||
scale = [(circle.width.to_f / image.width).abs, (circle.width.to_f / image.height).abs].min
|
||||
|
||||
Gosu.render(circle.width, circle.height) do
|
||||
@@ -21,5 +30,33 @@ module W3DHubLauncher
|
||||
circle.draw(0, 0, 1, 1, 1, 0xff_ffffff, :multiply)
|
||||
end
|
||||
end
|
||||
|
||||
# Downloads requested url and on success check if host element is still in the layout and call the block if so.
|
||||
def remote_image(path, url:, element:, &block)
|
||||
memcache_key = :"lock_net_download_#{element.style.tag}"
|
||||
|
||||
if !File.exist?(path) && MemCache[memcache_key].nil?
|
||||
MemCache[memcache_key] = true
|
||||
|
||||
Worker::Api.download_url(url, path) do |result|
|
||||
# ignore progress reports
|
||||
next unless result.okay? && result.data == true
|
||||
|
||||
MemCache.delete(memcache_key)
|
||||
# does the target element still exist in the layout?
|
||||
e = find_element_by_tag(element.root, element.style.tag)
|
||||
|
||||
next unless e
|
||||
|
||||
if result.okay?
|
||||
block.call(element, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def on_main_thread(block)
|
||||
window.add_to_queue(block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
6
lib/hardware_information.rb
Normal file
6
lib/hardware_information.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
module W3DHubLauncher
|
||||
class HardwareInformation
|
||||
def initialize
|
||||
end
|
||||
end
|
||||
end
|
||||
145
lib/ico.rb
Normal file
145
lib/ico.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
require "stringio"
|
||||
|
||||
module W3DHubLauncher
|
||||
class ICO
|
||||
PNG_IDENT = "\211PNG\r\n\032\n".force_encoding("ASCII-8BIT").freeze
|
||||
|
||||
IconDirectory = Struct.new(:reserved, :type, :image_count)
|
||||
|
||||
IconDirectoryEntity = Struct.new(
|
||||
:width,
|
||||
:height,
|
||||
:palette_size,
|
||||
:reserved,
|
||||
:color_planes,
|
||||
:bit_depth,
|
||||
:image_size,
|
||||
:image_offset
|
||||
)
|
||||
|
||||
|
||||
def initialize(file:)
|
||||
@file = StringIO.new(File.binread(file))
|
||||
|
||||
@images = []
|
||||
|
||||
parse
|
||||
end
|
||||
|
||||
def directory
|
||||
@icon_directory
|
||||
end
|
||||
|
||||
def images
|
||||
@images
|
||||
end
|
||||
|
||||
# SEE: https://en.wikipedia.org/wiki/ICO_(file_format)
|
||||
def parse
|
||||
# Parse IconDirectory
|
||||
@icon_directory = IconDirectory.new(
|
||||
read_u16, read_u16, read_u16
|
||||
)
|
||||
|
||||
@icon_directory.image_count.times do
|
||||
@images << IconDirectoryEntity.new(
|
||||
read_u8,
|
||||
read_u8,
|
||||
read_u8,
|
||||
read_u8,
|
||||
read_u16,
|
||||
read_u16,
|
||||
read_u32,
|
||||
read_u32
|
||||
)
|
||||
|
||||
@images.last.width = 256 if @images.last.width == 0
|
||||
@images.last.height = 256 if @images.last.height == 0
|
||||
end
|
||||
end
|
||||
|
||||
def read_u8
|
||||
@file.read(1).unpack1("C")
|
||||
end
|
||||
|
||||
def read_u16
|
||||
@file.read(2).unpack1("v")
|
||||
end
|
||||
|
||||
def read_u32
|
||||
@file.read(4).unpack1("V")
|
||||
end
|
||||
|
||||
def select_pngs
|
||||
@images.select do |image|
|
||||
image_png?(image)
|
||||
end
|
||||
end
|
||||
|
||||
def image_png?(image)
|
||||
@file.pos = image.image_offset
|
||||
buf = @file.read(8).unpack1("a*")
|
||||
|
||||
buf == PNG_IDENT
|
||||
end
|
||||
|
||||
def select_bmps
|
||||
@images.select do |image|
|
||||
image_bmp?(image) && image.palette_size.zero? && image.bit_depth == 32
|
||||
end
|
||||
end
|
||||
|
||||
def image_bmp?(image)
|
||||
!image_png?(image)
|
||||
end
|
||||
|
||||
def to_rgba32_blob(image)
|
||||
@file.pos = image.image_offset
|
||||
buf = StringIO.new(@file.read(image.image_size))
|
||||
|
||||
raise NotImplementedError "Cannot parse png based icons!" unless image_bmp?(image)
|
||||
|
||||
raise NotImplementedError "Cannot parse #{image.bit_depth}" unless image.bit_depth == 32
|
||||
|
||||
blob = "".force_encoding("ASCII-8BIT")
|
||||
|
||||
width = image.width
|
||||
height = image.height - 1
|
||||
|
||||
image.height.times do |y|
|
||||
image.width.times do |x|
|
||||
buf.pos = ((height - y) * width + x + 10) * 4
|
||||
|
||||
blue = buf.read(1)
|
||||
green = buf.read(1)
|
||||
red = buf.read(1)
|
||||
alpha = buf.read(1)
|
||||
|
||||
blob << red
|
||||
blob << green
|
||||
blob << blue
|
||||
blob << alpha
|
||||
end
|
||||
end
|
||||
|
||||
# Gosu::Image.from_blob(image.width, image.height, blob)
|
||||
ChunkyPNG::Canvas.from_rgba_stream(image.width, image.height, blob)
|
||||
end
|
||||
|
||||
def image_data(image)
|
||||
@file.pos = image.image_offset
|
||||
StringIO.new(@file.read(image.image_size)).string
|
||||
end
|
||||
|
||||
def save(image, filename)
|
||||
puts filename
|
||||
if image_bmp?(image)
|
||||
puts :gosu_image
|
||||
to_rgba32_blob(image).save(filename)
|
||||
else
|
||||
puts :png
|
||||
File.open(filename, "wb") { |f| f.write(image_data(image)) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
94
lib/pages/boot/initial_setup.rb
Normal file
94
lib/pages/boot/initial_setup.rb
Normal file
@@ -0,0 +1,94 @@
|
||||
module W3DHubLauncher
|
||||
module Page
|
||||
module Boot
|
||||
class InitialSetup < CyberarmEngine::Page
|
||||
include GuiExt
|
||||
|
||||
def setup
|
||||
stack(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: 8, background_nine_slice_color: 0x11_ffffff, padding: HALF_PADDING) do
|
||||
banner "Welcome to #{NAME}", width: 1.0, text_align: :center
|
||||
tagline "Your gateway to the world of W3D Hub games.", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, padding: PADDING) do
|
||||
stack(width: 1.0, fill: true, scroll: true) do
|
||||
title "Initial Setup"
|
||||
caption "Please confirm launcher's default settings and make any desired adjustments. These can be changed in the launcher settings later.", font: FONT_REGULAR, margin_left: PADDING
|
||||
|
||||
flow(width: 1.0, height: 40, margin_top: PADDING) do
|
||||
tagline "Nickname", height: 1.0, text_v_align: :center
|
||||
@nickname = edit_line Etc.getlogin, fill: true
|
||||
end
|
||||
inscription "Nickname to use when joining servers.", margin_left: PADDING
|
||||
|
||||
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
|
||||
tagline "Launcher package cache directory", height: 1.0, text_v_align: :center
|
||||
@launcher_package_cache_directory = edit_line DEFAULT_PACKAGE_CACHE_PATH, fill: true
|
||||
button "Browse..."
|
||||
end
|
||||
inscription "Location where the launcher will download application packages.", margin_left: PADDING
|
||||
|
||||
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
|
||||
tagline "Application installation directory", height: 1.0, text_v_align: :center
|
||||
@application_installation_directory = edit_line DEFAULT_APPLICATIONS_PATH, fill: true
|
||||
button "Browse..."
|
||||
end
|
||||
inscription "Location where the launcher will install new applications.", margin_left: PADDING
|
||||
|
||||
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
|
||||
tagline "Wine prefix path", height: 1.0, text_v_align: :center
|
||||
@wine_prefix_path = edit_line "", fill: true
|
||||
button "Browse..."
|
||||
end
|
||||
inscription "Location of wine prefix to use. Leave blank to use default.", margin_left: PADDING
|
||||
|
||||
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
|
||||
tagline "Wine command", height: 1.0, text_v_align: :center
|
||||
@wine_command = edit_line "wine", fill: true
|
||||
button "Browse..."
|
||||
end
|
||||
inscription "Path to wine executable. Use <c=f80>wine</c> for system installed wine.", margin_left: PADDING
|
||||
|
||||
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
|
||||
tagline "Winetricks command", height: 1.0, text_v_align: :center
|
||||
@winetricks_command = edit_line "winetricks", fill: true
|
||||
button "Browse..."
|
||||
end
|
||||
inscription "Path to winetricks executable. Use <c=f80>winetricks</c> for system installed winetricks.", margin_left: PADDING
|
||||
end
|
||||
|
||||
flow(width: 1.0, padding_top: PADDING) do
|
||||
flow(fill: true)
|
||||
button "Accept", **CTA_BUTTON_THEME do |btn|
|
||||
btn.enabled = false
|
||||
|
||||
# initialize default settings
|
||||
settings = Worker::Api::Settings.new({})
|
||||
|
||||
# update user preferences
|
||||
settings.preferences.nickname = @nickname.value.strip
|
||||
settings.preferences.launcher_package_cache_directory = @launcher_package_cache_directory.value.strip
|
||||
settings.preferences.application_installation_directory = @application_installation_directory.value.strip
|
||||
settings.preferences.wine_prefix_path = @wine_prefix_path.value.strip
|
||||
settings.preferences.wine_command = @wine_command.value.strip
|
||||
settings.preferences.winetricks_command = @winetricks_command.value.strip
|
||||
|
||||
Worker::Api.update_settings(settings) do |result|
|
||||
if result.okay?
|
||||
on_main_thread(proc {
|
||||
MemCache[:settings] = settings
|
||||
page(Page::Boot::StartUp)
|
||||
})
|
||||
else
|
||||
# FIXME: Somehow something went wrong... show an error message.
|
||||
btn.enabled = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
226
lib/pages/boot/start_up.rb
Normal file
226
lib/pages/boot/start_up.rb
Normal file
@@ -0,0 +1,226 @@
|
||||
module W3DHubLauncher
|
||||
module Page
|
||||
module Boot
|
||||
class StartUp < CyberarmEngine::Page
|
||||
include GuiExt
|
||||
|
||||
def setup
|
||||
@steps = [
|
||||
method(:step_connectivity),
|
||||
method(:step_launcher_update),
|
||||
method(:step_server_list),
|
||||
method(:step_refresh_account_data),
|
||||
method(:step_fetch_applications),
|
||||
method(:step_battlefield_control_established)
|
||||
]
|
||||
@initialization_acceptable = false
|
||||
@step_count = @steps.size
|
||||
|
||||
stack(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: 8, background_nine_slice_color: 0x11_ffffff, padding: HALF_PADDING) do
|
||||
banner "Establishing Battlefield Control", width: 1.0, text_align: :center
|
||||
tagline "Please stand by...", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, padding: PADDING) do
|
||||
@initialization_container = stack(width: 1.0, fill: true, scroll: true) do
|
||||
# a = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do
|
||||
# image safe_get_image("#{ROOT_PATH}/media/icons/checkmark.png"), height: 1.0, v_align: :center
|
||||
# tagline "DNS resolution", height: 1.0, text_v_align: :center
|
||||
# end
|
||||
# after(500) do
|
||||
# a.show
|
||||
# end
|
||||
# b = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do
|
||||
# image safe_get_image("#{ROOT_PATH}/media/icons/information.png"), height: 1.0, v_align: :center, color: 0xff_ff8800
|
||||
# tagline "Upstream backend", height: 1.0, text_v_align: :center
|
||||
# caption "Notice: Unreachable. Accounts will be unavailable.", height: 1.0, text_v_align: :center, color: 0xff_ff8800
|
||||
# end
|
||||
# after(1000) do
|
||||
# b.show
|
||||
# end
|
||||
# c = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do
|
||||
# image safe_get_image("#{ROOT_PATH}/media/icons/checkmark.png"), height: 1.0, v_align: :center
|
||||
# tagline "Alternate backend", height: 1.0, text_v_align: :center
|
||||
# end
|
||||
# after(1500) do
|
||||
# c.show
|
||||
# end
|
||||
# d = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do
|
||||
# image safe_get_image("#{ROOT_PATH}/media/icons/information.png"), height: 1.0, v_align: :center, color: 0xff_ff8800
|
||||
# tagline "Refresh account session", height: 1.0, text_v_align: :center
|
||||
# caption "Notice: Upstream backend unavailable, session not refreshed.", height: 1.0, text_v_align: :center, color: 0xff_ff8800
|
||||
# end
|
||||
# after(2000) do
|
||||
# d.show
|
||||
# end
|
||||
# e = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do
|
||||
# image safe_get_image("#{ROOT_PATH}/media/icons/checkmark.png"), height: 1.0, v_align: :center
|
||||
# tagline "Fetch game servers", height: 1.0, text_v_align: :center
|
||||
# end
|
||||
# after(2500) do
|
||||
# e.show
|
||||
# end
|
||||
# f = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do
|
||||
# image safe_get_image("#{ROOT_PATH}/media/icons/cross.png"), height: 1.0, v_align: :center, color: 0xff_ff0000
|
||||
# tagline "Fetch applications", height: 1.0, text_v_align: :center
|
||||
# caption "Fatal: Failed to retrieve applications list and no local cache exists. Cannot continue.", height: 1.0, text_v_align: :center, color: 0xff_ff0000
|
||||
# end
|
||||
# after(3000) do
|
||||
# puts "HELLO"
|
||||
# W3DHubLauncher::Worker::Request.new(:w3dhub_api_call, { call: :fetch_applications }) do |result|
|
||||
# # pp [:CALLBACK, result]
|
||||
# File.write("applications.json", result.data)
|
||||
# hash = JSON.parse(result.data)
|
||||
# applications = hash["applications"]&.map { |app| W3DHubLauncher::Worker::Api::Application.new(app) } || []
|
||||
# # pp applications.to_json
|
||||
|
||||
# MemCache[:applications] = applications
|
||||
# end
|
||||
|
||||
# f.show
|
||||
# @progress_bar.type = :linear
|
||||
# @progress_bar.value = 0.0
|
||||
# end
|
||||
|
||||
# after(35000) do
|
||||
# parent.page_host.clear do
|
||||
# banner "Battlefield control established".upcase, width: 1.0, height: 1.0, text_v_align: :center, text_align: :center
|
||||
# end
|
||||
# end
|
||||
# after(36000) do
|
||||
# pop_state
|
||||
# push_state(States::Interface)
|
||||
# end
|
||||
end
|
||||
|
||||
flow(width: 1.0, padding_top: PADDING) do
|
||||
@progress_bar = progress width: 1.0, fraction: 0.0
|
||||
end
|
||||
end
|
||||
|
||||
next_step
|
||||
end
|
||||
|
||||
def next_step
|
||||
step = @steps.shift
|
||||
step&.call
|
||||
|
||||
# pp (1.0 - (@steps.size / @step_count.to_f)), @steps.size, @step_count
|
||||
@progress_bar.value = (1.0 - (@steps.size / @step_count.to_f))
|
||||
|
||||
|
||||
if step.nil? && @initialization_acceptable
|
||||
after(100) do
|
||||
pop_state
|
||||
push_state(States::Interface)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def step_connectivity
|
||||
@initialization_container.append do
|
||||
tagline "Checking uplink..."
|
||||
# caption "Primary backend services <c=080>OK</c>", margin_left: LARGE_PADDING
|
||||
# caption "Upstream backend services <c=080>OK</c>", margin_left: LARGE_PADDING
|
||||
end
|
||||
|
||||
Worker::Api.dns_resolution do |result|
|
||||
@initialization_container.append do
|
||||
if result.okay?
|
||||
caption "DNS Resolution <c=0f0>OK</c>", margin_left: LARGE_PADDING
|
||||
next_step
|
||||
else
|
||||
caption "DNS Resolution <c=f00>FAILED</c>", margin_left: LARGE_PADDING
|
||||
caption "Failed to resolve: #{result.error}", color: 0xff_ff0000, margin_left: LARGE_PADDING * 2
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def step_launcher_update
|
||||
@initialization_container.append do
|
||||
tagline "Checking for updates..."
|
||||
caption "Launcher version <c=0f0>OK</c>", margin_left: LARGE_PADDING
|
||||
end
|
||||
|
||||
after(1000) do
|
||||
next_step
|
||||
end
|
||||
end
|
||||
|
||||
def step_server_list
|
||||
@initialization_container.append do
|
||||
tagline "Requesting server listing..."
|
||||
end
|
||||
|
||||
Worker::Api.servers do |result|
|
||||
@initialization_container.append do
|
||||
if result.okay?
|
||||
MemCache[:servers] = JSON.parse(result.data).map { |s| Worker::Api::GameServer.new(s) }
|
||||
|
||||
caption "Received #{MemCache[:servers].size} servers <c=0f0>OK</c>", margin_left: LARGE_PADDING
|
||||
else
|
||||
caption "Failed to retrieve server listing.", color: 0xff_ff8800, margin_left: LARGE_PADDING
|
||||
end
|
||||
|
||||
next_step
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def step_refresh_account_data
|
||||
@initialization_container.append do
|
||||
tagline "Updating account information..."
|
||||
caption "Refresh session <c=0f0>OK</c>", margin_left: LARGE_PADDING
|
||||
caption "Profile data <c=0f0>OK</c>", margin_left: LARGE_PADDING
|
||||
end
|
||||
|
||||
after(1000) do
|
||||
next_step
|
||||
end
|
||||
end
|
||||
|
||||
def step_fetch_applications
|
||||
@initialization_container.append do
|
||||
tagline "Requesting application listing..."
|
||||
end
|
||||
|
||||
Worker::Api.applications do |result|
|
||||
if result.okay?
|
||||
File.write("applications.json", result.data)
|
||||
hash = JSON.parse(result.data)
|
||||
applications = hash["applications"]&.map { |app| W3DHubLauncher::Worker::Api::Application.new(app) } || []
|
||||
|
||||
MemCache[:applications] = applications
|
||||
|
||||
@initialization_container.append do
|
||||
caption "Received #{MemCache[:applications].select(&:game?).size} applications <c=0f0>OK</c>", margin_left: LARGE_PADDING
|
||||
end
|
||||
|
||||
next_step
|
||||
else
|
||||
@initialization_container.append do
|
||||
caption "A fatal error occurred while getting application data.", margin_left: LARGE_PADDING, color: 0xaa_ff0000
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def step_battlefield_control_established
|
||||
@initialization_acceptable = true
|
||||
|
||||
@initialization_container.append do
|
||||
stack(fill: true)
|
||||
caption "All required systems operational", color: 0xff_00ff00, width: 1.0, text_v_align: :center, text_align: :center
|
||||
banner "Battlefield control established".upcase, width: 1.0, text_v_align: :center, text_align: :center
|
||||
end
|
||||
@progress_bar.hide
|
||||
|
||||
after(800) do
|
||||
next_step
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
38
lib/pages/boot/terms.rb
Normal file
38
lib/pages/boot/terms.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module W3DHubLauncher
|
||||
module Page
|
||||
module Boot
|
||||
class Terms < CyberarmEngine::Page
|
||||
include GuiExt
|
||||
|
||||
TERMS_AND_CONDITIONS = <<~TERMS
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
TERMS
|
||||
|
||||
def setup
|
||||
flow(width: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: 8, background_nine_slice_color: 0x11_ffffff, padding: HALF_PADDING) do
|
||||
banner "Terms and Conditions", width: 1.0, text_align: :center
|
||||
end
|
||||
|
||||
stack(width: 1.0, fill: true, padding: PADDING) do
|
||||
stack(width: 1.0, fill: true, scroll: true, padding_bottom: PADDING) do
|
||||
tagline TERMS_AND_CONDITIONS, font: FONT_REGULAR
|
||||
end
|
||||
|
||||
flow(width: 1.0) do
|
||||
flow(fill: true)
|
||||
button "Decline", margin_right: PADDING do
|
||||
window.close
|
||||
end
|
||||
|
||||
button "Accept", **CTA_BUTTON_THEME do
|
||||
page(Page::Boot::InitialSetup)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,79 +4,315 @@ module W3DHubLauncher
|
||||
include GuiExt
|
||||
|
||||
def setup
|
||||
@games = MemCache[:applications]&.select { |app| app.type.include?("game") } || []
|
||||
|
||||
@current_app = @games.first
|
||||
@current_channel = @current_app&.channels&.first
|
||||
|
||||
@display_mode = :all_games
|
||||
|
||||
# game bar container
|
||||
flow(width: 1.0, height: 60) do
|
||||
flow(width: 220, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_BLACK) do
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center, h_align: :center) do
|
||||
image safe_get_image("./media/icons/menuGrid.png"), height: 40, color: 0xff_bbbbbb
|
||||
widget(width: 220, height: 1.0, style_class: [:all_button]) do |w|
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center) do
|
||||
image safe_get_image("#{ROOT_PATH}/media/icons/menuGrid.png"), height: 40, color: 0xff_bbbbbb
|
||||
link "ALL GAMES", text_size: 24, font: FONT_BLACK, height: 1.0, text_v_align: :center
|
||||
end
|
||||
w.subscribe(:clicked_left_mouse_button) do
|
||||
populate_all_games
|
||||
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_BLACK, margin_left: PADDING) do
|
||||
image safe_get_image("./data/cache/apb.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0x88_5e5c64, border_thickness_bottom: 3, border_color_bottom: 0xff_3584e4, tip: "Red Alert: A Path Beyond"
|
||||
image safe_get_image("./data/cache/ren.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, tip: "Command & Conquer: Renegade"
|
||||
image safe_get_image("./data/cache/tsr.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, tip: "Tiberian Sun: Reborn"
|
||||
image safe_get_image("./data/cache/woa.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, tip: "Battle for Dune: War of Assassins"
|
||||
@games_list_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
|
||||
end
|
||||
end
|
||||
|
||||
# game content container
|
||||
flow(width: 1.0, fill: true, margin_top: LARGE_PADDING) do
|
||||
@game_content_container = flow(width: 1.0, fill: true, margin_top: LARGE_PADDING) do
|
||||
end
|
||||
|
||||
populate_game
|
||||
end
|
||||
|
||||
def focus
|
||||
@parent.show_battleview_panel
|
||||
end
|
||||
|
||||
def blur
|
||||
@parent.hide_battleview_panel
|
||||
end
|
||||
|
||||
def populate_game_content_container
|
||||
@game_content_container.clear do
|
||||
# game info container
|
||||
stack(width: 340, height: 1.0) do
|
||||
# logo
|
||||
image safe_get_image("/run/media/cyberarm/Storage/W3DHub/Launcher/package-cache/games/apb/logo.png.package"), width: 1.0, max_height: 124
|
||||
|
||||
# web links
|
||||
stack(width: 1.0, fill: true, padding: 0, padding_top: LARGE_PADDING) do
|
||||
link "Modifications", text_size: 24
|
||||
link "Bug Tracker", text_size: 24
|
||||
link "Player Statistics", text_size: 24
|
||||
end
|
||||
|
||||
# launching ta game
|
||||
caption "Game Version"
|
||||
list_box items: [ "Release", "Open Testing" ], width: 1.0, margin_bottom: PADDING
|
||||
flow(width: 1.0, height: 60) do
|
||||
button "PLAY", fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_LEFT, **CTA_BUTTON_THEME
|
||||
button safe_get_image("./media/icons/singleplayer.png"), image_height: 1.0, background_nine_slice: NINE_SLICE_SQUARE, **CTA_BUTTON_THEME
|
||||
button safe_get_image("./media/icons/gear.png"), image_height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED_RIGHT, **CTA_BUTTON_THEME
|
||||
end
|
||||
inscription "Version: 3.9.2.15", margin_top: PADDING
|
||||
@game_info_container = stack(width: 340, height: 1.0) do
|
||||
end
|
||||
|
||||
# game events and news container
|
||||
stack(fill: true, height: 1.0, margin_left: LARGE_PADDING, scroll: true) do
|
||||
flow(width: 1.0, height: 1.0, max_height: 380, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_BLACK) do
|
||||
image safe_get_image("./media/background.png"), fill: true, aspect_ratio: 16.0 / 9.0
|
||||
|
||||
stack(fill: true, height: 1.0, margin_left: PADDING) do
|
||||
caption "Upcoming Event".upcase, color: 0xff_22aa11
|
||||
title "Red Alert: A Path Beyond Game Night"
|
||||
tagline "July 11, 2028"
|
||||
|
||||
flow(fill: true)
|
||||
|
||||
button "Read More", margin_left: PADDING, margin_right: LARGE_PADDING, margin_bottom: PADDING, width: 1.0
|
||||
end
|
||||
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
|
||||
flow(width: 1.0, margin_top: PADDING) do
|
||||
9.times do
|
||||
stack(width: 1.0 / 3, height: 345, aspect_ratio: 1, margin_left: HALF_PADDING, margin_right: HALF_PADDING, margin_bottom: PADDING) do
|
||||
stack(width: 1.0, fill: true, background_image: safe_get_image("./media/background.png"), background_image_mode: :fill)
|
||||
stack(width: 1.0, height: 1.0 / 3, padding: PADDING, v_align: :bottom, background_nine_slice: NINE_SLICE_ROUNDED_BOTTOM, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_BLACK, border_thickness_top: 1, border_color_top: Gosu::Color::BLACK) do
|
||||
caption "NEWS", color: 0x88_ffffff
|
||||
tagline "A News Item Post A News Item Post"
|
||||
end
|
||||
end
|
||||
@news_container = flow(width: 1.0) do
|
||||
end
|
||||
|
||||
# "dynamically" adjust news item widths
|
||||
@news_container.subscribe(:size_changed) do |event|
|
||||
@news_container.children.each do |box|
|
||||
box.style.width = 1.0 / news_item_width_ratio
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def populate_all_games
|
||||
@display_mode = :all_games
|
||||
|
||||
@game_content_container.clear do
|
||||
flow(fill: true, height: 1.0, scroll: true, tag: :hi_mom) do |e|
|
||||
@games.each do |app|
|
||||
image safe_get_image("#{ROOT_PATH}/media/default_game_cover.png"), aspect_ratio: 3 / 4.0, width: 1.0 / game_cover_width_ratio(e), margin_left: HALF_PADDING, margin_right: HALF_PADDING, margin_bottom: PADDING, tip: app.name
|
||||
end
|
||||
|
||||
e.subscribe(:size_changed) do |e|
|
||||
e.children.each do |child|
|
||||
child.style.width = 1.0 / game_cover_width_ratio(e)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game(game = @current_app, channel = @current_channel)
|
||||
if @display_mode == :all_games
|
||||
@display_mode = :game
|
||||
|
||||
populate_game_content_container
|
||||
end
|
||||
|
||||
@current_app = game
|
||||
@current_channel = game&.channels&.first
|
||||
|
||||
# This shouldn't be possible, but you never know...
|
||||
unless @current_app
|
||||
@games_list_container.clear
|
||||
@game_info_container.clear
|
||||
|
||||
@event_container.clear
|
||||
@event_container.hide
|
||||
|
||||
@news_container.clear do
|
||||
title "Something has gone wrong somewhere."
|
||||
tagline "No games to display... ¯\\_(-|-)_/¯"
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
app_id = @current_app.id
|
||||
unless MemCache[:"events_#{app_id}"]
|
||||
Worker::Api.events(app_id) do |result|
|
||||
if result.okay?
|
||||
File.write("events_#{app_id}.json", result.data)
|
||||
MemCache[:"events_#{app_id}"] = JSON.parse(result.data)&.map { |item| Worker::Api::ServerEvent.new(item) } || []
|
||||
|
||||
populate_game_event if app_id == @current_app.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
unless MemCache[:"news_#{app_id}"]
|
||||
Worker::Api.news(app_id) do |result|
|
||||
if result.okay?
|
||||
File.write("news_#{app_id}.json", result.data)
|
||||
MemCache[:"news_#{app_id}"] = JSON.parse(result.data)["news"]&.map { |item| Worker::Api::NewsItem.new(item) } || []
|
||||
|
||||
populate_game_news if app_id == @current_app.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
populate_games_list
|
||||
populate_game_info
|
||||
populate_game_event
|
||||
populate_game_news
|
||||
end
|
||||
|
||||
def populate_games_list
|
||||
@games_list_container.clear do
|
||||
@games.each do |game|
|
||||
btn = button(safe_get_image("#{CACHE_PATH}/icon_#{game.id}.png"), tag: :"image_icon_#{game.id}", style_class: [:app_icon_button], enabled: @current_app.id != game.id, tip: game.name) do |btn|
|
||||
populate_game(game, game&.channels&.first)
|
||||
end
|
||||
remote_image("#{CACHE_PATH}/icon_#{game.id}.ico", url: "https://s3.w3d.cyberarm.dev/games/#{game.id}/#{game.id}.ico", element: btn) do |e, path|
|
||||
Worker::Api.ico_to_png(ico_path: path, png_path: path.sub(".ico", ".png")) do |result|
|
||||
e&.value = safe_get_image(result.data["path"]) if result.okay?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game_info
|
||||
application = MemCache[:settings].application_installed?(@current_app, @current_channel)
|
||||
# pp application
|
||||
|
||||
@game_info_container.clear do
|
||||
# logo
|
||||
img = image safe_get_image("#{CACHE_PATH}/logo_#{@current_app.id}.png"), tag: :"image_logo_#{@current_app.id}", width: 1.0, max_height: 124
|
||||
remote_image("#{CACHE_PATH}/logo_#{@current_app.id}.png", url: "https://s3.w3d.cyberarm.dev/games/#{@current_app.id}/logo.png", element: img) do |e, path|
|
||||
e&.value = safe_get_image(path)
|
||||
end
|
||||
|
||||
# web links
|
||||
stack(width: 1.0, fill: true, padding: 0, padding_top: LARGE_PADDING) do
|
||||
@current_app.web_links.each do |link|
|
||||
link link.name, text_size: 24, tip: link.uri do
|
||||
SDL.OpenURL(link.uri)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# launching ta game
|
||||
if (@current_app&.channels&.size || 0) > 1
|
||||
caption "Game Version"
|
||||
list_box(items: @current_app.channels.map(&:name), choose: @current_channel&.name, width: 1.0, margin_bottom: PADDING) do |item|
|
||||
@current_channel = @current_app.channels.find { |c| c.name == item }
|
||||
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 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", 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
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game_event
|
||||
app_events = MemCache[:"events_#{@current_app.id}"] || []
|
||||
if app_events.empty?
|
||||
@event_container.hide
|
||||
else
|
||||
@event_container.show
|
||||
|
||||
event = app_events.sort(&:start_time).last
|
||||
|
||||
@event_container.clear do
|
||||
caption "Upcoming Event".upcase, color: 0xaa_ffffff
|
||||
tagline event.title # "Red Alert: A Path Beyond Game Night"
|
||||
caption event.start_time.strftime("%B %e, %Y • %T") # "July 11, 2028"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def populate_game_news
|
||||
app_news = MemCache[:"news_#{@current_app.id}"] || []
|
||||
|
||||
@news_container.clear do
|
||||
app_news.each do |item|
|
||||
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
|
||||
|
||||
stack(width: 1.0, fill: true, padding: PADDING, padding_bottom: 0, scroll: false) do
|
||||
para item.blurb
|
||||
end
|
||||
|
||||
button "Read More", margin: PADDING, width: 1.0, tip: item.uri do
|
||||
SDL.OpenURL(item.uri)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def game_cover_width_ratio(e)
|
||||
(e.width / 300.0).round.clamp(1..10)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -4,43 +4,95 @@ module W3DHubLauncher
|
||||
include GuiExt
|
||||
|
||||
def setup
|
||||
@games_filter = []
|
||||
|
||||
# game bar container
|
||||
flow(width: 1.0, height: 60) do
|
||||
flow(width: 220, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_BLACK) do
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center, h_align: :center) do
|
||||
image safe_get_image("./media/icons/menuGrid.png"), height: 40, color: 0xff_bbbbbb
|
||||
widget(width: 220, height: 1.0, style_class: [:all_button]) do |w|
|
||||
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center) do
|
||||
image safe_get_image("#{ROOT_PATH}/media/icons/menuGrid.png"), height: 40, color: 0xff_bbbbbb
|
||||
link "ALL SERVERS", text_size: 24, font: FONT_BLACK, height: 1.0, text_v_align: :center
|
||||
end
|
||||
|
||||
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_BLACK, margin_left: PADDING) do
|
||||
image safe_get_image("./data/cache/apb.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0x88_5e5c64, border_thickness_bottom: 3, border_color_bottom: 0xff_3584e4, tip: "Red Alert: A Path Beyond"
|
||||
image safe_get_image("./data/cache/ren.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, tip: "Command & Conquer: Renegade"
|
||||
image safe_get_image("./data/cache/tsr.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, tip: "Tiberian Sun: Reborn"
|
||||
image safe_get_image("./data/cache/woa.png"), height: 1.0, padding: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED_TOP, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, tip: "Battle for Dune: War of Assassins"
|
||||
@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], tag: :"app_filter_#{app.id}", tip: app.name) do |btn|
|
||||
if shift_down?
|
||||
@games_filter << app.id
|
||||
@games_filter.uniq!
|
||||
else
|
||||
@games_filter.clear
|
||||
@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
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# game content container
|
||||
stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do
|
||||
10.times do
|
||||
flow(width: 1.0, height: 48, 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: 0x44_000000, hover: { background_nine_slice_color: 0x88_000000 }) do
|
||||
@servers_container = stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do
|
||||
end
|
||||
|
||||
# FIXME: Remember and use last used filter
|
||||
populate_server_list
|
||||
end
|
||||
|
||||
def focus
|
||||
CyberarmEngine::EventBus.subscribe("game_server_pings", self, :handle_game_server_pings)
|
||||
|
||||
@parent.show_server_details_panel
|
||||
end
|
||||
|
||||
def blur
|
||||
CyberarmEngine::EventBus.unsubscribe("game_server_pings", self)
|
||||
|
||||
@parent.hide_server_details_panel
|
||||
end
|
||||
|
||||
def handle_game_server_pings(payload)
|
||||
# TODO: Update displayed server ping
|
||||
end
|
||||
|
||||
def populate_server_list
|
||||
@servers_container.clear do
|
||||
(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 |w|
|
||||
# app icon container
|
||||
image(safe_get_image("./data/cache/apb.png"), width: 48, height: 1.0, margin_left: HALF_PADDING)
|
||||
# friend name and status container
|
||||
image(safe_get_image("#{CACHE_PATH}/icon_#{app.id}.png"), tip: app.name, width: 56, height: 1.0, margin_left: HALF_PADDING)
|
||||
|
||||
# server name, region, and times container
|
||||
stack(fill: true, height: 1.0, margin_left: HALF_PADDING) do
|
||||
stack(v_align: :center) do
|
||||
stack(width: 1.0, v_align: :center) do
|
||||
# server name
|
||||
caption ["Really Long Server Name Goes Here", "[US][W3D Hub] APB Game Night", "[US][W3D Hub] APB Co-op 3.7", "Really Long Server Name Goes Here Really Long Server Name Goes Here"].sample, text_wrap: :none
|
||||
caption server.name, text_wrap: :none, tip: server.name
|
||||
# server info
|
||||
a = ["North America", "South America", "Europe", "Asia", "Antarctica", "Arctica", "Oceania"]
|
||||
inscription "#{a.sample} • 13:52 / #{rand > 0.5 ? '∞' : '30:00'}", text_wrap: :none, margin_top: -HALF_PADDING
|
||||
inscription "#{server.channel} • #{server.region} • #{server.time_elapsed} / #{server.match_remaining_time}", text_wrap: :none, margin_top: -HALF_PADDING
|
||||
end
|
||||
end
|
||||
|
||||
# server map
|
||||
stack(width: 256, height: 1.0, margin_left: HALF_PADDING) do
|
||||
stack(width: 1.0, fill: true, v_align: :center) do
|
||||
caption ["RA_Under", "C&C Superduple Long Map Name Goes Here", "RA_NorthByNorthWest", "RA_HostileWatersParadox", "RA_PacificThreat"].sample, text_wrap: :none, width: 1.0, text_align: :center
|
||||
caption server.current_map, tip: server.current_map, text_wrap: :none, width: 1.0, text_align: :center
|
||||
inscription "map", text_wrap: :none, width: 1.0, text_align: :center, margin_top: -HALF_PADDING
|
||||
end
|
||||
end
|
||||
@@ -48,17 +100,22 @@ module W3DHubLauncher
|
||||
# server player count
|
||||
stack(width: 96, height: 1.0, margin_left: HALF_PADDING) do
|
||||
stack(width: 1.0, fill: true, v_align: :center) do
|
||||
caption format("%d / %d", rand(60), rand(60..127)), width: 1.0, text_align: :center
|
||||
caption format("%d / %d", server.player_count, server.max_players), width: 1.0, text_align: :center
|
||||
inscription "players", text_wrap: :none, width: 1.0, text_align: :center, margin_top: -HALF_PADDING
|
||||
end
|
||||
end
|
||||
|
||||
# server ping
|
||||
flow(width: 96, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do
|
||||
stack(fill: true, height: 1.0, v_align: :center) do
|
||||
caption format("%d ms", rand(16..360)), width: 1.0, text_align: :center
|
||||
caption server.ping == Worker::Api::GameServer::BAD_OR_UNKNOWN_PING ? "?" : format("%ims", server.ping), width: 1.0, text_align: :center, tip: server.ping == Worker::Api::GameServer::BAD_OR_UNKNOWN_PING ? "Server has not replied yet or did not reply to ICMP Echo Request.\nPing unknown." : ""
|
||||
inscription "ping", text_wrap: :none, width: 1.0, text_align: :center, margin_top: -HALF_PADDING
|
||||
end
|
||||
stack(width: 8, height: rand(0.25..1.0), 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: [0xff_26a269, 0xff_e5a50a, 0xff_a51d2d, 0xff_3d3846].sample)
|
||||
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
|
||||
|
||||
9
lib/state.rb
Normal file
9
lib/state.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module W3DHubLauncher
|
||||
class State < CyberarmEngine::GuiState
|
||||
include W3DHubLauncher::GuiExt
|
||||
|
||||
def setup
|
||||
theme(THEME)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
module W3DHubLauncher
|
||||
class States
|
||||
class Boot < W3DHubLauncher::State
|
||||
def setup
|
||||
super
|
||||
|
||||
# root container - background image
|
||||
stack(width: 1.0, height: 1.0, background_image: safe_get_image("#{ROOT_PATH}/media/background.png"), background_image_mode: :fill) do
|
||||
# root container - background image tint
|
||||
flow(width: 1.0, height: 1.0, background: 0xaa_000000) do
|
||||
# content container
|
||||
stack(fill: true, height: 1.0, margin: PADDING, margin_right: PADDING) do
|
||||
# header bar container
|
||||
flow(width: 1.0, height: 80, margin_bottom: PADDING) do |c|
|
||||
# logo image
|
||||
image(safe_get_image("#{ROOT_PATH}/media/logo.png"), height: 1.0)
|
||||
|
||||
stack(fill: true, height: 1.0) do
|
||||
stack(fill: true)
|
||||
title NAME
|
||||
stack(fill: true)
|
||||
end
|
||||
end
|
||||
|
||||
@page_host = stack(width: 1.0, fill: true, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: 8, background_nine_slice_color: ALPHA_GRAY) do
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Worker::Api.load_settings do |result|
|
||||
if result.okay?
|
||||
on_main_thread( proc {
|
||||
MemCache[:settings] = Worker::Api::Settings.new(JSON.parse(result.data))
|
||||
page(Page::Boot::StartUp)
|
||||
})
|
||||
else
|
||||
on_main_thread( proc {
|
||||
page(Page::Boot::Terms)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
module W3DHubLauncher
|
||||
class States
|
||||
class Interface < W3DHubLauncher::State
|
||||
attr_reader :application_task_status_bar_container
|
||||
|
||||
def setup
|
||||
super
|
||||
|
||||
# root container - background image
|
||||
stack(width: 1.0, height: 1.0, background_image: safe_get_image("#{ROOT_PATH}/media/background.png"), background_image_mode: :fill) do
|
||||
# root container - background image tint
|
||||
stack(width: 1.0, height: 1.0, background: ALPHA_BLACK) do
|
||||
# content container
|
||||
stack(width: 1.0, fill: true, margin: PADDING) do
|
||||
# header bar container
|
||||
flow(width: 1.0, height: 100, margin_bottom: PADDING) do |c|
|
||||
# logo + menu button
|
||||
button(safe_get_image("#{ROOT_PATH}/media/logo.png"), image_height: 1.0, background: 0, border_color: 0, hover: { background: 0 }, active: { background: 0, color: 0xff_ffffff }) do |btn|
|
||||
menu(parent: btn) do
|
||||
menu_item("Settings")
|
||||
menu_item("About") do
|
||||
dialog(Dialog::About)
|
||||
end
|
||||
menu_item("Exit") do
|
||||
window.close
|
||||
end
|
||||
end.show
|
||||
end
|
||||
|
||||
# navigation bar container
|
||||
stack(fill: true, height: 1.0) do
|
||||
# stack(fill: true)
|
||||
|
||||
# navigation container
|
||||
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)
|
||||
end
|
||||
image safe_get_image("#{ROOT_PATH}/media/icons/information.png"), height: 40, color: 0xff_bbbbbb, tip: "Notifications"
|
||||
end
|
||||
# application task status bar container
|
||||
@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)", tag: :status_bar_title
|
||||
stack(fill: true)
|
||||
para "Fetching manifests...", tag: :status_bar_label
|
||||
end
|
||||
progress(width: 1.0, fraction: 0.0, tag: :status_bar_progress)
|
||||
end
|
||||
end
|
||||
|
||||
# self account container
|
||||
@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")), 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, 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, width: 1.0, tag: :account_name
|
||||
# self set online state
|
||||
link "Online ▼", text_size: 18 do |l|
|
||||
menu(parent: l) do
|
||||
menu_item("Online")
|
||||
menu_item("Do Not Disturb")
|
||||
menu_item("Away")
|
||||
menu_item("Invisible")
|
||||
menu_item("Sign Out")
|
||||
end.show
|
||||
end
|
||||
flow(fill: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# layout container
|
||||
flow(width: 1.0, fill: true) do
|
||||
# page host container
|
||||
@page_host = stack(fill: true, height: 1.0) do
|
||||
end
|
||||
|
||||
# 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
|
||||
end
|
||||
|
||||
# battleview/friends container
|
||||
@battleview_container = stack(width: 400, height: 1.0, margin_left: LARGE_PADDING) do
|
||||
# friend management container
|
||||
flow(width: 1.0, height: 60) do
|
||||
flow(width: 1.0, v_align: :center) do
|
||||
button safe_get_image("#{ROOT_PATH}/media/icons/singleplayer.png"), image_height: 1.0
|
||||
button safe_get_image("#{ROOT_PATH}/media/icons/gear.png"), image_height: 1.0, margin_left: HALF_PADDING
|
||||
edit_line "", margin_left: HALF_PADDING, fill: true, height: 1.0
|
||||
end
|
||||
end
|
||||
|
||||
# friends/clanmates list container
|
||||
stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do
|
||||
50.times do |i|
|
||||
# friend container
|
||||
widget(width: 1.0, height: 68, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, hover: { background_nine_slice_color: ALPHA_GRAY }, active: { background_nine_slice_color: ALPHA_BLACK }) do |w|
|
||||
w.subscribe(:clicked_left_mouse_button) do
|
||||
puts "HELLO THERE"
|
||||
end
|
||||
|
||||
|
||||
# friend avatar container
|
||||
stack(width: 48 + HALF_PADDING, height: 1.0, margin_left: HALF_PADDING, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png")), background_image_mode: :fill) do
|
||||
stack(width: 12, height: 12, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269)
|
||||
end
|
||||
# friend name and status container
|
||||
stack(fill: true, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do
|
||||
stack(width: 1.0, v_align: :center) do
|
||||
caption ["Silverlight", "PXD2000", "Alstar", "SteelGhost", "FRAYDO"].sample, text_wrap: :none
|
||||
inscription "RA_Under • 13:52", text_wrap: :none, margin_top: -HALF_PADDING
|
||||
end
|
||||
end
|
||||
# friend active application container
|
||||
stack(width: 48, height: 1.0, margin_right: HALF_PADDING, background_image: safe_get_image("#{ROOT_PATH}/media/logo.png"), background_image_mode: :fill)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
hide_server_details_panel
|
||||
|
||||
page(Page::Games)
|
||||
end
|
||||
|
||||
def hide_battleview_panel
|
||||
@battleview_container.hide
|
||||
end
|
||||
|
||||
def show_battleview_panel
|
||||
@battleview_container.show
|
||||
end
|
||||
|
||||
def hide_server_details_panel
|
||||
@server_details_container.hide
|
||||
end
|
||||
|
||||
def show_server_details_panel
|
||||
@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
|
||||
|
||||
@battleview_container.toggle if id == Gosu::KB_F8
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
86
lib/theme.rb
86
lib/theme.rb
@@ -2,27 +2,41 @@ module W3DHubLauncher
|
||||
LARGE_PADDING = 40
|
||||
PADDING = 20
|
||||
HALF_PADDING = 10
|
||||
|
||||
ALPHA_BLACK = 0x88_000000
|
||||
ALPHA_GRAY = 0x88_5e5c64
|
||||
TRANSLUCENT_GRAY = 0xaa_3d3846
|
||||
|
||||
FONT_LIGHT = "./media/fonts/NotoSans-Light.ttf"
|
||||
FONT_REGULAR = "./media/fonts/NotoSans-Regular.ttf"
|
||||
FONT_BOLD = "./media/fonts/NotoSans-Bold.ttf"
|
||||
FONT_BLACK = "./media/fonts/NotoSans-Black.ttf"
|
||||
FONT_LIGHT = "#{ROOT_PATH}/media/fonts/NotoSans-Light.ttf"
|
||||
FONT_REGULAR = "#{ROOT_PATH}/media/fonts/NotoSans-Regular.ttf"
|
||||
FONT_BOLD = "#{ROOT_PATH}/media/fonts/NotoSans-Bold.ttf"
|
||||
FONT_BLACK = "#{ROOT_PATH}/media/fonts/NotoSans-Black.ttf"
|
||||
|
||||
FONT_MONO = "./media/fonts/NotoSansMono-Regular.ttf"
|
||||
FONT_MONO = "#{ROOT_PATH}/media/fonts/NotoSansMono-Regular.ttf"
|
||||
|
||||
NINE_SLICE_EDGE = 8
|
||||
NINE_SLICE_EDGE_SMALL = 4
|
||||
NINE_SLICE_ROUNDED = "./media/ui/rounded.png"
|
||||
NINE_SLICE_ROUNDED_SMALL = "./media/ui/rounded_small.png"
|
||||
NINE_SLICE_ROUNDED_LEFT = "./media/ui/rounded_left.png"
|
||||
NINE_SLICE_ROUNDED_RIGHT = "./media/ui/rounded_right.png"
|
||||
NINE_SLICE_ROUNDED_TOP = "./media/ui/rounded_top.png"
|
||||
NINE_SLICE_ROUNDED_BOTTOM = "./media/ui/rounded_bottom.png"
|
||||
NINE_SLICE_SQUARE = "./media/ui/square.png"
|
||||
NINE_SLICE_EDGE_TINY = 2
|
||||
NINE_SLICE_ROUNDED = "#{ROOT_PATH}/media/ui/rounded.png"
|
||||
NINE_SLICE_ROUNDED_SMALL = "#{ROOT_PATH}/media/ui/rounded_small.png"
|
||||
NINE_SLICE_ROUNDED_TINY = "#{ROOT_PATH}/media/ui/rounded_small.png"
|
||||
NINE_SLICE_ROUNDED_LEFT = "#{ROOT_PATH}/media/ui/rounded_left.png"
|
||||
NINE_SLICE_ROUNDED_RIGHT = "#{ROOT_PATH}/media/ui/rounded_right.png"
|
||||
NINE_SLICE_ROUNDED_TOP = "#{ROOT_PATH}/media/ui/rounded_top.png"
|
||||
NINE_SLICE_ROUNDED_BOTTOM = "#{ROOT_PATH}/media/ui/rounded_bottom.png"
|
||||
NINE_SLICE_SQUARE = "#{ROOT_PATH}/media/ui/square.png"
|
||||
|
||||
CTA_BUTTON_THEME = {
|
||||
background_nine_slice_color: 0xff_1a5fb4
|
||||
color: 0xff_ffffff,
|
||||
background_nine_slice_color: 0xff_1c71d8,
|
||||
hover: {
|
||||
color: 0xff_ffffff,
|
||||
background_nine_slice_color: 0xff_3584e4
|
||||
},
|
||||
active: {
|
||||
color: 0xff_ffffff,
|
||||
background_nine_slice_color: 0xff_1a5fb4
|
||||
}
|
||||
}
|
||||
|
||||
THEME = {
|
||||
@@ -65,6 +79,9 @@ module W3DHubLauncher
|
||||
text_shadow: false,
|
||||
color: 0xff_ffffff,
|
||||
background: 0,#x88_5e5c64,
|
||||
padding_left: HALF_PADDING,
|
||||
padding_right: HALF_PADDING,
|
||||
border_color: 0,
|
||||
background_nine_slice: NINE_SLICE_ROUNDED,
|
||||
background_nine_slice_from_edge: NINE_SLICE_EDGE,
|
||||
background_nine_slice_mode: :stretched,
|
||||
@@ -79,6 +96,11 @@ module W3DHubLauncher
|
||||
color: 0x88_ffffff,
|
||||
background: 0,
|
||||
background_nine_slice_color: 0xaa_5e5c64
|
||||
},
|
||||
disabled: {
|
||||
color: 0x88_c0bfbc,
|
||||
background: 0,
|
||||
background_nine_slice_color: 0xaa_77767b
|
||||
}
|
||||
},
|
||||
EditLine: {
|
||||
@@ -116,14 +138,50 @@ module W3DHubLauncher
|
||||
background: 0xee_444444
|
||||
}
|
||||
},
|
||||
Progress: {
|
||||
height: NINE_SLICE_EDGE_TINY * 2,
|
||||
background: 0,
|
||||
background_nine_slice: NINE_SLICE_ROUNDED_TINY,
|
||||
background_nine_slice_from_edge: NINE_SLICE_EDGE_TINY,
|
||||
background_nine_slice_mode: :stretched,
|
||||
background_nine_slice_color: 0x88_5e5c64,
|
||||
fraction_background: 0xff_1a5fb4,
|
||||
border_thickness: 0,
|
||||
},
|
||||
ToolTip: {
|
||||
delay: 500,
|
||||
text_size: 24,
|
||||
background: 0,#x88_5e5c64,
|
||||
background_nine_slice: NINE_SLICE_ROUNDED,
|
||||
background_nine_slice_from_edge: NINE_SLICE_EDGE,
|
||||
background_nine_slice_mode: :stretched,
|
||||
background_nine_slice_color: 0xdd_5e5c64,
|
||||
background_nine_slice_color: 0xcc_000000,
|
||||
border_thickness: 0
|
||||
},
|
||||
all_button: {
|
||||
background_nine_slice: NINE_SLICE_ROUNDED,
|
||||
background_nine_slice_from_edge: NINE_SLICE_EDGE,
|
||||
background_nine_slice_color: ALPHA_GRAY,
|
||||
hover: {
|
||||
background_nine_slice_color: ALPHA_BLACK
|
||||
},
|
||||
active: {
|
||||
background_nine_slice_color: ALPHA_GRAY
|
||||
}
|
||||
},
|
||||
app_icon_button: {
|
||||
height: 1.0,
|
||||
image_height: 1.0,
|
||||
padding: HALF_PADDING,
|
||||
border_thickness_bottom: 3,
|
||||
background_nine_slice: NINE_SLICE_ROUNDED_TOP,
|
||||
background_nine_slice_from_edge: NINE_SLICE_EDGE,
|
||||
background_nine_slice_color: 0,
|
||||
disabled: { # fake disabled: styling the "selected" app uses
|
||||
color: 0xff_ffffff,
|
||||
background_nine_slice_color: 0x88_5e5c64,
|
||||
border_color_bottom: 0xff_3584e4
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module W3DHubLauncher
|
||||
NAME = "Cyberarm's W3D Hub linux Launcher".freeze
|
||||
NAME = "Cyberarm's W3D Hub Linux Launcher".freeze
|
||||
VERSION = "2.0.0".freeze
|
||||
VERSION_NAME = "alpha".freeze # TODO: Come up with a short, fun, release name
|
||||
end
|
||||
|
||||
134
lib/window.rb
134
lib/window.rb
@@ -4,118 +4,34 @@ module W3DHubLauncher
|
||||
self.show_cursor = true
|
||||
self.caption = format("%s | v%s (%s)", NAME, VERSION, VERSION_NAME) # "Cyberarm's W3D Hub Linux Launcher | v2.0.0 alpha"
|
||||
|
||||
push_state(Interface)
|
||||
end
|
||||
end
|
||||
@main_thread_queue = []
|
||||
|
||||
class Interface < CyberarmEngine::GuiState
|
||||
include W3DHubLauncher::GuiExt
|
||||
|
||||
def setup
|
||||
theme(THEME)
|
||||
|
||||
# root container - background image
|
||||
stack(width: 1.0, height: 1.0, background_image: safe_get_image("/run/media/cyberarm/Storage/W3DHub/Launcher/package-cache/games/apb/background.png.package"), background_image_mode: :fill) do
|
||||
# root container - background image tint
|
||||
flow(width: 1.0, height: 1.0, background: 0xaa_000000) do
|
||||
# content container
|
||||
stack(fill: true, height: 1.0, margin: PADDING, margin_right: LARGE_PADDING) do
|
||||
# header bar container
|
||||
flow(width: 1.0, height: 80, margin_bottom: PADDING) do |c|
|
||||
# logo + menu button
|
||||
button(safe_get_image("./media/logo.png"), image_height: 1.0, background: 0, border_color: 0, hover: { background: 0 }, active: { background: 0, color: 0xff_ffffff }) do |btn|
|
||||
menu(parent: btn) do
|
||||
menu_item("Settings")
|
||||
menu_item("About") do
|
||||
dialog(Dialog::About)
|
||||
end
|
||||
menu_item("Exit") do
|
||||
window.close
|
||||
end
|
||||
end.show
|
||||
end
|
||||
|
||||
stack(fill: true, height: 1.0) do
|
||||
stack(fill: true)
|
||||
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) }
|
||||
stack(fill: true)
|
||||
image safe_get_image("./media/icons/import.png"), height: 40, color: 0xff_bbbbbb
|
||||
image safe_get_image("./media/icons/information.png"), height: 40, color: 0xff_bbbbbb
|
||||
end
|
||||
stack(fill: true)
|
||||
end
|
||||
end
|
||||
|
||||
# page content container
|
||||
@page_host = stack(width: 1.0, fill: true) do
|
||||
end
|
||||
end
|
||||
|
||||
# battleview/friends container
|
||||
stack(width: 300, height: 1.0, margin: PADDING, margin_left: 0) do
|
||||
# self account container
|
||||
flow(width: 1.0, height: 80) do
|
||||
# self avatar container
|
||||
stack(width: 80, height: 1.0, background_image: rounded_avatar(safe_get_image("./media/default.png"))) do
|
||||
# self online state container
|
||||
stack(width: 20, height: 20, v_align: :bottom, h_align: :right, background_image: safe_get_image("./media/ui/circle_small.png"), background_image_color: 0xff_26a269)
|
||||
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
|
||||
# self set online state
|
||||
link "Online ▼", text_size: 18 do |l|
|
||||
menu(parent: l) do
|
||||
menu_item("Online")
|
||||
menu_item("Do Not Disturb")
|
||||
menu_item("Away")
|
||||
menu_item("Invisible")
|
||||
menu_item("Sign Out")
|
||||
end.show
|
||||
end
|
||||
flow(fill: true)
|
||||
end
|
||||
end
|
||||
|
||||
# friend management container
|
||||
flow(width: 1.0, height: 60, margin_top: PADDING) do
|
||||
flow(width: 1.0, v_align: :center) do
|
||||
button safe_get_image("./media/icons/singleplayer.png"), image_height: 1.0
|
||||
button safe_get_image("./media/icons/gear.png"), image_height: 1.0, margin_left: HALF_PADDING
|
||||
edit_line "", margin_left: HALF_PADDING, fill: true, height: 1.0
|
||||
end
|
||||
end
|
||||
|
||||
# friends/clanmates list container
|
||||
stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do
|
||||
50.times do |i|
|
||||
# friend container
|
||||
flow(width: 1.0, height: 48, padding_top: HALF_PADDING, padding_bottom: HALF_PADDING, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: 0, hover: { background_nine_slice_color: 0x44_000000 }) do
|
||||
# friend avatar container
|
||||
stack(width: 48, height: 1.0, margin_left: HALF_PADDING, background_image: rounded_avatar(safe_get_image("./media/default.png"))) do
|
||||
stack(width: 12, height: 12, v_align: :bottom, h_align: :right, background_image: safe_get_image("./media/ui/circle_small.png"), background_image_color: 0xff_26a269)
|
||||
end
|
||||
# friend name and status container
|
||||
stack(fill: true, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do
|
||||
stack(v_align: :center) do
|
||||
caption ["Silverlight", "PXD2000", "Alstar", "SteelGhost", "FRAYDO"].sample, text_wrap: :none
|
||||
inscription "RA_Under • 13:52", text_wrap: :none, margin_top: -HALF_PADDING
|
||||
end
|
||||
end
|
||||
# friend active application container
|
||||
stack(width: 48, height: 1.0, margin_right: HALF_PADDING, background_image: safe_get_image("./media/logo.png"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
CyberarmEngine::EventBus.subscribe("game_server_pings", self) do |payload|
|
||||
MemCache[:game_server_pings] = payload
|
||||
end
|
||||
|
||||
page(Page::Games)
|
||||
push_state(States::Boot)
|
||||
# push_state(States::Interface)
|
||||
end
|
||||
|
||||
def needs_redraw?
|
||||
states.any?(&:needs_repaint?)
|
||||
end
|
||||
|
||||
def update
|
||||
while(block = @main_thread_queue.shift)
|
||||
block.call
|
||||
end
|
||||
|
||||
WORKER.service
|
||||
|
||||
super
|
||||
|
||||
sleep 0.001
|
||||
end
|
||||
|
||||
def add_to_queue(block)
|
||||
@main_thread_queue << block
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
464
lib/worker.rb
464
lib/worker.rb
@@ -1,24 +1,79 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
def initialize
|
||||
@threads = []
|
||||
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
|
||||
end
|
||||
|
||||
def connect
|
||||
puts :connect
|
||||
|
||||
@buffer = StringIO.new
|
||||
@socket = UNIXSocket.new(IPC_PATH)
|
||||
end
|
||||
|
||||
def connected?
|
||||
@socket && !@socket.closed?
|
||||
end
|
||||
|
||||
def listen
|
||||
init_server
|
||||
end
|
||||
|
||||
def init_server
|
||||
@threads = []
|
||||
@requests = []
|
||||
@tasks = []
|
||||
|
||||
@client = nil
|
||||
|
||||
@settings = nil
|
||||
@game_servers = []
|
||||
@host_pings = {}
|
||||
|
||||
# listen for requests from frontend
|
||||
listener = Thread.new { listen }
|
||||
# connect to and monitor GSH web service
|
||||
@threads << Thread.new { game_server_hub_websocket }
|
||||
# connect to and monitor Backend web service
|
||||
@threads << Thread.new { backend_websocket }
|
||||
# poke game servers to ascertain their ping relative to the local machine
|
||||
@threads << Thread.new { ping_game_servers }
|
||||
|
||||
Ractor.main.send({ message: "3 o'clock 'nd all's well!" })
|
||||
@w3dhub_api = W3DHubLauncher::W3DHubApi.new
|
||||
|
||||
listener.join
|
||||
end
|
||||
Async do |task|
|
||||
UNIXServer.open(IPC_PATH) do |server|
|
||||
while(socket = server.accept)
|
||||
@client = socket
|
||||
|
||||
def listen
|
||||
loop do
|
||||
request = Ractor.receive
|
||||
pp request
|
||||
task.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
|
||||
ensure # manually delete "socket"
|
||||
File.delete(IPC_PATH)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,5 +82,392 @@ module W3DHubLauncher
|
||||
|
||||
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)
|
||||
|
||||
Async do |task|
|
||||
while true
|
||||
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, <checksum>, <identifier>, <sequence number>, 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
|
||||
|
||||
if respond_to?(query.type)
|
||||
@socket.puts(payload)
|
||||
else
|
||||
raise "UNKNOWN REQUEST: #{query}"
|
||||
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
|
||||
})
|
||||
# FIXME: Send intermediate response to requester
|
||||
# send(query, Response.new(Request::STATUS_IN_PROGRESS, query.request_id progress_result))
|
||||
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::CONFIG_PATH}/settings.json", query.data)
|
||||
|
||||
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
|
||||
)
|
||||
@tasks.last.start(self)
|
||||
|
||||
Response.new(Request::STATUS_PENDING, query.request_id, CyberarmEngine::Result.new(data: true))
|
||||
end
|
||||
|
||||
def task_update_application(query)
|
||||
end
|
||||
|
||||
def task_repair_application(query)
|
||||
end
|
||||
|
||||
def task_move_application(query)
|
||||
end
|
||||
|
||||
def task_uninstall_application(query)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,78 +1,104 @@
|
||||
# Helper for launcher frontend to safely communicate with ractor (prevent deadlocks and concurrent access errors)
|
||||
# Helper for launcher frontend to safely communicate with launcher backend
|
||||
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
Request = Data.define(:coming_soon)
|
||||
|
||||
def initialize
|
||||
@requests = []
|
||||
end
|
||||
|
||||
# downloads requested resource, returns raw string
|
||||
def fetch_url
|
||||
def self.fetch_url
|
||||
end
|
||||
|
||||
# downloads requested resource, periodically reporting progress until completion, returning path for file on disk
|
||||
def download_url
|
||||
def self.download_url(url, path, method = :get, headers = nil, body = nil, &block)
|
||||
Worker::Request.new(:download_url, { url: url, path: path, method: method, headers: headers, body: body }, &block)
|
||||
end
|
||||
|
||||
def self.ico_to_png(ico_path:, png_path:, &block)
|
||||
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
|
||||
|
||||
# returns user account data
|
||||
#
|
||||
# automatically handles signing in / refreshing token (DOES NOT remove account data if failed to refresh token due to network timeout)
|
||||
def account
|
||||
def self.account
|
||||
end
|
||||
|
||||
# returns launcher settings
|
||||
def settings
|
||||
def self.settings
|
||||
end
|
||||
|
||||
# returns raw json of launcher settings
|
||||
def self.load_settings(&block)
|
||||
Worker::Request.new(:load_settings, "", &block)
|
||||
end
|
||||
|
||||
# write updated launcher settings
|
||||
def update_settings
|
||||
def self.update_settings(settings, &block)
|
||||
Worker::Request.new(:update_settings, settings.to_json, &block)
|
||||
end
|
||||
|
||||
# returns list of available applications
|
||||
#
|
||||
# if updated list is requested, return cached version immediately and then the updated list later.
|
||||
def applications
|
||||
def self.applications(&block)
|
||||
W3DHubLauncher::Worker::Request.new(:w3dhub_api_call, { call: :fetch_applications }, &block)
|
||||
end
|
||||
|
||||
# returns current list of servers as reported from GSH / cache
|
||||
def servers
|
||||
def self.servers(&block)
|
||||
Worker::Request.new(:servers, "", &block)
|
||||
end
|
||||
|
||||
# returns news for application
|
||||
def news
|
||||
def self.news(category = "launcher-home", &block)
|
||||
W3DHubLauncher::Worker::Request.new(:w3dhub_api_call, { call: :fetch_news, arguments: [category] }, &block)
|
||||
end
|
||||
|
||||
# returns news for application
|
||||
def self.events(category, &block)
|
||||
W3DHubLauncher::Worker::Request.new(:w3dhub_api_call, { call: :fetch_events, arguments: [category] }, &block)
|
||||
end
|
||||
|
||||
# request installation of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def install_application
|
||||
def self.install_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_install_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
|
||||
# request update of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def update_application
|
||||
def self.update_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_update_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
|
||||
# request repair of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def repair_application
|
||||
def self.repair_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_repair_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
|
||||
# request relocation of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def move_application
|
||||
def self.move_application(app_id, channel_id, target_directory, &block)
|
||||
Worker::Request.new(:task_move_application, { app_id: app_id, channel_id: channel_id, target_directory: target_directory }, &block)
|
||||
end
|
||||
|
||||
# request removal of application
|
||||
#
|
||||
# periodically reports progress until completion
|
||||
def uninstall_application
|
||||
def self.uninstall_application(app_id, channel_id, &block)
|
||||
Worker::Request.new(:task_uninstall_application, { app_id: app_id, channel_id: channel_id }, &block)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
133
lib/worker/api/application.rb
Normal file
133
lib/worker/api/application.rb
Normal file
@@ -0,0 +1,133 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class Application
|
||||
attr_reader :id, :category, :name, :type, :channels, :extended_data, :web_links
|
||||
|
||||
def initialize(data)
|
||||
# pp data
|
||||
|
||||
@id = data["id"]
|
||||
@category = data["category"]
|
||||
@name = data["name"]
|
||||
@type = data["type"]
|
||||
|
||||
@channels = data["channels"]&.map { |c| Channel.new(c, self) } || []
|
||||
|
||||
@extended_data = data["extended-data"]&.map { |d| ExtendedData.new(d) } || nil
|
||||
@web_links = data["web-links"]&.map { |d| WebLink.new(d) } || nil
|
||||
|
||||
@servicable = extended_data("application.isservicable", "true").strip.downcase == "true"
|
||||
end
|
||||
|
||||
def extended_data(key, default)
|
||||
v = @extended_data&.find { |d| d.name == key }&.value
|
||||
return default if v.nil?
|
||||
|
||||
v
|
||||
end
|
||||
|
||||
# libgosu friendly color
|
||||
def color
|
||||
"ff_#{extended_data("colour","#000000").sub("#", "")}".to_i(16)
|
||||
end
|
||||
|
||||
def game?
|
||||
@type.include?("game")
|
||||
end
|
||||
|
||||
def servicable?
|
||||
@servicable
|
||||
end
|
||||
|
||||
def to_json(context = nil)
|
||||
{
|
||||
"id": @id,
|
||||
"category": @category,
|
||||
"name": @name,
|
||||
"type": @type,
|
||||
"channels": @channels,
|
||||
"extended-data": @extended_data,
|
||||
"web-links": @web_links
|
||||
}.to_json(context)
|
||||
end
|
||||
|
||||
class Channel
|
||||
attr_reader :id, :name, :user_level, :version, :extended_data
|
||||
|
||||
def initialize(data, app)
|
||||
@_app = app
|
||||
|
||||
@id = data["id"]
|
||||
@name = data["name"]
|
||||
@user_level = data["user-level"]
|
||||
@version = Gem::Version.new(data["current-version"])
|
||||
@extended_data = data["extended-data"]&.map { |d| ExtendedData.new(d) } || nil
|
||||
end
|
||||
|
||||
def extended_data(key, default)
|
||||
v = @extended_data&.find { |d| d.name == key }&.value
|
||||
if v.nil?
|
||||
v = @_app.extended_data(key, default)
|
||||
|
||||
return default if v.nil?
|
||||
end
|
||||
|
||||
v
|
||||
end
|
||||
|
||||
def server_channel
|
||||
extended_data("serverchannel", @id)
|
||||
end
|
||||
|
||||
def to_json(context = nil)
|
||||
{
|
||||
"id": @id,
|
||||
"name": @name,
|
||||
"user-level": @user_level,
|
||||
"current-version": @version.to_s,
|
||||
"extended-data": @extended_data
|
||||
}.to_json(context)
|
||||
end
|
||||
end
|
||||
|
||||
class ExtendedData
|
||||
attr_reader :name, :value
|
||||
|
||||
def initialize(data)
|
||||
if data.is_a?(Hash)
|
||||
@name = data["name"]
|
||||
@value = data["value"]
|
||||
else
|
||||
@name = data[0]
|
||||
@value = data[1]
|
||||
end
|
||||
end
|
||||
|
||||
def to_json(context = nil)
|
||||
{
|
||||
"name": @name,
|
||||
"value": @value
|
||||
}.to_json(context)
|
||||
end
|
||||
end
|
||||
|
||||
class WebLink
|
||||
attr_reader :name, :uri
|
||||
|
||||
def initialize(data)
|
||||
@name = data["name"]
|
||||
@uri = data["uri"]
|
||||
end
|
||||
|
||||
def to_json(context = nil)
|
||||
{
|
||||
"name": @name,
|
||||
"uri": @uri
|
||||
}.to_json(context)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
129
lib/worker/api/game_server.rb
Normal file
129
lib/worker/api/game_server.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
# 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,
|
||||
:match_start_time, :estimated_end_time, :match_remaining_time,
|
||||
:teams, :next_map, :version
|
||||
|
||||
def initialize(data)
|
||||
# main data
|
||||
@id = data["id"]
|
||||
@game = data["game"]
|
||||
@channel = data["channel"]
|
||||
@address = data["address"]
|
||||
@port = data["port"]
|
||||
@region = data["region"]
|
||||
|
||||
# status data
|
||||
@name = data["status"]["name"]
|
||||
@password = data["status"]["password"] || false
|
||||
@current_map = data["status"]["map"]
|
||||
@player_count = data["status"]["numplayers"] || 0
|
||||
@max_players = data["status"]["maxplayers"]
|
||||
|
||||
@match_start_time = data["status"]["started"]
|
||||
@estimated_end_time = data["status"]["estimatedEndTime"]
|
||||
@match_remaining_time = data["status"]["remaining"]
|
||||
|
||||
# status teams data
|
||||
@teams = []
|
||||
data["status"]["teams"].each do |team_data|
|
||||
@teams << Team.new(team_data)
|
||||
end
|
||||
|
||||
# status player data
|
||||
data["status"]["players"].each do |player_data|
|
||||
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"] || NO_OR_DEFAULT_VERSION
|
||||
@next_map = data["status"]["nextmap"] || ""
|
||||
|
||||
@version = Gem::Version.new(@version) if @version.split(".").size == 4
|
||||
end
|
||||
|
||||
# TODO: implement :)
|
||||
def time_elapsed
|
||||
"00:00"
|
||||
end
|
||||
|
||||
def password?
|
||||
@password
|
||||
end
|
||||
|
||||
def ping
|
||||
MemCache.dig(:game_server_pings, @address) || BAD_OR_UNKNOWN_PING
|
||||
end
|
||||
|
||||
def ping_score_ratio
|
||||
# under 50 is best, over 200 is worst
|
||||
case ping
|
||||
when 0..49
|
||||
return 1.0
|
||||
when 50..200
|
||||
1.0 - (ping / 200.0) + 0.1
|
||||
else
|
||||
return 0.1
|
||||
end
|
||||
end
|
||||
|
||||
def ping_score_color
|
||||
case ping
|
||||
when 0..49 # gentle green
|
||||
0xff_26a269
|
||||
when 50..149 # rich orange
|
||||
0xff_e5a50a
|
||||
when 150..200 # dark red
|
||||
0xff_a51d2d
|
||||
else # dark gray
|
||||
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
|
||||
attr_reader :id, :name, :score, :kills, :deaths, :players
|
||||
|
||||
def initialize(data)
|
||||
@id = data["id"]
|
||||
@name = data["name"]
|
||||
@score = data["score"]
|
||||
@kills = data["kills"]
|
||||
@deaths = data["deaths"]
|
||||
|
||||
@players = []
|
||||
end
|
||||
end
|
||||
|
||||
class Player
|
||||
attr_reader :name, :team, :score, :kills, :deaths, :ping, :time
|
||||
|
||||
def initialize(data)
|
||||
@name = data["nick"]
|
||||
@team = data["team"]
|
||||
@score = data["score"]
|
||||
@kills = data["kills"]
|
||||
@deaths = data["deaths"]
|
||||
|
||||
# Cyberarm GSH extras
|
||||
@ping = data["ping"] || 0
|
||||
@time = data["time"] || "00:00:00"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
95
lib/worker/api/legacy_manifest.rb
Normal file
95
lib/worker/api/legacy_manifest.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class LegacyManifest
|
||||
attr_reader :game, :type, :version, :base_version, :files, :dependencies
|
||||
|
||||
def initialize(file_path)
|
||||
@document = REXML::Document.new(File.read(file_path))
|
||||
root = @document.root
|
||||
|
||||
@game = root["game"]
|
||||
@type = root["type"]
|
||||
@version = root["version"] ? Gem::Version.new(root["version"]) : nil
|
||||
@base_version = root["baseVersion"] ? Gem::Version.new(root["baseVersion"]) : nil
|
||||
|
||||
@files = []
|
||||
@dependencies = []
|
||||
|
||||
parse_files
|
||||
parse_dependencies
|
||||
end
|
||||
|
||||
def patch?
|
||||
@type == "Patch"
|
||||
end
|
||||
|
||||
def full?
|
||||
@type == "Full"
|
||||
end
|
||||
|
||||
def parse_files
|
||||
@document.root.elements.each("//File") do |element|
|
||||
@files.push(ManifestFile.new(element, @version))
|
||||
end
|
||||
end
|
||||
|
||||
def parse_dependencies
|
||||
@document.root.elements.each("//Dependency") do |element|
|
||||
@dependencies.push(Dependency.new(element))
|
||||
end
|
||||
end
|
||||
|
||||
class ManifestFile
|
||||
attr_reader :name, :checksum, :package, :patch_package, :removed_since, :from, :version
|
||||
|
||||
def initialize(xml, version)
|
||||
@data = xml
|
||||
|
||||
@name = @data["name"]
|
||||
@checksum = @data["checksum"]
|
||||
@package = @data["package"]
|
||||
@removed_since = @data["removedsince"] ? Gem::Version.new(@data["removedsince"]) : nil
|
||||
|
||||
xml.elements.each("Patch") do |patch|
|
||||
@patch = true
|
||||
|
||||
@from = patch["from"]
|
||||
|
||||
if @package.to_s.empty?
|
||||
@package = patch["package"]
|
||||
else
|
||||
@patch_package = patch["package"]
|
||||
end
|
||||
end
|
||||
|
||||
@version = version ? Gem::Version.new(version) : nil
|
||||
end
|
||||
|
||||
def removed?
|
||||
@removed_since
|
||||
end
|
||||
|
||||
def patch?
|
||||
@patch
|
||||
end
|
||||
|
||||
def full_and_patch?
|
||||
@package && @patch_package
|
||||
end
|
||||
end
|
||||
|
||||
class Dependency
|
||||
attr_reader :name, :removed_since
|
||||
|
||||
def initialize(xml)
|
||||
@data = xml
|
||||
|
||||
@name = @data["name"]
|
||||
@removed_since = @data["removed_since"]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
72
lib/worker/api/legacy_manifest_package.rb
Normal file
72
lib/worker/api/legacy_manifest_package.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
require "json"
|
||||
require "digest/sha2"
|
||||
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class LegacyManifestPackage
|
||||
attr_reader :category, :subcategory, :version, :name, :error, :file_size,
|
||||
:checksum_chunk_size, :sha256_checksum, :checksum_chunks, :download_url
|
||||
|
||||
def initialize(hash)
|
||||
@category = hash["category"]
|
||||
@subcategory = hash["subcategory"]
|
||||
@version = Gem::Version.new(hash["version"] || "0.0.0.0")
|
||||
@name = hash["name"]
|
||||
@error = hash["error"]
|
||||
@file_size = Integer(hash["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)
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# return boolean after completely digesting file
|
||||
@sha256_checksum == overall_digest.hexdigest.upcase
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def package_cache_filename(category, subcategory)
|
||||
if subcategory.empty?
|
||||
category
|
||||
else
|
||||
"#{category}-#{subcategory}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
28
lib/worker/api/news_item.rb
Normal file
28
lib/worker/api/news_item.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class NewsItem
|
||||
attr_reader :topic_id, :title, :blurb, :uri, :image_uri, :author_id, :author, :author_uri, :timestamp
|
||||
|
||||
def initialize(data)
|
||||
@topic_id = Integer(data["topic-id"] || 0)
|
||||
@title = data["title"].to_s
|
||||
@blurb = clean_and_scrub_blurb(data["blurb"].to_s)
|
||||
@uri = data["uri"].to_s
|
||||
@image_uri = data["image"].to_s
|
||||
|
||||
@author_id = data["author-id"].to_s
|
||||
@author = data["author"].to_s
|
||||
@author_uri = data["author-uri"].to_s
|
||||
|
||||
@timestamp = Time.at(Integer(data["timestamp"] || 0), in: "UTC").localtime
|
||||
end
|
||||
|
||||
# Replaces multiple newlines with a single newline, and trims off any leading and trailing whitespace.
|
||||
def clean_and_scrub_blurb(string)
|
||||
string.gsub(/\n+/, "\n").strip
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
17
lib/worker/api/server_event.rb
Normal file
17
lib/worker/api/server_event.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class ServerEvent
|
||||
attr_reader :title, :image_uri, :app_id, :start_time, :end_time, :timestamp
|
||||
def initialize(data)
|
||||
@title = data["title"]
|
||||
@image_uri = data["image"]
|
||||
@app_id = data["server"]
|
||||
@start_time = Time.parse(data["starttime"], in: "UTC").localtime
|
||||
@end_time = Time.parse(data["endtime"], in: "UTC").localtime
|
||||
@timestamp = Time.parse(data["dateTime"], in: "UTC").localtime
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
183
lib/worker/api/settings.rb
Normal file
183
lib/worker/api/settings.rb
Normal file
@@ -0,0 +1,183 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
|
||||
# Inferred or automaticly set options
|
||||
class Settings
|
||||
SCHEMA = 0
|
||||
|
||||
attr_accessor :last_selected_app, :last_selected_app_channel, :last_selected_server_app, :preferences, :applications, :account
|
||||
|
||||
def initialize(data)
|
||||
@schema = data["schema"] || SCHEMA
|
||||
|
||||
raise "Data is not a hash!" unless data.is_a?(Hash)
|
||||
|
||||
@last_selected_app = data["last_selected_app"] || "ren"
|
||||
@last_selected_app_channel = data["last_selected_app_channel"] || "release"
|
||||
@last_selected_server_app = data["last_selected_server_app"] || ""
|
||||
|
||||
@preferences = Preferences.new(data["preferences"])
|
||||
@applications = (data["applications"] || []).map { |app_data| Application.new(app_data) }
|
||||
@account = Account.new(data["account"])
|
||||
end
|
||||
|
||||
def to_json(options = {})
|
||||
{
|
||||
schema: @schema,
|
||||
last_selected_app: @last_selected_app,
|
||||
last_selected_app_channel: @last_selected_app_channel,
|
||||
last_selected_server_app: @last_selected_server_app,
|
||||
preferences: @preferences,
|
||||
applications: @applications,
|
||||
account: @account
|
||||
}.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
|
||||
|
||||
attr_accessor :language, :nickname, :launcher_package_cache_directory, :application_installation_directory,
|
||||
:wine_prefix_path, :wine_command, :winetricks_command
|
||||
|
||||
def initialize(data)
|
||||
data ||= {}
|
||||
|
||||
@schema = data["schema"] || SCHEMA
|
||||
|
||||
@language = data["language"] || "en"
|
||||
@nickname = data["nickname"] || ""
|
||||
@launcher_package_cache_directory = data["launcher_package_cache_directory"] || DEFAULT_PACKAGE_CACHE_PATH
|
||||
@application_installation_directory = data["application_installation_directory"] || DEFAULT_APPLICATIONS_PATH
|
||||
@wine_prefix_path = data["wine_prefix_path"] || ""
|
||||
@wine_command = data["wine_command"] || "wine"
|
||||
@winetricks_command = data["winetricks_command"] || "winetricks"
|
||||
end
|
||||
|
||||
def to_json(options = {})
|
||||
{
|
||||
schema: @schema,
|
||||
language: @language,
|
||||
nickname: @nickname,
|
||||
launcher_package_cache_directory: @launcher_package_cache_directory,
|
||||
application_installation_directory: @application_installation_directory,
|
||||
wine_prefix_path: @wine_prefix_path,
|
||||
wine_command: @wine_command,
|
||||
winetricks_command: @winetricks_command
|
||||
}.to_json(options)
|
||||
end
|
||||
end
|
||||
|
||||
# Installed application data
|
||||
class Application
|
||||
SCHEMA = 0
|
||||
|
||||
attr_reader :id, :channel
|
||||
attr_accessor :version, :installation_path, :wine_prefix_path, :launch_command, :timestamp
|
||||
|
||||
def self.create(id:, channel:, version:, installation_path:, wine_prefix_path:, launch_command:, timestamp:)
|
||||
hash = {
|
||||
"schema" => SCHEMA,
|
||||
"id" => id,
|
||||
"channel" => channel,
|
||||
"version" => version,
|
||||
"installation_path" => installation_path,
|
||||
"wine_prefix_path" => wine_prefix_path,
|
||||
"launch_command" => launch_command,
|
||||
"timestamp" => timestamp
|
||||
}
|
||||
|
||||
self.new(hash)
|
||||
end
|
||||
|
||||
def initialize(data)
|
||||
@schema = data["schema"] || SCHEMA
|
||||
|
||||
@id = data["id"]
|
||||
@channel = data["channel"]
|
||||
@version = Gem::Version.new(data["version"])
|
||||
@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 = {})
|
||||
{
|
||||
schema: @schema,
|
||||
id: @id,
|
||||
channel: @channel,
|
||||
version: @version.to_s,
|
||||
installation_path: @installation_path,
|
||||
wine_prefix_path: @wine_prefix_path,
|
||||
launch_command: @launch_command,
|
||||
timestamp: @timestamp.to_i
|
||||
}.to_json(options)
|
||||
end
|
||||
end
|
||||
|
||||
# User account data
|
||||
class Account
|
||||
SCHEMA = 0
|
||||
|
||||
attr_accessor :user_id, :username, :displayname, :avatar_uri, :access_token, :refresh_token,
|
||||
:access_token_expiration_time
|
||||
|
||||
def self.create(user_id:, username:, displayname:, avatar_uri:, access_token:, refresh_token:, access_token_expiration_time:)
|
||||
hash = {
|
||||
"schema" => SCHEMA,
|
||||
"user_id" => user_id,
|
||||
"username" => username,
|
||||
"displayname" => displayname,
|
||||
"avatar_uri" => avatar_uri,
|
||||
"access_token" => access_token,
|
||||
"refresh_token" => refresh_token,
|
||||
"access_token_expiration_time" => access_token_expiration_time
|
||||
}
|
||||
|
||||
self.new(hash)
|
||||
end
|
||||
|
||||
def initialize(data)
|
||||
data ||= {}
|
||||
|
||||
@schema = data["schema"] || SCHEMA
|
||||
|
||||
@user_id = data["user_id"] || -1
|
||||
@username = data["username"] || ""
|
||||
@displayname = data["displayname"] || ""
|
||||
@avatar_uri = data["avatar_uri"] || ""
|
||||
@access_token = data["access_token"] || ""
|
||||
@refresh_token = data["refresh_token"] || ""
|
||||
@access_token_expiration_time = Time.at(data["access_token_expiration_time"] || 0)
|
||||
end
|
||||
|
||||
def signed_in?
|
||||
@user_id.positive?
|
||||
end
|
||||
|
||||
def to_json(options = {})
|
||||
{
|
||||
schema: @schema,
|
||||
user_id: @user_id,
|
||||
username: @username,
|
||||
displayname: @displayname,
|
||||
avatar_uri: @avatar_uri,
|
||||
access_token: @access_token,
|
||||
refresh_token: @refresh_token,
|
||||
access_token_expiration_time: @access_token_expiration_time.to_i
|
||||
}.to_json(options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
19
lib/worker/api/test_event.rb
Normal file
19
lib/worker/api/test_event.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Api
|
||||
class TestEvent
|
||||
attr_reader :title, :image_uri, :timestamp
|
||||
|
||||
def initialize(data)
|
||||
@title = data["name"]
|
||||
# @title = data["title"]
|
||||
@image_uri = data["image"]
|
||||
# @app_id = data["server"]
|
||||
# @start_time = Time.parse(data["starttime"]).localtime
|
||||
# @end_time = Time.parse(data["endtime"]).localtime
|
||||
@timestamp = Time.parse(data["dateTime"], in: "UTC").localtime
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
60
lib/worker/request.rb
Normal file
60
lib/worker/request.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
module W3DHubLauncher
|
||||
class Worker
|
||||
class Request
|
||||
Query = Data.define(:type, :request_id, :data)
|
||||
|
||||
STATUS_ERROR = -1 # request has failed
|
||||
STATUS_PENDING = 0 # request has not yet started
|
||||
STATUS_OK = 1 # request completed successfully
|
||||
STATUS_COMPLETE = STATUS_OK
|
||||
STATUS_IN_PROGRESS = 2 # request is in progress
|
||||
STATUS_BUSY = STATUS_IN_PROGRESS
|
||||
|
||||
# NOT "Thread"/Ractor safe
|
||||
@request_id = 0
|
||||
@requests = []
|
||||
|
||||
# NOT "Thread"/Ractor safe. Only call from main ractor
|
||||
# returns next available request id, and auto increments by 1
|
||||
def self.request_id
|
||||
@request_id += 1
|
||||
end
|
||||
|
||||
# NOT "Thread"/Ractor safe.
|
||||
# returns an array of pending requests
|
||||
def self.requests
|
||||
@requests
|
||||
end
|
||||
|
||||
attr_reader :type, :data, :request_id
|
||||
|
||||
def initialize(type, data, request_id: Request.request_id, &block)
|
||||
@type = type.freeze
|
||||
@data = data.freeze
|
||||
@status = STATUS_PENDING
|
||||
|
||||
@request_id = request_id
|
||||
@callback = block # only called on error or success
|
||||
|
||||
enqueue(@type, @request_id, @data)
|
||||
end
|
||||
|
||||
def enqueue(type, id, data)
|
||||
Request.requests << self
|
||||
W3DHubLauncher::WORKER.request(Query.new(type, id, data))
|
||||
end
|
||||
|
||||
# event from Worker received
|
||||
def handle_event(event, data)
|
||||
# pp [event, data]
|
||||
|
||||
@callback&.call(data, event)
|
||||
|
||||
case event
|
||||
when STATUS_ERROR, STATUS_COMPLETE
|
||||
Request.requests.delete(self)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,561 @@
|
||||
module W3DHubLauncher
|
||||
class Task
|
||||
# ignore *.meta and paths.ini files
|
||||
IGNORED_FILES = [/\A.+\.meta\z/i, /\A.+\/paths\.ini\z/i].freeze
|
||||
|
||||
IndexedFile = Data.define(:type, :path)
|
||||
|
||||
attr_reader :request_id, :application, :channel, :installed_version, :target_version
|
||||
|
||||
def initialize(request_id:, application:, channel:, installed_version:, target_version:)
|
||||
@request_id = request_id
|
||||
@application = application
|
||||
@channel = channel
|
||||
@installed_version = installed_version
|
||||
@target_version = target_version
|
||||
|
||||
pp self
|
||||
|
||||
# caching mechinism for file path normalization
|
||||
@file_index = {}
|
||||
# manifests!
|
||||
@manifests = {}
|
||||
# files that must exist for a valid installation
|
||||
@manifest_files = []
|
||||
# files that should be removed
|
||||
@deleted_manifest_files = []
|
||||
# files needed to make installation valid for target version
|
||||
@required_manifest_files = []
|
||||
# packages to verify and/or download
|
||||
@packages = []
|
||||
|
||||
setup
|
||||
end
|
||||
|
||||
def setup
|
||||
end
|
||||
|
||||
def start(worker)
|
||||
@worker = worker
|
||||
|
||||
@installation_directory ||= application_target_installation_directory
|
||||
|
||||
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)
|
||||
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
|
||||
format("%s/%s/%s", @worker.settings.preferences.application_installation_directory, @application.id, @channel.id)
|
||||
end
|
||||
end
|
||||
|
||||
def package_cache_path(package)
|
||||
directory = @worker.settings.preferences.launcher_package_cache_directory
|
||||
|
||||
if package.version?
|
||||
format("%s/%s/%s/%s", directory, @application.id, package.version.to_s, package.name)
|
||||
else
|
||||
format("%s/%s/%s", directory, @application.id, package.name)
|
||||
end
|
||||
end
|
||||
|
||||
def ignored_file?(path)
|
||||
IGNORED_FILES.any? { |regex| path.match?(regex) }
|
||||
end
|
||||
|
||||
def create_directory(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
# directory doesn't exists, create it!
|
||||
unless File.directory?(path)
|
||||
FileUtils.mkdir_p(path)
|
||||
|
||||
add_directory_to_file_index(path)
|
||||
end
|
||||
end
|
||||
|
||||
# add EXISTING file or directory to index
|
||||
def add_to_file_index(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
@file_index[path.downcase] = IndexedFile.new(File.directory?(path) ? :directory : :file, path)
|
||||
end
|
||||
|
||||
# add EXISTING directory to file index, walking up the directory tree until existing directory entries are found
|
||||
# NOTE: this will **NOT** "scan" the directory for new directories or files
|
||||
def add_directory_to_file_index(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
segments = path.split("/")
|
||||
sub_path = path
|
||||
until (file_index = @file_index[sub_path.downcase])
|
||||
@file_index[sub_path.downcase]
|
||||
|
||||
segments.pop
|
||||
|
||||
break if segments.empty?
|
||||
|
||||
sub_path = segments.join("/")
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_file_index(path)
|
||||
path.gsub!("\\", "/")
|
||||
|
||||
@file_index.delete(path.downcase)
|
||||
end
|
||||
|
||||
# And behold, the backslashes were slain and the path deemed sane!
|
||||
def normalize_path(base_path)
|
||||
base_path = base_path.gsub("\\", "/")
|
||||
path = "#{@installation_directory}/#{base_path}".gsub("\\", "/")
|
||||
|
||||
# try to find exact, existing match
|
||||
index_file = @file_index[path.downcase]
|
||||
return index_file.path if index_file
|
||||
|
||||
# try to find directory structure
|
||||
if (index_directory = @file_index[File.dirname(path).downcase])
|
||||
return format("%s/%s", index_directory.path, File.basename(path))
|
||||
end
|
||||
|
||||
# walk directories to see if we have a partial path match
|
||||
sub_paths = base_path.split("/")
|
||||
sub_paths.pop # drop file name from sub path reconstruction
|
||||
unless sub_paths.empty?
|
||||
partial_index_directory = nil
|
||||
sub_path = format("%s/%s", @installation_directory, sub_paths.shift).downcase
|
||||
while (partial_index_directory = @file_index[sub_path.downcase])
|
||||
sub_path = format("%s/%s", sub_path, sub_paths.shift)
|
||||
end
|
||||
|
||||
if partial_index_directory
|
||||
if sub_paths.empty? # we've found the path!
|
||||
return format("%s/%s", partial_index_directory.path, File.basename(base_path))
|
||||
else # we has a directory or two that don't exist, downcase them, and then head out to the pub!
|
||||
return format("%s/%s/%s", partial_index_directory.path, sub_paths.map(&:downcase).join("/"), File.basename(base_path))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# path doesn't exist, convert directories to lower-case, and use provided file case
|
||||
if File.basename(path) == base_path # base_path doesn't have a sub directory
|
||||
format("%s/%s", File.dirname(path), File.basename(path))
|
||||
else # base_path has sub directory/directories
|
||||
format("%s/%s/%s", @installation_directory, File.dirname(base_path).downcase, File.basename(path))
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_manifest(version)
|
||||
result = @worker.w3dhub_api.fetch_package_details([
|
||||
{
|
||||
category: "games",
|
||||
subcategory: @application.id,
|
||||
name: "manifest.xml",
|
||||
version: version
|
||||
}
|
||||
])
|
||||
|
||||
return result unless result.okay?
|
||||
|
||||
response = JSON.parse(result.data)
|
||||
manifest_package = response["packages"]&.map { |item| Worker::Api::LegacyManifestPackage.new(item) }&.first
|
||||
|
||||
if manifest_package.nil? || manifest_package.error?
|
||||
pp manifest_package
|
||||
abort_task!("Failed to fetch package details for manifest #{version}")
|
||||
end
|
||||
|
||||
# FIXME: check if locally downloaded manifest is still valid, if present.
|
||||
file_path = package_cache_path(manifest_package)
|
||||
|
||||
create_directory(File.dirname(file_path))
|
||||
|
||||
result = if manifest_package.download_url
|
||||
@worker.w3dhub_api.download(manifest_package.download_url, path: file_path)
|
||||
else
|
||||
# TODO xD
|
||||
@worker.w3dhub_api.fetch_package("TODO")
|
||||
end
|
||||
|
||||
if result.okay?
|
||||
return Worker::Api::LegacyManifest.new(package_cache_path(manifest_package))
|
||||
else
|
||||
pp result
|
||||
abort_task!("Failed to fetch manifest #{version}")
|
||||
end
|
||||
end
|
||||
|
||||
def apply_patch(manifest_file, package)
|
||||
Tempfile.create do |f|
|
||||
package_path = package_cache_path(package)
|
||||
|
||||
puts "unpacking patch..."
|
||||
unzip(package_path, f.path)
|
||||
|
||||
puts "reading patch data.."
|
||||
patch_mix = W3DHubLauncher::WWMix.new(path: f.path)
|
||||
raise patch_mix.error_reason unless patch_mix.load
|
||||
|
||||
patch_entry = patch_mix.entries.find { |e| e.name.casecmp?(".w3dhub.patch") || e.name.casecmp?(".bhppatch") }
|
||||
patch_entry.read
|
||||
# "remove" patch meta file from patch before copying patch data
|
||||
patch_mix.entries.delete(patch_entry)
|
||||
|
||||
patch_info = JSON.parse(patch_entry.blob)
|
||||
|
||||
puts "loading target mix metadata... (#{normalize_path(manifest_file.name)})"
|
||||
target_mix = W3DHubLauncher::WWMix.new(path: normalize_path(manifest_file.name))
|
||||
raise target_mix.error_reason unless target_mix.load
|
||||
|
||||
patch_info["removedFiles"].each do |file|
|
||||
puts "removing file from target: #{file}"
|
||||
target_mix.entries.delete_if { |e| e.name.casecmp?(file) }
|
||||
end
|
||||
|
||||
patch_info["updatedFiles"].each do |file|
|
||||
puts "adding/updating file from target: #{file}"
|
||||
patch_mix.entries.each do |entry|
|
||||
target_mix.add_entry(entry: entry, replace: true)
|
||||
end
|
||||
end
|
||||
|
||||
Tempfile.create do |temp|
|
||||
temp_mix = W3DHubLauncher::WWMix.new(path: temp.path, encrypted: target_mix.encrypted?)
|
||||
target_mix.entries.each { |e| temp_mix.add_entry(entry: e, replace: true) }
|
||||
raise temp_mix.error_reason unless temp_mix.save
|
||||
|
||||
puts "copying file..."
|
||||
FileUtils.cp(temp.path, normalize_path(manifest_file.name))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def unzip(zip_path, output_path = nil)
|
||||
stream = Zip::InputStream.new(File.open(zip_path))
|
||||
|
||||
while(entry = stream.get_next_entry)
|
||||
file_path = output_path || normalize_path(entry.name)
|
||||
|
||||
next if ignored_file?(file_path)
|
||||
|
||||
pp file_path
|
||||
|
||||
create_directory(File.dirname(file_path)) unless output_path
|
||||
|
||||
File.open(file_path, "wb") do |f|
|
||||
entry_stream = entry.get_input_stream
|
||||
|
||||
while(chunk = entry_stream.read(4_194_304))
|
||||
f.write(chunk)
|
||||
end
|
||||
end
|
||||
|
||||
add_to_file_index(file_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# def execute_task
|
||||
# show_application_taskbar
|
||||
|
||||
# fail_fast
|
||||
# return false if failed?
|
||||
|
||||
# fetch_manifests
|
||||
# return false if failed?
|
||||
|
||||
# build_package_list
|
||||
# return false if failed?
|
||||
|
||||
# remove_deleted_files
|
||||
# return false if failed?
|
||||
|
||||
# verify_files
|
||||
# return false if failed?
|
||||
|
||||
# fetch_packages
|
||||
# return false if failed?
|
||||
|
||||
# verify_packages
|
||||
# return false if failed?
|
||||
|
||||
# unpack_packages
|
||||
# return false if failed?
|
||||
|
||||
# create_wine_prefix
|
||||
# return false if failed?
|
||||
|
||||
# install_dependencies
|
||||
# return false if failed?
|
||||
|
||||
# write_paths_ini
|
||||
# return false if failed?
|
||||
|
||||
# mark_application_installed
|
||||
# return false if failed?
|
||||
|
||||
# sleep 1
|
||||
# hide_application_taskbar
|
||||
|
||||
# true
|
||||
# end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
module W3DHubLauncher
|
||||
class Task
|
||||
class InstallApplication < Task
|
||||
def setup
|
||||
end
|
||||
|
||||
def execute
|
||||
# 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
|
||||
fetch_manifests
|
||||
|
||||
# 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
|
||||
end
|
||||
|
||||
0
lib/worker/tasks/move_application.rb
Normal file
0
lib/worker/tasks/move_application.rb
Normal file
@@ -0,0 +1,166 @@
|
||||
module W3DHubLauncher
|
||||
class W3DHubApi
|
||||
API_TIMEOUT = 30 # seconds
|
||||
API_CONNECT_TIMEOUT = 10 # seconds
|
||||
|
||||
PRIMARY_W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze
|
||||
ALTERNATIVE_W3DHUB_API_ENDPOINT = "https://backend.w3d.cyberarm.dev".freeze
|
||||
|
||||
attr_reader :applications
|
||||
|
||||
def initialize
|
||||
@access_token = nil
|
||||
|
||||
@applications = []
|
||||
|
||||
@http_clients = {}
|
||||
end
|
||||
|
||||
def headers(form_encoded: false, range: nil)
|
||||
array = [
|
||||
["user-agent", W3DHubLauncher::USER_AGENT],
|
||||
["accept", "application/json"],
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
array
|
||||
end
|
||||
|
||||
# return raw response to requester
|
||||
def fetch(url, method: :get, body: nil, headers: headers())
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
Sync do |task|
|
||||
task.with_timeout(API_TIMEOUT) do
|
||||
Async::HTTP::Internet.send(method, url, headers, body) do |response|
|
||||
pp [method, url, headers, body, response]
|
||||
if response.success?
|
||||
result.data = response.read
|
||||
else
|
||||
result.error = true
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
result.error = e
|
||||
rescue Async::TimeoutError => e
|
||||
result.error = e
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# write response to file, periodically reporting progress to requester
|
||||
def download(url, path:, method: :get, body: nil, headers: headers(), &block)
|
||||
result = CyberarmEngine::Result.new
|
||||
|
||||
Sync do |task|
|
||||
Async::HTTP::Internet.send(method, url, headers, body) do |response|
|
||||
if response.success?
|
||||
content_length = response.headers["content-length"] || 0
|
||||
|
||||
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?
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
result.data = true
|
||||
end
|
||||
rescue StandardError => e
|
||||
result.error = e
|
||||
rescue Async::TimeoutError
|
||||
result.error = e
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def user_login()
|
||||
result = CyberarmEngine::Result.new
|
||||
end
|
||||
|
||||
def refresh_user_login()
|
||||
result = CyberarmEngine::Result.new
|
||||
end
|
||||
|
||||
def fetch_user_details()
|
||||
result = CyberarmEngine::Result.new
|
||||
end
|
||||
|
||||
def fetch_applications
|
||||
result = CyberarmEngine::Result.new
|
||||
# If we're not signed in, then the primary backend will try to redirect us to the alternate backend. So skip it if we're not signed in.
|
||||
primary_result = @access_token ? fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/launcher/1/get-applications") : CyberarmEngine::Result.new(error: true)
|
||||
alternate_result = fetch("#{ALTERNATIVE_W3DHUB_API_ENDPOINT}/apis/launcher/1/get-applications")
|
||||
|
||||
single_source = false
|
||||
|
||||
# We've failed to retrieve data
|
||||
if primary_result.error? && alternate_result.error?
|
||||
return result
|
||||
# We've only gotten data back from the primary backend
|
||||
elsif primary_result.okay? && alternate_result.error?
|
||||
result.data = primary_result.data
|
||||
single_source = true
|
||||
# We've only gotten data back from the alternate backend
|
||||
elsif primary_result.error? && alternate_result.okay?
|
||||
result.data = alternate_result.data
|
||||
single_source = true
|
||||
end
|
||||
|
||||
unless single_source
|
||||
puts "FIXME: merge applications source"
|
||||
# We've gotten data back from both backends, merge them.
|
||||
# pp [primary_result, alternate_result]
|
||||
|
||||
# FIXME: Merge primary and alternate results
|
||||
end
|
||||
|
||||
@applications = JSON.parse(result.data)["applications"]&.map { |app| W3DHubLauncher::Worker::Api::Application.new(app) } || []
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def fetch_news(category)
|
||||
fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/w3dhub/1/get-news", method: :post, body: "data={\"category\":\"#{category}\"}", headers: headers(form_encoded: true))
|
||||
end
|
||||
|
||||
def fetch_events(category)
|
||||
fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/w3dhub/1/get-server-events", method: :post, body: "data={\"serverPath\":\"#{category}\"}", headers: headers(form_encoded: true))
|
||||
end
|
||||
|
||||
def fetch_test_events(category)
|
||||
fetch("#{PRIMARY_W3DHUB_API_ENDPOINT}/apis/w3dhub/1/get-testing-times")
|
||||
end
|
||||
|
||||
def fetch_manifest()
|
||||
result = CyberarmEngine::Result.new
|
||||
end
|
||||
|
||||
def fetch_package_details(packages)
|
||||
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
|
||||
fetch("#{@access_token ? PRIMARY_W3DHUB_API_ENDPOINT : ALTERNATIVE_W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package-details", method: :post, body: body, headers: headers(form_encoded: true))
|
||||
end
|
||||
|
||||
def download_package()
|
||||
download()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
287
lib/ww_mix.rb
Normal file
287
lib/ww_mix.rb
Normal file
@@ -0,0 +1,287 @@
|
||||
require "digest"
|
||||
require "stringio"
|
||||
|
||||
module W3DHubLauncher
|
||||
# Reimplementating MIX1 reader/writer with years more
|
||||
# experience working with these formats and having the
|
||||
# advantage of being able to reference the renegade source
|
||||
# code :)
|
||||
class WWMix
|
||||
MIX1_HEADER = 0x3158494D
|
||||
MIX2_HEADER = 0x3258494D
|
||||
|
||||
MixHeader = Struct.new(
|
||||
:mime_type, # int32
|
||||
:file_data_offset, # int32
|
||||
:file_names_offset, # int32
|
||||
:_reserved # int32
|
||||
)
|
||||
|
||||
EntryInfoHeader = Struct.new(
|
||||
:crc32, # uint32
|
||||
:content_offset, # uint32
|
||||
:content_length # uint32
|
||||
)
|
||||
|
||||
class Entry
|
||||
attr_accessor :path, :name, :info, :blob, :is_blob
|
||||
|
||||
def initialize(name:, path:, info:, blob: nil)
|
||||
@name = name
|
||||
@path = path
|
||||
@info = info
|
||||
@blob = blob
|
||||
|
||||
@info.content_length = blob.size if blob?
|
||||
end
|
||||
|
||||
def blob?
|
||||
@blob
|
||||
end
|
||||
|
||||
def calculate_crc32
|
||||
Digest::CRC32.hexdigest(@name.upcase).upcase.to_i(16)
|
||||
end
|
||||
|
||||
# Write entry's data to stream.
|
||||
# Caller is responsible for ensuring stream is valid for writing
|
||||
def copy_to(stream)
|
||||
if blob?
|
||||
return false if @blob.size.zero?
|
||||
|
||||
stream.write(blob)
|
||||
return true
|
||||
else
|
||||
if read
|
||||
stream.write(@blob)
|
||||
@blob = nil
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def read
|
||||
return false unless File.exist?(@path)
|
||||
return false if File.directory?(@path)
|
||||
return false if File.size(@path) < @info.content_offset + @info.content_length
|
||||
|
||||
@blob = File.binread(@path, @info.content_length, @info.content_offset)
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :path, :encrypted, :entries, :error_reason
|
||||
|
||||
def initialize(path:, encrypted: false)
|
||||
@path = path
|
||||
@encrypted = encrypted
|
||||
@entries = []
|
||||
|
||||
@error_reason = ""
|
||||
end
|
||||
|
||||
# Load entries from MIX file. Entry data is NOT loaded.
|
||||
# @return true on success or false on failure. Check m_error_reason for why.
|
||||
def load
|
||||
unless File.exist?(@path)
|
||||
@error_reason = format("Path does not exist: %s", @path)
|
||||
return false
|
||||
end
|
||||
|
||||
if File.directory?(@path)
|
||||
@error_reason = format("Path is a directory: %s", @path)
|
||||
return false
|
||||
end
|
||||
|
||||
File.open(@path, "rb") do |f|
|
||||
header = MixHeader.new(0, 0, 0, 0)
|
||||
header.mime_type = read_i32(f)
|
||||
header.file_data_offset = read_i32(f)
|
||||
header.file_names_offset = read_i32(f)
|
||||
header._reserved = read_i32(f)
|
||||
|
||||
unless header.mime_type == MIX1_HEADER || header.mime_type == MIX2_HEADER
|
||||
@error_reason = format("Invalid mime type: %d", header.mime_type)
|
||||
return false
|
||||
end
|
||||
|
||||
@encrypted = header.mime_type == MIX2_HEADER
|
||||
|
||||
# Read entry info
|
||||
f.pos = header.file_data_offset
|
||||
file_count = read_i32(f)
|
||||
|
||||
file_count.times do |i|
|
||||
entry_info = EntryInfoHeader.new(0, 0, 0)
|
||||
entry_info.crc32 = read_u32(f)
|
||||
entry_info.content_offset = read_u32(f)
|
||||
entry_info.content_length = read_u32(f)
|
||||
|
||||
@entries << Entry.new(name: "", path: @path, info: entry_info)
|
||||
end
|
||||
|
||||
# Read entry names
|
||||
f.pos = header.file_names_offset
|
||||
file_count = read_i32(f)
|
||||
|
||||
file_count.times do |i|
|
||||
@entries[i].name = read_string(f)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def save
|
||||
unless @entries.size.positive?
|
||||
@error_reason = "No entries to write."
|
||||
return false
|
||||
end
|
||||
|
||||
if File.directory?(@path)
|
||||
@error_reason = format("Path is a directory: %s", @path)
|
||||
return false
|
||||
end
|
||||
|
||||
File.open(@path, "wb") do |f|
|
||||
header = MixHeader.new(encrypted? ? MIX2_HEADER : MIX1_HEADER, 0, 0, 0)
|
||||
|
||||
# write mime type
|
||||
write_i32(f, header.mime_type)
|
||||
|
||||
f.pos = 16
|
||||
|
||||
# sort entries by crc32 of their name
|
||||
sort_entries
|
||||
|
||||
# write file blobs
|
||||
@entries.each do |entry|
|
||||
# store current io position
|
||||
pos = f.pos
|
||||
|
||||
# copy entry to stream
|
||||
entry.copy_to(f)
|
||||
|
||||
# update entry with new offset
|
||||
entry.info.content_offset = pos
|
||||
|
||||
# add alignment padding
|
||||
padding = (-f.pos & 7)
|
||||
padding.times do |i|
|
||||
write_u8(f, 0)
|
||||
end
|
||||
end
|
||||
|
||||
# Save file data offset
|
||||
header.file_data_offset = f.pos
|
||||
|
||||
# write number of entries
|
||||
write_i32(f, @entries.size)
|
||||
|
||||
# write entries file data
|
||||
@entries.each do |entry|
|
||||
write_u32(f, entry.info.crc32)
|
||||
write_u32(f, entry.info.content_offset)
|
||||
write_u32(f, entry.info.content_length)
|
||||
end
|
||||
|
||||
# save file names offset
|
||||
header.file_names_offset = f.pos
|
||||
|
||||
# write number of entries
|
||||
write_i32(f, @entries.size)
|
||||
|
||||
# write entry names
|
||||
@entries.each do |entry|
|
||||
write_string(f, entry.name)
|
||||
end
|
||||
|
||||
# jump to io_position 4
|
||||
f.pos = 4
|
||||
# write rest of header
|
||||
|
||||
write_i32(f, header.file_data_offset)
|
||||
write_i32(f, header.file_names_offset)
|
||||
write_i32(f, header._reserved)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def valid?
|
||||
# ALL entries MUST have unique case-insensitive names
|
||||
@entries.each do |a|
|
||||
@entries.each do |b|
|
||||
next if a == b
|
||||
|
||||
return false if a.name.upcase == b.name.upcase
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def encrypted?
|
||||
@encrypted
|
||||
end
|
||||
|
||||
def add_file(path:, replace: false)
|
||||
return false unless File.exist?(path)
|
||||
return false if File.directory?(path)
|
||||
|
||||
entry = Entry.new(name: File.basename(path), path: path, info: EntryInfoHeader.new(0, 0, File.size(path)))
|
||||
add_entry(entry: entry, replace: replace)
|
||||
end
|
||||
|
||||
def add_blob(path:, blob:, replace: false)
|
||||
info = EntryInfoHeader.new(0, 0, blob.size)
|
||||
entry = Entry.new(name: File.basename(path), path: path, info: info, blob: blob)
|
||||
into.crc32 = @entries.last.calculate_crc32
|
||||
|
||||
add_entry(entry: entry, replace: replace)
|
||||
end
|
||||
|
||||
def add_entry(entry:, replace: false)
|
||||
duplicate = @entries.find { |e| e.name.upcase == entry.name.upcase }
|
||||
|
||||
if duplicate
|
||||
if replace
|
||||
@entries.delete(duplicate)
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
@entries << entry
|
||||
true
|
||||
end
|
||||
|
||||
def sort_entries
|
||||
return false if @entries.any? { |e| e.info.crc32 == 0 }
|
||||
|
||||
@entries.sort! { |a, b| a.info.crc32 <=> b.info.crc32 }
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def read_i32(f) = f.read(4).unpack1("l")
|
||||
def read_u32(f) = f.read(4).unpack1("L")
|
||||
def read_u8(f) = f.read(1).unpack1("C")
|
||||
def read_string(f)
|
||||
f.read(read_u8(f)).strip
|
||||
end
|
||||
|
||||
def write_i32(f, value) = f.write([value].pack("l"))
|
||||
def write_u32(f, value) = f.write([value].pack("L"))
|
||||
def write_u8(f, value) = f.write([value].pack("C"))
|
||||
def write_string(f, string)
|
||||
length = string.size + 1 # include null byte
|
||||
write_u8(f, length)
|
||||
f.write(string)
|
||||
write_u8(f, 0) # null byte
|
||||
end
|
||||
end
|
||||
end
|
||||
BIN
media/default_game_cover.png
Normal file
BIN
media/default_game_cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
media/default_map_preview.png
Normal file
BIN
media/default_map_preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
media/ui/rounded_tiny.png
Normal file
BIN
media/ui/rounded_tiny.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 B |
@@ -4,35 +4,91 @@ rescue LoadError
|
||||
require "cyberarm_engine"
|
||||
end
|
||||
|
||||
require "socket"
|
||||
require "rexml"
|
||||
require "base64"
|
||||
require "logger"
|
||||
require "digest"
|
||||
require "pathname"
|
||||
require "fileutils"
|
||||
require "tempfile"
|
||||
|
||||
require "async"
|
||||
require "async/http/internet/instance"
|
||||
require "async/websocket"
|
||||
require "digest/crc"
|
||||
require "ircparser"
|
||||
require "zip"
|
||||
require "chunky_png"
|
||||
require "sdl3"
|
||||
|
||||
require_relative "lib/gosu_ext"
|
||||
|
||||
require_relative "lib/version"
|
||||
require_relative "lib/constants"
|
||||
require_relative "lib/attribution"
|
||||
require_relative "lib/gui_ext"
|
||||
require_relative "lib/state"
|
||||
require_relative "lib/dialog"
|
||||
require_relative "lib/theme"
|
||||
require_relative "lib/pages/games"
|
||||
require_relative "lib/pages/server_browser"
|
||||
require_relative "lib/pages/boot/terms"
|
||||
require_relative "lib/pages/boot/initial_setup"
|
||||
require_relative "lib/pages/boot/start_up"
|
||||
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"
|
||||
require_relative "lib/worker/api"
|
||||
require_relative "lib/worker/api/application"
|
||||
require_relative "lib/worker/api/game_server"
|
||||
require_relative "lib/worker/api/settings"
|
||||
require_relative "lib/worker/api/news_item"
|
||||
require_relative "lib/worker/api/server_event"
|
||||
require_relative "lib/worker/api/test_event"
|
||||
require_relative "lib/worker/api/legacy_manifest"
|
||||
require_relative "lib/worker/api/legacy_manifest_package"
|
||||
require_relative "lib/worker/request"
|
||||
require_relative "lib/worker/w3dhub_api"
|
||||
require_relative "lib/worker/task"
|
||||
require_relative "lib/worker/tasks/install_application"
|
||||
require_relative "lib/worker/tasks/uninstall_application"
|
||||
require_relative "lib/worker/tasks/repair_application"
|
||||
require_relative "lib/worker/tasks/update_application"
|
||||
require_relative "lib/worker/tasks/move_application"
|
||||
|
||||
module W3DHubLauncher
|
||||
WORKER = Ractor.new(name: "Parallel Worker") { W3DHubLauncher::Worker.new }
|
||||
MemCache = {}
|
||||
|
||||
# UNIXServer
|
||||
Thread.new do
|
||||
W3DHubLauncher::Worker.new.listen
|
||||
end
|
||||
|
||||
# UNIXSocket / client
|
||||
WORKER = W3DHubLauncher::Worker.new
|
||||
WORKER.connect
|
||||
end
|
||||
|
||||
# Hello, I exist because there presently exists no way to ask if there are pending
|
||||
# messages in our ractors mailbox without making a blocking call which is a big no no
|
||||
# for a GUI application. :|
|
||||
#
|
||||
# Keep an eye on: https://bugs.ruby-lang.org/issues/21930: "Add Ractor#empty? method to check for pending messages without blocking"
|
||||
#
|
||||
# NOTE: May need to mangle Window#update to do ruby-land sleep so thread gets time to process :(
|
||||
Thread.new do
|
||||
loop do
|
||||
message = Ractor.receive
|
||||
pp message
|
||||
end
|
||||
end
|
||||
SDL.load_lib("SDL3")
|
||||
|
||||
window = W3DHubLauncher::Window.new(width: 1280, height: 800, resizable: true)
|
||||
# window = W3DHubLauncher::Window.new(width: 1920, height: 1080, resizable: true)
|
||||
|
||||
# Gosu.choose_file do |files|
|
||||
# pp files
|
||||
# end
|
||||
|
||||
window.show
|
||||
|
||||
Reference in New Issue
Block a user