58 Commits

Author SHA1 Message Date
2737336c5d Update default map preview image to not have border spacing and tweak map name padding 2026-09-21 10:22:07 -05:00
9348367994 Make renegade importable, tweak game runner for renegade (tt patch launcher doesn't work well with wine apparently) 2026-09-21 09:32:23 -05:00
80954522e5 Disabled forced on dxvk hud, set server map name text border color 2026-09-20 17:16:27 -05:00
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
45 changed files with 4037 additions and 394 deletions

1
.gitignore vendored
View File

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

View File

@@ -4,10 +4,12 @@ source "https://gem.coop"
gem "base64" gem "base64"
gem "rexml" gem "rexml"
gem "logger" gem "logger"
gem "json"
# "game" library gem # "game" library gem
gem "cyberarm_engine" gem "cyberarm_engine"
gem "sdl2-bindings" gem "sdl3-bindings"
gem "gosu", '1.5.0' # temporary, manually built, stand-in for SDL3 supporting gosu
# networking libs # networking libs
gem "async" gem "async"
@@ -18,3 +20,4 @@ gem "async-websocket"
gem "digest-crc" gem "digest-crc"
gem "ircparser" gem "ircparser"
gem "rubyzip" gem "rubyzip"
gem "chunky_png"

View File

@@ -1,32 +1,29 @@
GEM GEM
remote: https://gem.coop/ remote: https://gem.coop/
specs: specs:
async (2.39.0) async (2.46.0)
console (~> 1.29) console (~> 1.29)
fiber-annotation fiber-annotation
io-event (~> 1.11) io-event (~> 1.21)
metrics (~> 0.12) async-http (0.105.0)
traces (~> 0.18) async (>= 2.35.1)
async-http (0.95.0) async-pool (~> 0.12)
async (>= 2.10.2) io-endpoint (~> 0.18)
async-pool (~> 0.11) io-stream (~> 0.14)
io-endpoint (~> 0.14) protocol-http (~> 0.72)
io-stream (~> 0.6) protocol-http1 (~> 0.41)
metrics (~> 0.12) protocol-http2 (~> 0.28)
protocol-http (~> 0.62)
protocol-http1 (~> 0.39)
protocol-http2 (~> 0.26)
protocol-url (~> 0.2) protocol-url (~> 0.2)
traces (~> 0.10) async-pool (0.12.0)
async-pool (0.11.2)
async (>= 2.0) async (>= 2.0)
async-websocket (0.30.0) async-websocket (0.30.1)
async-http (~> 0.76) async-http (~> 0.76)
protocol-http (~> 0.34) protocol-http (~> 0.34)
protocol-rack (~> 0.7) protocol-rack (~> 0.7)
protocol-websocket (~> 0.17) protocol-websocket (~> 0.17)
base64 (0.3.0) base64 (0.3.0)
console (1.34.3) chunky_png (1.4.0)
console (1.38.0)
fiber-annotation fiber-annotation
fiber-local (~> 1.1) fiber-local (~> 1.1)
json json
@@ -39,35 +36,38 @@ GEM
fiber-local (1.1.0) fiber-local (1.1.0)
fiber-storage fiber-storage
fiber-storage (1.0.1) fiber-storage (1.0.1)
gosu (1.4.6) gosu (1.5.0-x86_64-linux)
io-endpoint (0.17.2) ffi (~> 1.12)
io-event (1.15.1) io-endpoint (0.18.0)
io-stream (0.11.1) openssl (>= 3.3.0)
io-event (1.22.0)
io-stream (0.14.0)
openssl (>= 3.3)
ircparser (1.0.0) ircparser (1.0.0)
json (2.19.4) json (3.0.2)
logger (1.7.0) logger (1.7.0)
metrics (0.15.0) openssl (4.0.2)
protocol-hpack (1.5.1) protocol-hpack (1.5.1)
protocol-http (0.62.2) protocol-http (0.72.0)
protocol-http1 (0.39.0) protocol-http1 (0.41.0)
protocol-http (~> 0.62) protocol-http (~> 0.68)
protocol-http2 (0.26.0) protocol-http2 (0.28.0)
protocol-hpack (~> 1.4) protocol-hpack (~> 1.4)
protocol-http (~> 0.62) protocol-http (~> 0.62)
protocol-rack (0.22.1) protocol-rack (0.23.0)
console (~> 1.0)
io-stream (>= 0.10) io-stream (>= 0.10)
protocol-http (~> 0.58) protocol-http (~> 0.58)
rack (>= 1.0) rack (>= 1.0)
protocol-url (0.4.0) protocol-url (0.19.0)
protocol-websocket (0.21.0) protocol-websocket (0.21.2)
protocol-http (~> 0.2) protocol-http (~> 0.2)
rack (3.2.6) rack (3.2.7)
rake (13.4.2) rake (13.4.2)
rexml (3.4.4) rexml (3.4.4)
rubyzip (3.2.2) rubyzip (3.6.0)
sdl2-bindings (0.2.3) sdl3-bindings (1.0.4)
ffi (~> 1.15) ffi (~> 1.16)
traces (0.18.2)
PLATFORMS PLATFORMS
x86_64-linux x86_64-linux
@@ -77,48 +77,51 @@ DEPENDENCIES
async-http async-http
async-websocket async-websocket
base64 base64
chunky_png
cyberarm_engine cyberarm_engine
digest-crc digest-crc
gosu (= 1.5.0)
ircparser ircparser
json
logger logger
rexml rexml
rubyzip rubyzip
sdl2-bindings sdl3-bindings
CHECKSUMS CHECKSUMS
async (2.39.0) sha256=df18730073f2bbb45788077dfa20cb365ecc1b9453969f44de6796b5191a00aa async (2.46.0) sha256=34f984733dbf63a997376346451ec995e858cacc3105e86ccfeaf8877b4f490b
async-http (0.95.0) sha256=08128cab255a48e41d1e856bfa9cbda001f1c57c88bafa3fa3a545ad795754aa async-http (0.105.0) sha256=55191969d5cf61be5fbc2e0aadd51d77085318ba192a5ff5e12e3aba0826906a
async-pool (0.11.2) sha256=0a43a17b02b04d9c451b7d12fafa9a50e55dc6dd00d4369aca00433f16a7e3ed async-pool (0.12.0) sha256=5580493dc024bd704dab2ed0bf1307100722e49cbb825c7965f8501194f58506
async-websocket (0.30.0) sha256=55739954528ad8f87f7792d0452e1268d1ef2aa5b3719f79400a05a1a6202cdf async-websocket (0.30.1) sha256=54bb8a8f184e4aa64434c7a78ecc55850a67a3d1dcd02e3ae787376e2c673936
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
console (1.34.3) sha256=869fbd74697efc4c606f102d2812b0b008e4e7fd738a91c591e8577140ec0dcc chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe
console (1.38.0) sha256=35b82f59130e95e9be6b832d9e539c8b3988fc192e4a8e9a86ab4922f44ad5de
cyberarm_engine (0.25.1) sha256=6f851a2ed0a32bdd25fc619172b546a8e26eefeb13a62c509f226337170d0efe cyberarm_engine (0.25.1) sha256=6f851a2ed0a32bdd25fc619172b546a8e26eefeb13a62c509f226337170d0efe
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03 fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03
fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06 fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06
fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1 fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1
gosu (1.4.6) sha256=80c7fa48cf61f4960ef067fdd1c000d98b4adc8e11faeaa486543d00908a5c15 gosu (1.5.0-x86_64-linux)
io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338 io-endpoint (0.18.0) sha256=720d186bcd584e8ba490bfc91dab332cc3c5ffee848b5b03894555cb275151aa
io-event (1.15.1) sha256=c644cdcf48254015d63f558bf4492f35471f5bb204a42180ea49752be59b30cc io-event (1.22.0) sha256=4936d38dfcf2291d897707fd94690f1a63fdb1ea3ab4fa63c6739fc6c61e1534
io-stream (0.11.1) sha256=fa5f551fcff99581c1757b9d1cee2c37b124f07d2ca4f40b756a05ab9bd21b87 io-stream (0.14.0) sha256=07d9b13ea2066ccbbc761fff8d69ad5a31e47561d31e02cc271f3ed9c1a8f1cd
ircparser (1.0.0) sha256=0d4598f691172e2ad078207c50ba62c0d3428ae841836b0a682e6cd01d62878d ircparser (1.0.0) sha256=0d4598f691172e2ad078207c50ba62c0d3428ae841836b0a682e6cd01d62878d
json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072 openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2
protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91 protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91
protocol-http (0.62.2) sha256=e1c1f2f56029c1af8c4e2b8a67d0d096c76620f3afd8d99d1dcd2f6b8ffa773b protocol-http (0.72.0) sha256=b153474785979495b9f2df78f0fdc2e02edc7e92c1f7ea00425eed73bd02194c
protocol-http1 (0.39.0) sha256=e49b3f4cda6f5d94c76a323d2b7f6977cba3ebd082d2da437039594da77ad8eb protocol-http1 (0.41.0) sha256=9863874a1228948a896c3e7ea16d13a680c8f3ed50706007c78b7033018b0ea3
protocol-http2 (0.26.0) sha256=bac89cd78082b241ccd0cf7246f5160e4bb0c9c975fb4bf7deef5f88cc317486 protocol-http2 (0.28.0) sha256=b7c6195dcfa80ec2484670e9fd8fc21024cc654a391aa0792433aff1188c1691
protocol-rack (0.22.1) sha256=1185d245927ef9849a603700d6991ca353bc89724fbf98efa4a4333ed62a9fc3 protocol-rack (0.23.0) sha256=63eec3341b20565b3dd155345fd1426ecbbed322cdc8a6accc993905388c03d7
protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1 protocol-url (0.19.0) sha256=3f6b9f6aa742a259a0b3610fbbd9685dcd9506adf6734f428ff96259faabfb21
protocol-websocket (0.21.0) sha256=6e2ccc2adf7de1895b0f6548fdfacf5f9735c0d4deb56cd2bac1ae38a1952e93 protocol-websocket (0.21.2) sha256=cbe35be8e045597b221874fba5928d7cd2483884126e3e36faece4741dc88eb7
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695
sdl2-bindings (0.2.3) sha256=878c0755a4bea1aed0fa85c01596effd1c2b7d1878141ceec5bef808038bd309 sdl3-bindings (1.0.4) sha256=326f497c30e6a5cab402e6178403b30c9df4a11a4e109e08c5da6e920c7bb55b
traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214
BUNDLED WITH BUNDLED WITH
4.0.8 4.0.8

75
lib/application_helper.rb Normal file
View File

@@ -0,0 +1,75 @@
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 = case app.id
when "ecw" # ecw is a little special. each of its numbered exes represents a max render distance.
format("%s/game500.exe", app.installation_path)
when "ren" # little hacky. TT auto patching launcher doesn't work* on linux so bypass it.
format("%s/game2.exe", app.installation_path)
else # the rest of the games are reasonably sane.
format("%s/game.exe", app.installation_path)
end
env = {}
unless wine_prefix(app).to_s.empty?
env["WINEPREFIX"] = wine_prefix(app).to_s
end
# env["DXVK_HUD"] = "full"
pid = Process.spawn(
env,
"#{wine_command(app)}\"#{exe_path}\" -launcher #{args.join(' ')}"
)
Process.detach(pid)
end
def self.join_server(app, server, password: nil, token: nil, multi: false)
parameters = []
parameters << format("+connect %s:%s", server.address, server.port)
parameters << format("+netplayername \"%s\"", "cyberarm")
parameters << format("+password \"%s\"", password) if password
parameters << format("+userkey \"%s\"", token) if token
parameters << "+multi" if multi
run(
app,
parameters.join(" ")
)
end
# select "best" server to join
def self.play_now_server(app)
channel = MemCache[:applications].find { |appl| appl.id == app.id }&.channels&.find { |c| c.id == app.channel }
return nil unless channel
server_options = MemCache[:servers].select do |server|
server.game == app.id &&
server.channel == channel.server_channel &&
!server.password? &&
server.player_count < server.max_players
end.sort_by do |s|
[s.player_count, -s.ping]
end.reverse
# try to find server with lowest ping and matching app version
found_server = server_options.find { |s| s.version == app.version }
# try to find server with lowest ping and undefined version
found_server ||= server_options.find { |s| s.version == Worker::Api::GameServer::NO_OR_DEFAULT_VERSION }
found_server
end
end
end

View File

@@ -12,7 +12,7 @@ module W3DHubLauncher
LIBRARIES = [ 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("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("gosu", "Light-weight game library", "https://libgosu.org", "MIT", "https://github.com/gosu/gosu/blob/master/COPYING"),
Item.new("SDL2", "Simple DirectMedia Layer", "https://libsdl.org", "MIT", "https://github.com/libsdl-org/SDL/blob/SDL2/LICENSE.txt"), Item.new("SDL3", "Simple DirectMedia Layer", "https://libsdl.org", "zlib", "https://github.com/libsdl-org/SDL/blob/main/LICENSE.txt"),
Item.new("MojoAL", "OpenAL sound library implementation in a single C file", "https://icculus.org/mojoAL/", "MIT", "https://github.com/icculus/mojoAL/blob/main/LICENSE.txt"), Item.new("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", "Asynchronous event-driven reactor for Ruby", "https://github.com/socketry/async", "MIT", "https://github.com/socketry/async/blob/main/license.md"),

View File

@@ -1,5 +1,12 @@
module W3DHubLauncher module W3DHubLauncher
ROOT_PATH = Dir.pwd 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 USER_AGENT = "#{NAME} v#{VERSION}".freeze
end 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

View File

@@ -3,7 +3,7 @@ module W3DHubLauncher
def setup def setup
theme(THEME) theme(THEME)
background 0xee_222222 background TRANSLUCENT_GRAY
end end
end end
end end

View File

@@ -4,16 +4,16 @@ module W3DHubLauncher
def setup def setup
super 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 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: ALPHA_GRAY) 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 banner "About", width: 1.0, text_align: :center
end 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 title NAME
tagline format("v%s (%s)", VERSION, VERSION_NAME), margin_left: PADDING tagline format("v%s (%s)", VERSION, VERSION_NAME), margin_left: PADDING
para "© 2021 - #{Time.now.year} cyberarm", margin_left: PADDING para "© 2021 - #{Time.now.year} cyberarm", margin_left: PADDING
link "MIT licence", margin_left: PADDING link "MIT license", margin_left: PADDING
title "Special Thanks", margin_top: LARGE_PADDING title "Special Thanks", margin_top: LARGE_PADDING
W3DHubLauncher::Attribution::SPECIAL_THANKS.each do |item| W3DHubLauncher::Attribution::SPECIAL_THANKS.each do |item|
@@ -26,10 +26,10 @@ module W3DHubLauncher
end 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(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) flow(fill: true)
button "Close" do button "Close", **CTA_BUTTON_THEME do
pop_state pop_state
end end
@@ -40,12 +40,18 @@ module W3DHubLauncher
def present_item(item) def present_item(item)
unless item.url.empty? unless item.url.empty?
link item.name, tip: item.url, margin_left: PADDING, font: FONT_BOLD link item.name, tip: item.url, margin_left: PADDING, font: FONT_BOLD do
SDL.OpenURL(item.url)
end
else else
tagline item.name, margin_left: PADDING tagline item.name, margin_left: PADDING
end end
para item.description, margin_left: LARGE_PADDING para item.description, margin_left: LARGE_PADDING
link item.license, tip: item.license_url, margin_left: PADDING + LARGE_PADDING unless item.license.empty? unless item.license.empty?
link format("%s license", item.license), tip: item.license_url, margin_left: PADDING + LARGE_PADDING do
SDL.OpenURL(item.license_url)
end
end
end end
end 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,11 +3,20 @@ module W3DHubLauncher
BLACK_IMAGE = Gosu.render(64, 64, retro: true) { Gosu.draw_rect(0, 0, 32, 32, Gosu::Color::BLACK) } 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) } WHITE_IMAGE = Gosu.render(64, 64, retro: true) { Gosu.draw_rect(0, 0, 32, 32, Gosu::Color::WHITE) }
def safe_get_image(path, retro: false) def safe_get_image(path, fallback_path: "#{ROOT_PATH}/media/default.png", retro: false)
return get_image(path, retro: retro) if File.exist?(path) raise RuntimeError, "Images may only be loaded from the main thread!" unless Thread.current == Thread.main
path = "#{ROOT_PATH}/media/default.png" begin
return get_image(path, retro: retro) if File.exist?(path) 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 WHITE_IMAGE
end end
@@ -21,5 +30,33 @@ module W3DHubLauncher
circle.draw(0, 0, 1, 1, 1, 0xff_ffffff, :multiply) circle.draw(0, 0, 1, 1, 1, 0xff_ffffff, :multiply)
end end
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
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

