61 Commits

Author SHA1 Message Date
53e7a72ceb Added initial support for extracting map preview images from game level mixes that include a screenshot or overhead map 2026-09-18 22:21:20 -05:00
0a1909a84c SDL2 -> SDL3 :party: 2026-09-18 22:19:55 -05:00
b7739de136 Add DDS loading library 2026-09-18 22:19:19 -05:00
17614f4416 Support 'play now' server selection 2026-09-18 15:25:22 -05:00
f8ceb69402 Fix up app filters on server browser, make it possible to join server from server browser 2026-09-18 13:51:46 -05:00
6707f260c9 Initial support for importing games 2026-09-18 09:28:39 -05:00
56699bb644 Very crude initial support for starting app from launcher 2026-09-17 22:53:14 -05:00
09ef9d613f Tweak margins of game news and game events so that the top and right edges align nicely 2026-09-17 10:01:37 -05:00
b505f22b8c Applications can now be marked as installed, status bar can now show real data 2026-09-16 14:26:22 -05:00
b551312751 Initial work on patching mixes 2026-09-15 22:06:41 -05:00
1d3164da72 Write paths.ini, fix a few issues 2026-09-14 18:42:13 -05:00
73a9a80a34 Unpacking packages (without patching) now works! 2026-09-14 17:26:37 -05:00
7adb23d71f Manifest package verification now works and partial package downloads (resumed downloads) 2026-09-14 15:40:21 -05:00
4ba71b760a Package selection and downloading functional 2026-09-14 14:21:40 -05:00
db0dd9fc7c More work on Task towards getting app installation workin' 2026-09-12 19:41:27 -05:00
2cf8146553 Initial work on manifest fetching and parsing 2026-09-11 23:55:04 -05:00
af438e35bc Fixed news item titles clipped on if wrapped to second line 2026-09-11 15:59:13 -05:00
2f07cde109 Server Details panel now shows real data 2026-09-11 15:57:00 -05:00
5092c828a7 Switch between battleview panel and server information panel when switching between games and server pages respectively. tweak layouts a smidge to correct for content-box to border-box CyberarmEngine gui changes 2026-09-11 13:06:59 -05:00
21e0888f8e Tweak server info panel layout a bit 2026-09-10 23:05:17 -05:00
132d39d3a4 Prototyped server info panel 2026-09-10 22:51:28 -05:00
7a5667a53a Initial work on implementing tasks 2026-09-10 21:36:43 -05:00
79dfa963c2 Stub all games display, added default game_cover image 2026-09-10 18:47:53 -05:00
b0fefb9f7f Initial pass of fetching and using application news, server events, server ping display now shows unit (ms) instead of just the number- also helps with confusing it for player count 2026-09-09 23:38:47 -05:00
0693733179 Stub HardwareInformation (hardware survey) class, added LegacyManifest and LegacyManifastPackage handling classes 2026-09-09 10:16:09 -05:00
b52f6e2183 Fix typo 2026-09-09 10:15:08 -05:00
915e643a98 SDL3 stuff 2026-09-08 20:06:37 -05:00
6823f9bade Initial support for SDL3 native file open dialogs ❤️ 2026-09-08 20:03:45 -05:00
8c14ecffc1 Actually start using the game server pings 2026-09-08 18:51:22 -05:00
a7ae460446 Use ChunkyPNG for converting ICO bmp to png as gosu doesn't like doing it on the background thread, fixed Worker#service payload sometimes returning multiple events causing the json parser to have a bad time- also can support payloads larger than a megabyte now. 2026-09-08 14:48:05 -05:00
969a82a262 Initial pass at lazily loading app icons 2026-09-08 11:42:54 -05:00
efc9c2f150 Added ICO and MIX libraries 2026-09-08 11:41:35 -05:00
9ad43f61b6 Improved start up page and fixed applications select reporting account stuff instead of app stuff 2026-09-07 21:16:23 -05:00
ccea626852 DIY up theming of app selection/filter buttons 2026-09-06 22:15:02 -05:00
b589a3dc1b Comment out a few debug prints 2026-09-04 20:53:05 -05:00
8333c7b00d Implemented unprivileged UDP based ICMP echo requests for pinging game server hosts 2026-09-04 20:52:47 -05:00
7f716a57ca Retrieval and display of game servers is now working 2026-09-04 18:55:57 -05:00
7524d01347 Update theme to make disabled buttons use the nine slice background color and added TRANSLUCENT_GRAY color, updated about dialog theming, disabled games page import buttons, made some bits of start up page functional 2026-09-03 21:59:11 -05:00
a4dcd302c1 Improved Worker responses and added support for dns resolution check 2026-09-03 21:56:44 -05:00
8dd9b44cb0 Make accessing application extended data easyish, dynamicify game page game play/import/install buttons 2026-09-03 13:27:15 -05:00
60672ef9c3 Settings are now set 2026-09-03 12:25:16 -05:00
aeaf0b377b Added nickname to initial setup settings, make terms and initial setup accept buttons blue (call-to-action styled) 2026-09-03 11:30:20 -05:00
ad0b9dbe7b Fleshed out settings a bit more 2026-09-03 10:53:26 -05:00
1f33e125f6 Stubbed Settings 2026-09-03 00:43:05 -05:00
ba055897bc Fixed disabled buttons having black corners 2026-09-03 00:42:52 -05:00
baec1e1e18 Added GSH game server data containing class, stub Account and Join Server dialogs, support 'lazy loading' remote images (downloading images in the background then calling a callback to replace temp image if host element is still in layout), misc. other changes. 2026-09-02 11:17:04 -05:00
59d884aad1 Initial work on using real data for Games page 2026-08-27 23:51:08 -05:00
d77408484a Initial functioning work to get IPC working via UNIX sockets since ractors are still not a viable option :sad: 2026-08-24 23:01:27 -05:00
c6ee9eab31 Sync 2026-06-30 19:48:48 -05:00
02cbd215f5 Sync 2026-06-22 10:57:00 -05:00
675ab3b1a8 Sync 2026-06-22 10:55:51 -05:00
0eb188f72e More work on Ractor communication. Need to have a proper think about how to keep it DRY and KISS :) 2026-04-22 23:12:30 -05:00
c8bb271d82 Added downloads dialog for seeing progress and for pausing or cancelling operation 2026-04-22 23:11:29 -05:00
f1d6bbe390 Added some more paths 2026-04-22 23:10:07 -05:00
56bb6a59e2 Tweak layout so that player account info is always in the top right but the 'battleview/friends' container can be hidden 2026-04-22 08:32:25 -05:00
9d0a99de6e Update gems 2026-04-21 12:57:36 -05:00
e97e5cc862 Gems, workers, networking, oh my! 2026-04-21 12:57:05 -05:00
dc9e737587 Define ROOT_PATH to fix using absolute paths for assets 2026-04-20 12:25:19 -05:00
30776857a2 Bunch of stuff 2026-04-18 21:58:58 -05:00
43f8af42ec Flesh out about dialog a bit more, added tooltips to server browser server list item elements 2026-04-18 12:30:08 -05:00
33de050cd2 Fixed capitalization error in app name 2026-04-17 22:14:23 -05:00
49 changed files with 4584 additions and 278 deletions

1
.gitignore vendored
View File

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

20
Gemfile
View File

@@ -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"

View File

@@ -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

View File

@@ -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
View File

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

30
lib/attribution.rb Normal file
View 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
View 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
View File

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

9
lib/dialog.rb Normal file
View File

@@ -0,0 +1,9 @@
module W3DHubLauncher
class Dialog < CyberarmEngine::Dialog
def setup
theme(THEME)
background TRANSLUCENT_GRAY
end
end
end

View File

@@ -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
View 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
View 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

View File

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

38
lib/dialogs/server.rb Normal file
View 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
View 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

View File

@@ -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

View File

@@ -0,0 +1,6 @@
module W3DHubLauncher
class HardwareInformation
def initialize
end
end
end

145
lib/ico.rb Normal file
View 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

View 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
View 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
View 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

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,9 @@
module W3DHubLauncher
class State < CyberarmEngine::GuiState
include W3DHubLauncher::GuiExt
def setup
theme(THEME)
end
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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
View 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

View File

@@ -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

View File

@@ -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

View File

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
media/ui/rounded_tiny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

View File

@@ -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