@@ -13,48 +13,77 @@ module W3DHubLauncher
stack(width: 1.0, fill: true, padding: PADDING) do stack(width: 1.0, fill: true, padding: PADDING) do
stack(width: 1.0, fill: true, scroll: true) do stack(width: 1.0, fill: true, scroll: true) do
title "Initial Setup" title "Initial Setup"
caption "Please confirm launcher's default settings and make any desired tweaks.", font: FONT_REGULAR 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 flow(width: 1.0, height: 40, margin_top: PADDING) do
tagline "Launcher data directory", height: 1.0, text_v_align: :center tagline "Nickname", height: 1.0, text_v_align: :center
edit_line format("%s/.local/share/W3D Hub", Dir.home), fill: true @nickname = edit_line Etc.getlogin, fill: true
button "Browse..."
end end
inscription "Location where the launcher stores it's configuration files and cache interface data." inscription "Nickname to use when joining servers.", margin_left: PADDING
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
tagline "Launcher package cache directory", height: 1.0, text_v_align: :center tagline "Launcher package cache directory", height: 1.0, text_v_align: :center
edit_line format("%s/.local/share/W3D Hub/package-cache", Dir.home), fill: true @launcher_package_cache_directory = edit_line DEFAULT_PACKAGE_CACHE_PATH, fill: true
button "Browse..." button "Browse..."
end end
inscription "Location where the launcher will download application packages." inscription "Location where the launcher will download application packages.", margin_left: PADDING
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
tagline "Application installation directory", height: 1.0, text_v_align: :center tagline "Application installation directory", height: 1.0, text_v_align: :center
edit_line format("%s/.local/share/W3D Hub/applications", Dir.home), fill: true @application_installation_directory = edit_line DEFAULT_APPLICATIONS_PATH, fill: true
button "Browse..." button "Browse..."
end end
inscription "Location where the launcher will install new applications." inscription "Location where the launcher will install new applications.", margin_left: PADDING
flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
tagline "Wine context", height: 1.0, text_v_align: :center tagline "Wine prefix path", height: 1.0, text_v_align: :center
edit_line "", fill: true @wine_prefix_path = edit_line "", fill: true
button "Browse..." button "Browse..."
end end
inscription "Location of wine context to use. Leave blank to use default." 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 flow(width: 1.0, height: 40, margin_top: HALF_PADDING) do
tagline "Wine command", height: 1.0, text_v_align: :center tagline "Wine command", height: 1.0, text_v_align: :center
edit_line "wine", fill: true @wine_command = edit_line "wine", fill: true
button "Browse..." button "Browse..."
end end
inscription "Path to wine executable. Use `wine` for system installed wine." 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 end
flow(width: 1.0, padding_top: PADDING) do flow(width: 1.0, padding_top: PADDING) do
flow(fill: true) flow(fill: true)
button "Accept" do 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) 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

View File

@@ -5,75 +5,219 @@ module W3DHubLauncher
include GuiExt include GuiExt
def setup 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 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 banner "Establishing Battlefield Control", width: 1.0, text_align: :center
tagline "Please stand by...", width: 1.0, text_align: :center tagline "Please stand by...", width: 1.0, text_align: :center
end end
stack(width: 1.0, fill: true, padding: PADDING) do stack(width: 1.0, fill: true, padding: PADDING) do
stack(width: 1.0, fill: true, scroll: true) do @initialization_container = stack(width: 1.0, fill: true, scroll: true) do
a = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) 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 # 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 # tagline "DNS resolution", height: 1.0, text_v_align: :center
end # end
after(500) do # after(500) do
a.show # a.show
end # end
b = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do # 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 # 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 # 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 # caption "Notice: Unreachable. Accounts will be unavailable.", height: 1.0, text_v_align: :center, color: 0xff_ff8800
end # end
after(1000) do # after(1000) do
b.show # b.show
end # end
c = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do # 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 # 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 # tagline "Alternate backend", height: 1.0, text_v_align: :center
end # end
after(1500) do # after(1500) do
c.show # c.show
end # end
d = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do # 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 # 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 # 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 # caption "Notice: Upstream backend unavailable, session not refreshed.", height: 1.0, text_v_align: :center, color: 0xff_ff8800
end # end
after(2000) do # after(2000) do
d.show # d.show
end # end
e = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do # 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 # 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 # tagline "Fetch game servers", height: 1.0, text_v_align: :center
end # end
after(2500) do # after(2500) do
e.show # e.show
end # end
f = flow(width: 1.0, height: HALF_PADDING + PADDING, visible: false) do # 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 # 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 # 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 # 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 # end
after(3000) do # after(3000) do
f.show # puts "HELLO"
@progress_bar.type = :linear # W3DHubLauncher::Worker::Request.new(:w3dhub_api_call, { call: :fetch_applications }) do |result|
@progress_bar.value = 0.0 # # 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 end
after(3500) do flow(width: 1.0, padding_top: PADDING) do
parent.page_host.clear do @progress_bar = progress width: 1.0, fraction: 0.0
banner "Battlefield control established".upcase, width: 1.0, height: 1.0, text_v_align: :center, text_align: :center
end end
end end
after(3600) do
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 pop_state
push_state(States::Interface) push_state(States::Interface)
end end
end end
end
flow(width: 1.0, padding_top: PADDING) do def step_connectivity
@progress_bar = progress width: 1.0, type: :marquee @initialization_container.append do
end 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

View File

@@ -26,7 +26,7 @@ module W3DHubLauncher
window.close window.close
end end
button "Accept" do button "Accept", **CTA_BUTTON_THEME do
page(Page::Boot::InitialSetup) page(Page::Boot::InitialSetup)
end end
end end

View File

@@ -4,77 +4,315 @@ module W3DHubLauncher
include GuiExt include GuiExt
def setup 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 # game bar container
flow(width: 1.0, height: 60) do flow(width: 1.0, height: 60) do
widget(width: 220, height: 1.0, 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 }) do widget(width: 220, height: 1.0, style_class: [:all_button]) do |w|
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center, h_align: :center) do 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 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 link "ALL GAMES", text_size: 24, font: FONT_BLACK, height: 1.0, text_v_align: :center
end end
w.subscribe(:clicked_left_mouse_button) do
populate_all_games
end
end end
flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do @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
image safe_get_image("#{ROOT_PATH}/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("#{ROOT_PATH}/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("#{ROOT_PATH}/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("#{ROOT_PATH}/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"
end end
end end
# game content container # 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 # game info container
stack(width: 340, height: 1.0) 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("#{ROOT_PATH}/media/icons/singleplayer.png"), image_height: 1.0, background_nine_slice: NINE_SLICE_SQUARE, **CTA_BUTTON_THEME
button safe_get_image("#{ROOT_PATH}/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
end end
# game events and news container # game events and news container
stack(fill: true, height: 1.0, margin_left: LARGE_PADDING, scroll: true) do stack(fill: true, height: 1.0, margin_left: 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_GRAY) 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
image safe_get_image("#{ROOT_PATH}/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
end end
# news container # news container
flow(width: 1.0, margin_top: PADDING) do @news_container = flow(width: 1.0) do
9.times do end
stack(width: 1.0 / 3, min_width: 345, 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("#{ROOT_PATH}/media/background.png"), background_image_mode: :fill) # "dynamically" adjust news item widths
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_GRAY, border_thickness_top: 1, border_color_top: Gosu::Color::BLACK) do @news_container.subscribe(:size_changed) do |event|
caption "NEWS", color: 0x88_ffffff @news_container.children.each do |box|
tagline "A News Item Post A News Item Post" box.style.width = 1.0 / news_item_width_ratio
end end
end end
end 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", tip: "Import existing application installation", fill: true, height: 1.0, **CTA_BUTTON_THEME do |btn|
dialog(Dialog::ImportApplication, application: @current_app, channel: @current_channel)
end
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 end

View File

@@ -4,45 +4,95 @@ module W3DHubLauncher
include GuiExt include GuiExt
def setup def setup
@games_filter = []
# game bar container # game bar container
flow(width: 1.0, height: 60) do 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_GRAY) do widget(width: 220, height: 1.0, style_class: [:all_button]) do |w|
flow(width: 1.0, height: 40, margin_left: PADDING, v_align: :center, h_align: :center) do 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 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 link "ALL SERVERS", text_size: 24, font: FONT_BLACK, height: 1.0, text_v_align: :center
end 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 end
flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do @app_filters_container = flow(fill: true, height: 1.0, background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_color: ALPHA_GRAY, margin_left: PADDING) do
image safe_get_image("#{ROOT_PATH}/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" MemCache[:applications].each do |app|
image safe_get_image("#{ROOT_PATH}/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" next unless app.game?
image safe_get_image("#{ROOT_PATH}/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("#{ROOT_PATH}/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" 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
end end
# game content container # game content container
stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do @servers_container = stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do
10.times do end
widget(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: ALPHA_GRAY, hover: { background_nine_slice_color: 0xff_5e5c64 } , active: { background_nine_slice_color: 0xaa_5e5c64 }) do
# 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 # app icon container
image(safe_get_image("#{ROOT_PATH}/data/cache/apb.png"), tip: "Red Alert: A Path Beyond", width: 48, height: 1.0, margin_left: HALF_PADDING) image(safe_get_image("#{CACHE_PATH}/icon_#{app.id}.png"), tip: app.name, width: 56, height: 1.0, margin_left: HALF_PADDING)
# friend name and status container
# server name, region, and times container
stack(fill: true, height: 1.0, margin_left: HALF_PADDING) do 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 # server name
server_name = ["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 caption server.name, text_wrap: :none, tip: server.name
caption server_name, text_wrap: :none, tip: server_name
# server info # server info
a = ["North America", "South America", "Europe", "Asia", "Antarctica", "Arctica", "Oceania"] inscription "#{server.channel}#{server.region}#{server.time_elapsed} / #{server.match_remaining_time}", text_wrap: :none, margin_top: -HALF_PADDING
inscription "#{a.sample} • 13:52 / #{rand > 0.5 ? '∞' : '30:00'}", text_wrap: :none, margin_top: -HALF_PADDING
end end
end end
# server map # server map
stack(width: 256, height: 1.0, margin_left: HALF_PADDING) do stack(width: 256, height: 1.0, margin_left: HALF_PADDING) do
stack(width: 1.0, fill: true, v_align: :center) do stack(width: 1.0, fill: true, v_align: :center) do
server_map = ["RA_Under", "C&C Superduple Long Map Name Goes Here", "RA_NorthByNorthWest", "RA_HostileWatersParadox", "RA_PacificThreat"].sample caption server.current_map, tip: server.current_map, text_wrap: :none, width: 1.0, text_align: :center
caption server_map, tip: server_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 inscription "map", text_wrap: :none, width: 1.0, text_align: :center, margin_top: -HALF_PADDING
end end
end end
@@ -50,17 +100,22 @@ module W3DHubLauncher
# server player count # server player count
stack(width: 96, height: 1.0, margin_left: HALF_PADDING) do stack(width: 96, height: 1.0, margin_left: HALF_PADDING) do
stack(width: 1.0, fill: true, v_align: :center) 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 inscription "players", text_wrap: :none, width: 1.0, text_align: :center, margin_top: -HALF_PADDING
end end
end end
# server ping # server ping
flow(width: 96, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do 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 stack(fill: true, height: 1.0, v_align: :center) do
caption rand > 0.85 ? "?" : 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 inscription "ping", text_wrap: :none, width: 1.0, text_align: :center, margin_top: -HALF_PADDING
end 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 end
end end

View File

@@ -5,7 +5,7 @@ module W3DHubLauncher
super super
# root container - background image # 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 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 # root container - background image tint
flow(width: 1.0, height: 1.0, background: 0xaa_000000) do flow(width: 1.0, height: 1.0, background: 0xaa_000000) do
# content container # content container
@@ -28,7 +28,18 @@ module W3DHubLauncher
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) page(Page::Boot::Terms)
})
end
end
end end
end end
end end

View File

@@ -1,17 +1,19 @@
module W3DHubLauncher module W3DHubLauncher
class States class States
class Interface < W3DHubLauncher::State class Interface < W3DHubLauncher::State
attr_reader :application_task_status_bar_container
def setup def setup
super super
# root container - background image # 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 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 # root container - background image tint
flow(width: 1.0, height: 1.0, background: ALPHA_BLACK) do stack(width: 1.0, height: 1.0, background: ALPHA_BLACK) do
# content container # content container
stack(fill: true, height: 1.0, margin: PADDING, margin_right: LARGE_PADDING) do stack(width: 1.0, fill: true, margin: PADDING) do
# header bar container # header bar container
flow(width: 1.0, height: 80, margin_bottom: PADDING) do |c| flow(width: 1.0, height: 100, margin_bottom: PADDING) do |c|
# logo + menu button # 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| 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(parent: btn) do
@@ -25,38 +27,46 @@ module W3DHubLauncher
end.show end.show
end end
# navigation bar container
stack(fill: true, height: 1.0) do 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) stack(fill: true)
flow(width: 1.0) do image safe_get_image("#{ROOT_PATH}/media/icons/import.png"), height: 40, color: 0xff_bbbbbb, tip: "Downloads" do |img|
link("GAMES", text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::Games) } dialog(Dialog::Downloads)
link("SERVERS", text_v_align: :center, font: FONT_BLACK, margin_left: PADDING) { page(Page::ServerBrowser) } end
stack(fill: true)
image safe_get_image("#{ROOT_PATH}/media/icons/import.png"), height: 40, color: 0xff_bbbbbb, tip: "Downloads"
image safe_get_image("#{ROOT_PATH}/media/icons/information.png"), height: 40, color: 0xff_bbbbbb, tip: "Notifications" image safe_get_image("#{ROOT_PATH}/media/icons/information.png"), height: 40, color: 0xff_bbbbbb, tip: "Notifications"
end 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) stack(fill: true)
para "Fetching manifests...", tag: :status_bar_label
end
progress(width: 1.0, fraction: 0.0, tag: :status_bar_progress)
end end
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 # self account container
flow(width: 1.0, height: 80) do @account_container = flow(width: 400, height: 80, margin_left: LARGE_PADDING) do
# self avatar container # self avatar container
stack(width: 80, height: 1.0, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png"))) do 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 # self online state container
stack(width: 20, height: 20, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269) stack(width: 20, height: 20, v_align: :bottom, h_align: :right, background_image: safe_get_image("#{ROOT_PATH}/media/ui/circle_small.png"), background_image_color: 0xff_26a269, tag: :account_online_state)
end end
stack(fill: true, height: 1.0, margin_left: HALF_PADDING) do stack(fill: true, height: 1.0, margin_left: HALF_PADDING) do
flow(fill: true) flow(fill: true)
# self name # self name
caption "cyberarm", font: FONT_BLACK, text_wrap: :none caption "cyberarm", font: FONT_BLACK, text_wrap: :none, width: 1.0, tag: :account_name
# self set online state # self set online state
link "Online ▼", text_size: 18 do |l| link "Online ▼", text_size: 18 do |l|
menu(parent: l) do menu(parent: l) do
@@ -70,9 +80,22 @@ module W3DHubLauncher
flow(fill: true) flow(fill: true)
end end
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 # friend management container
flow(width: 1.0, height: 60, margin_top: PADDING) do flow(width: 1.0, height: 60) do
flow(width: 1.0, v_align: :center) 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/singleplayer.png"), image_height: 1.0
button safe_get_image("#{ROOT_PATH}/media/icons/gear.png"), image_height: 1.0, margin_left: HALF_PADDING button safe_get_image("#{ROOT_PATH}/media/icons/gear.png"), image_height: 1.0, margin_left: HALF_PADDING
@@ -84,25 +107,27 @@ module W3DHubLauncher
stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do stack(width: 1.0, fill: true, margin_top: LARGE_PADDING, scroll: true) do
50.times do |i| 50.times do |i|
# friend container # friend container
widget(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: ALPHA_GRAY }, active: { background_nine_slice_color: ALPHA_BLACK }) do |w| 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 w.subscribe(:clicked_left_mouse_button) do
puts "HELLO THERE" puts "HELLO THERE"
end end
# friend avatar container # friend avatar container
stack(width: 48, height: 1.0, margin_left: HALF_PADDING, background_image: rounded_avatar(safe_get_image("#{ROOT_PATH}/media/default.png"))) do 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) 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 end
# friend name and status container # friend name and status container
stack(fill: true, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do stack(fill: true, height: 1.0, margin_left: HALF_PADDING, margin_right: HALF_PADDING) do
stack(v_align: :center) do stack(width: 1.0, v_align: :center) do
caption ["Silverlight", "PXD2000", "Alstar", "SteelGhost", "FRAYDO"].sample, text_wrap: :none caption ["Silverlight", "PXD2000", "Alstar", "SteelGhost", "FRAYDO"].sample, text_wrap: :none
inscription "RA_Under • 13:52", text_wrap: :none, margin_top: -HALF_PADDING inscription "RA_Under • 13:52", text_wrap: :none, margin_top: -HALF_PADDING
end end
end end
# friend active application container # friend active application container
stack(width: 48, height: 1.0, margin_right: HALF_PADDING, background_image: safe_get_image("#{ROOT_PATH}/media/logo.png")) 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
@@ -110,8 +135,231 @@ module W3DHubLauncher
end end
end end
hide_server_details_panel
page(Page::Games) page(Page::Games)
end 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: true,
text_border_size: 1,
text_border_color: 0xaa_000000,
text_shadow: false,
width: 1.0,
height: 1.0,
padding_bottom: HALF_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 end
end end

View File

@@ -2,8 +2,10 @@ module W3DHubLauncher
LARGE_PADDING = 40 LARGE_PADDING = 40
PADDING = 20 PADDING = 20
HALF_PADDING = 10 HALF_PADDING = 10
ALPHA_BLACK = 0x88_000000 ALPHA_BLACK = 0x88_000000
ALPHA_GRAY = 0x88_5e5c64 ALPHA_GRAY = 0x88_5e5c64
TRANSLUCENT_GRAY = 0xaa_3d3846
FONT_LIGHT = "#{ROOT_PATH}/media/fonts/NotoSans-Light.ttf" FONT_LIGHT = "#{ROOT_PATH}/media/fonts/NotoSans-Light.ttf"
FONT_REGULAR = "#{ROOT_PATH}/media/fonts/NotoSans-Regular.ttf" FONT_REGULAR = "#{ROOT_PATH}/media/fonts/NotoSans-Regular.ttf"
@@ -77,6 +79,9 @@ module W3DHubLauncher
text_shadow: false, text_shadow: false,
color: 0xff_ffffff, color: 0xff_ffffff,
background: 0,#x88_5e5c64, background: 0,#x88_5e5c64,
padding_left: HALF_PADDING,
padding_right: HALF_PADDING,
border_color: 0,
background_nine_slice: NINE_SLICE_ROUNDED, background_nine_slice: NINE_SLICE_ROUNDED,
background_nine_slice_from_edge: NINE_SLICE_EDGE, background_nine_slice_from_edge: NINE_SLICE_EDGE,
background_nine_slice_mode: :stretched, background_nine_slice_mode: :stretched,
@@ -91,6 +96,11 @@ module W3DHubLauncher
color: 0x88_ffffff, color: 0x88_ffffff,
background: 0, background: 0,
background_nine_slice_color: 0xaa_5e5c64 background_nine_slice_color: 0xaa_5e5c64
},
disabled: {
color: 0x88_c0bfbc,
background: 0,
background_nine_slice_color: 0xaa_77767b
} }
}, },
EditLine: { EditLine: {
@@ -147,6 +157,31 @@ module W3DHubLauncher
background_nine_slice_mode: :stretched, background_nine_slice_mode: :stretched,
background_nine_slice_color: 0xcc_000000, background_nine_slice_color: 0xcc_000000,
border_thickness: 0 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 end

View File

@@ -4,12 +4,34 @@ module W3DHubLauncher
self.show_cursor = true 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" self.caption = format("%s | v%s (%s)", NAME, VERSION, VERSION_NAME) # "Cyberarm's W3D Hub Linux Launcher | v2.0.0 alpha"
# push_state(States::Boot) @main_thread_queue = []
push_state(States::Interface)
CyberarmEngine::EventBus.subscribe("game_server_pings", self) do |payload|
MemCache[:game_server_pings] = payload
end
push_state(States::Boot)
# push_state(States::Interface)
end end
def needs_redraw? def needs_redraw?
states.any?(&:needs_repaint?) states.any?(&:needs_repaint?)
end 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
end end

View File

@@ -1,42 +1,78 @@
module W3DHubLauncher module W3DHubLauncher
class Worker class Worker
Response = Data.define(:status, :request_id, :data) 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 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 = [] @threads = []
@requests = [] @requests = []
@tasks = []
# next available request_id to assign incoming requests @client = nil
@request_id = 0
@settings = nil
@game_servers = []
@host_pings = {}
# listen for requests from frontend
listener = Thread.new { listen }
# connect to and monitor GSH web service # connect to and monitor GSH web service
@threads << Thread.new { game_server_hub_websocket } @threads << Thread.new { game_server_hub_websocket }
# connect to and monitor Backend web service # connect to and monitor Backend web service
@threads << Thread.new { backend_websocket } @threads << Thread.new { backend_websocket }
# poke game servers to ascertain their ping relative to the local machine
@threads << Thread.new { ping_game_servers }
@w3dhub_api = W3DHubLauncher::W3DHubApi.new @w3dhub_api = W3DHubLauncher::W3DHubApi.new
listener.join Async do |task|
end UNIXServer.open(IPC_PATH) do |server|
while(socket = server.accept)
@client = socket
def listen task.async do
loop do while(data = socket.gets)
query = Ractor.receive json = JSON.parse(data)
pp query query = Request::Query.new(type: json["type"].to_sym, request_id: json["request_id"], data: json["data"])
case query.type # pp [:server_incoming, data, query]
when Request::FETCH_URL
when Request::DOWNLOAD_URL if respond_to?(query.type)
when Request::W3DHUB_API_CALL response = send(query.type, query)
Async do # pp [:server_to_client, response]
result = @w3dhub_api.send(query.data[:call], *(query.data[:arguments] || [])) payload = { status: response.status, request_id: response.request_id, data: response.result.data, error: response.result.error }.to_json
response = Response.new(result.okay? ? Request::STATUS_COMPLETE : Request::STATUS_ERROR, query.request_id, result) socket.write(payload)
Ractor.main.send(response) socket.write(RESPONSE_SEPARATOR)
socket.flush
end end
else end
raise "UNKNOWN REQUEST" end
end
ensure # manually delete "socket"
File.delete(IPC_PATH)
end end
end end
end end
@@ -46,5 +82,392 @@ module W3DHubLauncher
def backend_websocket def backend_websocket
end 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
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 module W3DHubLauncher
class Worker class Worker
class Api class Api
Request = Data.define(:coming_soon)
def initialize
@requests = []
end
# downloads requested resource, returns raw string # downloads requested resource, returns raw string
def fetch_url def self.fetch_url
end end
# downloads requested resource, periodically reporting progress until completion, returning path for file on disk # 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 end
# returns user account data # returns user account data
# #
# automatically handles signing in / refreshing token (DOES NOT remove account data if failed to refresh token due to network timeout) # 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 end
# returns launcher settings # 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 end
# write updated launcher settings # write updated launcher settings
def update_settings def self.update_settings(settings, &block)
Worker::Request.new(:update_settings, settings.to_json, &block)
end end
# returns list of available applications # returns list of available applications
# #
# if updated list is requested, return cached version immediately and then the updated list later. # 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 end
# returns current list of servers as reported from GSH / cache # returns current list of servers as reported from GSH / cache
def servers def self.servers(&block)
Worker::Request.new(:servers, "", &block)
end end
# returns news for application # 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 end
# request installation of application # request installation of application
# #
# periodically reports progress until completion # 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 end
# request update of application # request update of application
# #
# periodically reports progress until completion # 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 end
# request repair of application # request repair of application
# #
# periodically reports progress until completion # 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 end
# request relocation of application # request relocation of application
# #
# periodically reports progress until completion # 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 end
# request removal of application # request removal of application
# #
# periodically reports progress until completion # 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 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

View File

@@ -3,10 +3,6 @@ module W3DHubLauncher
class Request class Request
Query = Data.define(:type, :request_id, :data) Query = Data.define(:type, :request_id, :data)
FETCH_URL = 0
DOWNLOAD_URL = 1
W3DHUB_API_CALL = 10
STATUS_ERROR = -1 # request has failed STATUS_ERROR = -1 # request has failed
STATUS_PENDING = 0 # request has not yet started STATUS_PENDING = 0 # request has not yet started
STATUS_OK = 1 # request completed successfully STATUS_OK = 1 # request completed successfully
@@ -45,17 +41,17 @@ module W3DHubLauncher
def enqueue(type, id, data) def enqueue(type, id, data)
Request.requests << self Request.requests << self
W3DHubLauncher::WORKER.send(Query.new(type, id, data)) W3DHubLauncher::WORKER.request(Query.new(type, id, data))
end end
# event from Worker received # event from Worker received
def handle_event(event, data) def handle_event(event, data)
pp [event, data] # pp [event, data]
@callback&.call(data, event)
case event case event
when STATUS_COMPLETE when STATUS_ERROR, STATUS_COMPLETE
Request.requests.delete(self)
when STATUS_ERROR
Request.requests.delete(self) Request.requests.delete(self)
end end
end end

View File

@@ -1,4 +1,561 @@
module W3DHubLauncher module W3DHubLauncher
class Task 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
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

@@ -6,11 +6,29 @@ module W3DHubLauncher
PRIMARY_W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze PRIMARY_W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze
ALTERNATIVE_W3DHUB_API_ENDPOINT = "https://backend.w3d.cyberarm.dev".freeze ALTERNATIVE_W3DHUB_API_ENDPOINT = "https://backend.w3d.cyberarm.dev".freeze
attr_reader :applications
def initialize def initialize
@access_token = nil @access_token = nil
@applications = []
@http_clients = {}
end end
def headers(form_encoded: false) 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 end
# return raw response to requester # return raw response to requester
@@ -20,13 +38,20 @@ module W3DHubLauncher
Sync do |task| Sync do |task|
task.with_timeout(API_TIMEOUT) do task.with_timeout(API_TIMEOUT) do
Async::HTTP::Internet.send(method, url, headers, body) do |response| Async::HTTP::Internet.send(method, url, headers, body) do |response|
pp [method, url, headers, body, response]
if response.success?
result.data = response.read result.data = response.read
else
result.error = true
end
end
rescue StandardError => e rescue StandardError => e
result.error = e result.error = e
end rescue Async::TimeoutError => e
rescue Async::TimeoutError
result.error = e result.error = e
end end
result
end end
result result
@@ -37,13 +62,15 @@ module W3DHubLauncher
result = CyberarmEngine::Result.new result = CyberarmEngine::Result.new
Sync do |task| Sync do |task|
task.with_timeout(API_TIMEOUT) do
Async::HTTP::Internet.send(method, url, headers, body) do |response| Async::HTTP::Internet.send(method, url, headers, body) do |response|
if response.success? if response.success?
content_length = response.headers["content-length"] || 0 content_length = response.headers["content-length"] || 0
total_downloaded_bytes = 0 total_downloaded_bytes = 0
File.open(path, "wb") do |file| 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| response.each do |chunk|
file.write(chunk) file.write(chunk)
downloaded_bytes = chunk.length downloaded_bytes = chunk.length
@@ -57,7 +84,6 @@ module W3DHubLauncher
end end
rescue StandardError => e rescue StandardError => e
result.error = e result.error = e
end
rescue Async::TimeoutError rescue Async::TimeoutError
result.error = e result.error = e
end end
@@ -80,29 +106,60 @@ module W3DHubLauncher
def fetch_applications def fetch_applications
result = CyberarmEngine::Result.new 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 end
def fetch_news() unless single_source
result = CyberarmEngine::Result.new 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 end
def fetch_events() @applications = JSON.parse(result.data)["applications"]&.map { |app| W3DHubLauncher::Worker::Api::Application.new(app) } || []
result = CyberarmEngine::Result.new
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 end
def fetch_manifest() def fetch_manifest()
result = CyberarmEngine::Result.new result = CyberarmEngine::Result.new
end end
def fetch_manifests() def fetch_package_details(packages)
result = CyberarmEngine::Result.new 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 end
def fetch_package_details() def download_package()
result = CyberarmEngine::Result.new
end
def fetch_package()
download() download()
end 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: 24 KiB

View File

@@ -4,9 +4,14 @@ rescue LoadError
require "cyberarm_engine" require "cyberarm_engine"
end end
require "socket"
require "rexml" require "rexml"
require "base64" require "base64"
require "logger" require "logger"
require "digest"
require "pathname"
require "fileutils"
require "tempfile"
require "async" require "async"
require "async/http/internet/instance" require "async/http/internet/instance"
@@ -14,6 +19,10 @@ require "async/websocket"
require "digest/crc" require "digest/crc"
require "ircparser" require "ircparser"
require "zip" require "zip"
require "chunky_png"
require "sdl3"
require_relative "lib/gosu_ext"
require_relative "lib/version" require_relative "lib/version"
require_relative "lib/constants" require_relative "lib/constants"
@@ -28,12 +37,29 @@ require_relative "lib/pages/boot/terms"
require_relative "lib/pages/boot/initial_setup" require_relative "lib/pages/boot/initial_setup"
require_relative "lib/pages/boot/start_up" require_relative "lib/pages/boot/start_up"
require_relative "lib/dialogs/about" 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/boot"
require_relative "lib/states/interface" require_relative "lib/states/interface"
require_relative "lib/window" 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"
require_relative "lib/worker/api" 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/request"
require_relative "lib/worker/w3dhub_api" require_relative "lib/worker/w3dhub_api"
require_relative "lib/worker/task" require_relative "lib/worker/task"
@@ -41,34 +67,28 @@ require_relative "lib/worker/tasks/install_application"
require_relative "lib/worker/tasks/uninstall_application" require_relative "lib/worker/tasks/uninstall_application"
require_relative "lib/worker/tasks/repair_application" require_relative "lib/worker/tasks/repair_application"
require_relative "lib/worker/tasks/update_application" require_relative "lib/worker/tasks/update_application"
require_relative "lib/worker/tasks/move_application"
module W3DHubLauncher module W3DHubLauncher
WORKER = Ractor.new(name: "Parallel Worker") { W3DHubLauncher::Worker.new } MemCache = {}
end
# Hello, I exist because there presently exists no way to ask if there are pending # UNIXServer
# 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 Thread.new do
loop do W3DHubLauncher::Worker.new.listen
response = Ractor.receive
pp response
request = W3DHubLauncher::Worker::Request.requests.find { |r| r.request_id == response.request_id }
request&.handle_event(response.status, response.data)
end
end end
10.times do # UNIXSocket / client
W3DHubLauncher::Worker::Request.new(W3DHubLauncher::Worker::Request::W3DHUB_API_CALL, { call: :fetch_applications }) do |result| WORKER = W3DHubLauncher::Worker.new
pp result WORKER.connect
end
end end
SDL.load_lib("SDL3")
window = W3DHubLauncher::Window.new(width: 1280, height: 800, resizable: true) window = W3DHubLauncher::Window.new(width: 1280, height: 800, resizable: true)
# window = W3DHubLauncher::Window.new(width: 1920, height: 1080, resizable: true) # window = W3DHubLauncher::Window.new(width: 1920, height: 1080, resizable: true)
# Gosu.choose_file do |files|
# pp files
# end
window.show window.show