86 Commits

Author SHA1 Message Date
a856dd5dd4 Fixed breaking changes from CyberarmEngine styling refactor 2026-03-21 21:14:52 -05:00
38a882179c Refactor automatic server and application refresh into their own methods, added auto refresh for account token 2026-03-20 20:27:11 -05:00
f350a9a937 Use JSON.pretty_generate when saving json (settings, app cache, and asterisk) 2026-03-20 17:46:22 -05:00
2d429cb834 Update hardware survey to be able to collect the same data as on windows, mostly. 2026-03-19 14:37:52 -05:00
d72142a409 Update gems, added logger gem as it is no longer bundled with ruby 2026-03-19 11:57:24 -05:00
9cb41a8693 Change nickname validation to match classic launcher 2026-02-18 08:44:16 -06:00
f024109327 Bump version 2026-02-11 14:31:20 -06:00
287022f2b8 Merge in updates to ApplicationManager from development branch 2026-02-11 14:31:05 -06:00
68df923bea Bump version 2026-01-31 18:25:33 -06:00
ddbec8d72c Fixed patching not preserving encryption flag of target mix 2026-01-31 18:24:47 -06:00
70d4e0c40f WWMix: added support for replacing entries that are duplicates 2026-01-31 18:23:00 -06:00
f30658ffc2 Corrected indention of WebsocketClient, use Async::HTTP::Client instead of Async::HTTP::Internet in order to provide ssl_context with a custom ca_file path in order to support tebako builds again 2026-01-14 19:06:58 -06:00
9e8f4e1c71 Update gems, disable windows packaging gems 2026-01-14 14:54:31 -06:00
b7e2e69af9 Improve missing wine error message, add link to wiki on settings page, fix crash when saving settings due to disabling winetricks options 2026-01-14 14:45:16 -06:00
3dbfd23b10 Limit game events to 1, hide currently unused winetricks bit of settings 2026-01-14 14:04:58 -06:00
d1d667056b Refresh and enable Welcome dialog if no settings.json is present 2026-01-14 12:12:27 -06:00
c881296ac8 Improve wine settings, fixup wineprefix usage 2026-01-14 10:58:55 -06:00
d630e5044e Make clear package cache button functional 2026-01-14 08:12:29 -06:00
632fc2c05c Bump version 2026-01-09 09:59:51 -06:00
bf8f440ec7 Update gems 2026-01-09 09:37:24 -06:00
633aa10d4a Fixed ServerBrowser differed recalcuate after server update not implemented correctly :D 2026-01-09 09:26:45 -06:00
51d6d981f1 Removed implementation of find_element_by_tag from ServerBrowser, is implemented in CyberarmEngine nowadays. 2026-01-09 09:10:36 -06:00
820da31fe2 Remove Excon ca bundle setting since we removed Excon 2026-01-09 09:02:41 -06:00
6281a44961 Fixed crash 2026-01-09 09:01:55 -06:00
90a1c47389 Fix typo avorive -> avorite, improve Play Now server selection to check server version if provided 2026-01-08 21:18:43 -06:00
782d0f1cb3 Replace websocket-client-simple gem with async-websocket 2026-01-08 21:17:00 -06:00
f2cd26dda3 Update readme 2026-01-07 18:17:23 -06:00
11e5b578a1 Added screenshot for README 2026-01-07 18:12:21 -06:00
7f7e0fab6a Replace usage of Mixer with WWMix reimplementation, fixes excessive memory usage by NOT eager loading MIX file data blobs 2026-01-07 18:05:02 -06:00
a0ff6ec812 Reimplemented MIX file reader/writer 2026-01-07 17:05:40 -06:00
840bc849d3 Migrated away from Excon and to async-http, fixes issues with ipv6 dns resolving but not reachable- and is the start towards more migration to async libs, websocket based server list updater is temporarily broken 2026-01-07 14:18:48 -06:00
752bd2b026 Show server version in server browser 2026-01-07 00:16:14 -06:00
8086ab59b9 Renamed multicast server to broadcast server 2026-01-07 00:15:54 -06:00
948fcfda9a Use correct repo url for self-updater (doesn't work yet, but its a step) 2026-01-06 23:26:56 -06:00
daceb5d56d Use application installed version 2026-01-06 23:26:56 -06:00
e6eae4117f Use server version 2026-01-06 23:26:56 -06:00
a8c74095fe Show launcher version in title bar 2026-01-06 23:26:56 -06:00
f608f45f02 Formatted boot, rescue from write error at start up in #app_icons 2025-12-30 17:27:45 -06:00
603328a51f Update gems, drop i18n gem and implement basic replacement 2025-12-11 16:23:39 -06:00
48297ad9cd Update README 2025-11-30 21:31:57 -06:00
39fbb9df38 Add FUNDING.yml 2025-11-29 11:40:04 -06:00
bc9a524a55 Handle edge case where Gosu.user_languages is empty (on alpine linux for example) 2025-11-29 11:31:36 -06:00
d92a8753d8 Bump version 2025-10-31 11:54:28 -05:00
b299593076 Fixed a couple edge cases with Task#normalize_path causing failures 2025-10-31 11:53:53 -05:00
ce10cdc658 Fix not properly saving access_token_expiry value 2025-10-25 21:23:55 -05:00
5a3f350015 Fixed edge case where Task#normalize_path wouldn't handle partial matches of correctly 2025-10-24 23:05:04 -05:00
d53299e904 Windows is too easy, or annoying. Fix Task#normalize_path on windows ignoring base_path" 2025-10-15 16:27:28 -05:00
d12d3ff6b8 Don't downcase file path unless we need too, update gems. 2025-10-10 13:30:37 -05:00
d67ffa14a3 Show error message at start up if we cannot resolve critical domains. Fixes #15 2025-10-08 15:12:22 -05:00
71047ce9e8 Ugg. 2025-10-08 14:16:33 -05:00
7da716dde4 Possibly fix failing to rescue from networking errors when fetching packages 2025-10-08 14:09:11 -05:00
3a72a2e094 Possibly fix failing to rescue from timeouts 2025-10-08 13:51:32 -05:00
3c565e6fee Bump version 2025-10-08 11:33:05 -05:00
2dc750a686 Task#normalize_path now takes the base path as an argument and attempts to find the case-insensitive file path to target path 2025-10-08 11:32:35 -05:00
ed119a4925 Fixed All Games section was failing to load app icons 2025-09-19 13:55:06 -05:00
e4d99aac00 Added functional support for developer multi join 2025-09-12 23:41:03 -05:00
e9b8638c27 Fixed (soft) crash when downloading package with a space in its name 2025-09-05 20:02:58 -05:00
4997cfabb0 Fixed not handling filename case for patches 2025-08-26 09:23:54 -05:00
The Unnamed Engineer
0c906464f0 case desensitize unzip 2025-08-26 08:55:27 -05:00
The Unnamed Engineer
5bafc77d97 Update task.rb
Modify all potentially case sensitive file operations to operate in a case-insensitive manner.
2025-08-26 08:55:27 -05:00
30aa44312d Fixed failing to download application manifests unless logged in by checking which source the application/channel orginated from, updated gems. 2025-08-26 08:51:08 -05:00
2031f589b7 Moved processed app icons to cache directory, removed app logos (banners) from media since we now download them and app backgrounds from the api 2025-08-04 22:09:00 -05:00
b909952790 Use fresh logos and backgrounds 2025-08-04 12:25:27 -05:00
6d651c7ad6 Download game logos and backgrounds from backend 2025-08-04 12:25:13 -05:00
60909b0963 Fixed W3DHub.ask_folder crashing on windows 2025-08-04 12:18:38 -05:00
48617b26da Minor post-merge refactor, mainly moved duplicated method ca_bundle_path into common.rb 2025-08-04 10:50:07 -05:00
ad2544a56b Merge branch 'The-Unnamed-Engineer-feature-buildBinaryPackage' 2025-08-03 17:10:06 -05:00
80c104772f Merge branch 'feature-buildBinaryPackage' of github.com:The-Unnamed-Engineer/w3d_hub_linux_launcher into The-Unnamed-Engineer-feature-buildBinaryPackage 2025-08-03 17:09:36 -05:00
09082c0c5d Whitespace 2025-08-03 17:09:10 -05:00
The Unnamed Engineer
27e5da9fd2 Merge branch 'cyberarm:master' into feature-buildBinaryPackage 2025-08-03 17:55:46 -04:00
0bb8ef5f19 Initial work launcher (self) updater 2025-06-25 19:45:23 -05:00
cc0910e68e Updated cyberarm_engine gem to fix edit_line's with prefilled text not visible 2025-06-24 13:56:50 -05:00
fd728fa945 Redid Settings page 2025-06-24 13:47:02 -05:00
ec6dfe8371 Bump version 2025-06-24 10:41:07 -05:00
49d501a8b0 Refactored API to support both backends and to re-enable logging in (on the primary backend) 2025-06-24 10:38:41 -05:00
The Unnamed Engineer
e239f9cd4d Patch IRC config to detect RHEL cert bundle 2025-06-11 06:21:11 -04:00
The Unnamed Engineer
b68d24deda Simply warn for unknown languages. 2025-06-10 15:02:23 -04:00
The Unnamed Engineer
1081832df0 Handle cases where image has not yet downloaded 2025-06-10 13:45:01 -04:00
The Unnamed Engineer
c3cee78265 Fix language error crash 2025-06-10 13:22:24 -04:00
The Unnamed Engineer
4d3163740a Update API to support RHEL cert bundle 2025-06-10 13:21:34 -04:00
The Unnamed Engineer
f1953c45e7 Initial support for binary packaging 2025-06-10 11:20:55 -04:00
685a1aa82c Bump version 2025-05-16 09:48:13 -05:00
9dfee9d1d3 Updated server browser to order servers by player count, then by ping. 2025-05-16 09:47:49 -05:00
1e0adc398c Add support for patching encrypted mixes 2025-05-16 09:39:05 -05:00
3485d5b61a Bump version 2025-04-26 19:46:17 -05:00
cb81a51bfe Fixed failing to fetch manifests properly 2025-04-26 19:45:58 -05:00
48 changed files with 2036 additions and 1084 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: cyberarm
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

47
.github/workflows/build-tebako.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Build Launcher Binary
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build-tebako:
runs-on: ubuntu-latest
strategy:
matrix:
architecture: [x64]
container:
image: ghcr.io/tamatebako/tebako-ubuntu-20.04:latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Install Gosu and native dependencies
run: |
apt-get update
apt-get install -y libsdl2-dev libgl1-mesa-dev libopenal-dev libgmp-dev libfontconfig1-dev libsndfile1-dev libmpg123-dev libpango1.0-dev libtool libssl-dev libffi-dev
- name: Update Bundler and lockfile
run: |
gem install bundler -v 2.4.22
bundle _2.4.22_ lock --update --bundler
- name: Build Tebako binary
run: |
tebako press -P -R 3.4.1 -m bundle -o w3d_hub_linux_launcher -r $PWD -e w3d_hub_linux_launcher.rb
- name: Prepare artifact directory
run: |
mkdir w3d-hub-launcher-x86_64
cp w3d_hub_linux_launcher w3d-hub-launcher-x86_64/
cp -r media w3d-hub-launcher-x86_64/
cp -r locales w3d-hub-launcher-x86_64/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: w3d-hub-launcher-x86_64
path: w3d-hub-launcher-x86_64

20
Gemfile
View File

@@ -1,16 +1,26 @@
source "https://rubygems.org" source "https://rubygems.org"
# "standard lib" gems
gem "base64" gem "base64"
gem "excon" gem "rexml"
gem "logger"
# networking libs
gem "async-http"
gem "async-websocket"
# "game" library gem
gem "cyberarm_engine" gem "cyberarm_engine"
gem "sdl2-bindings" gem "sdl2-bindings"
gem "libui", platforms: [:windows]
# misc. libs
gem "digest-crc" gem "digest-crc"
gem "i18n"
gem "ircparser" gem "ircparser"
gem "rexml"
gem "rubyzip" gem "rubyzip"
gem "websocket-client-simple"
# file selection dialogs on windows (SDL3 has these built-in, but we're on SDL2)
gem "libui", platforms: [:windows]
# misc. windows only gems
gem "win32-process", platforms: [:windows] gem "win32-process", platforms: [:windows]
gem "win32-security", platforms: [:windows] gem "win32-security", platforms: [:windows]

View File

@@ -1,35 +1,78 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
base64 (0.2.0) async (2.38.1)
concurrent-ruby (1.3.4) console (~> 1.29)
cyberarm_engine (0.24.4) fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-http (0.94.2)
async (>= 2.10.2)
async-pool (~> 0.11)
io-endpoint (~> 0.14)
io-stream (~> 0.6)
metrics (~> 0.12)
protocol-http (~> 0.58)
protocol-http1 (~> 0.36)
protocol-http2 (~> 0.22)
protocol-url (~> 0.2)
traces (~> 0.10)
async-pool (0.11.2)
async (>= 2.0)
async-websocket (0.30.0)
async-http (~> 0.76)
protocol-http (~> 0.34)
protocol-rack (~> 0.7)
protocol-websocket (~> 0.17)
base64 (0.3.0)
console (1.34.3)
fiber-annotation
fiber-local (~> 1.1)
json
cyberarm_engine (0.25.1)
gosu (~> 1.1) gosu (~> 1.1)
digest-crc (0.6.5) digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
event_emitter (0.2.6) ffi (1.17.0)
excon (1.2.3) ffi-win32-extensions (1.1.0)
ffi (1.17.1-x64-mingw-ucrt) ffi (>= 1.15.5, <= 1.17.0)
ffi (1.17.1-x86_64-linux-gnu) fiber-annotation (0.2.0)
ffi-win32-extensions (1.0.4) fiber-local (1.1.0)
ffi fiber-storage
fiber-storage (1.0.1)
fiddle (1.1.8)
gosu (1.4.6) gosu (1.4.6)
i18n (1.14.6) io-endpoint (0.17.2)
concurrent-ruby (~> 1.0) io-event (1.14.4)
io-stream (0.11.1)
ircparser (1.0.0) ircparser (1.0.0)
libui (0.1.2-x64-mingw) json (2.19.2)
mutex_m (0.3.0) libui (0.2.0-x64-mingw-ucrt)
rake (13.2.1) fiddle
rexml (3.4.0) logger (1.7.0)
rubyzip (2.4.1) metrics (0.15.0)
protocol-hpack (1.5.1)
protocol-http (0.60.0)
protocol-http1 (0.37.0)
protocol-http (~> 0.58)
protocol-http2 (0.24.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.47)
protocol-rack (0.22.0)
io-stream (>= 0.10)
protocol-http (~> 0.58)
rack (>= 1.0)
protocol-url (0.4.0)
protocol-websocket (0.20.2)
protocol-http (~> 0.2)
rack (3.2.5)
rake (13.3.1)
rexml (3.4.4)
rubyzip (3.2.2)
sdl2-bindings (0.2.3) sdl2-bindings (0.2.3)
ffi (~> 1.15) ffi (~> 1.15)
websocket (1.2.11) traces (0.18.2)
websocket-client-simple (0.9.0)
base64
event_emitter
mutex_m
websocket
win32-process (0.10.0) win32-process (0.10.0)
ffi (>= 1.0.0) ffi (>= 1.0.0)
win32-security (0.5.0) win32-security (0.5.0)
@@ -41,19 +84,19 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
async-http
async-websocket
base64 base64
cyberarm_engine cyberarm_engine
digest-crc digest-crc
excon
i18n
ircparser ircparser
libui libui
logger
rexml rexml
rubyzip rubyzip
sdl2-bindings sdl2-bindings
websocket-client-simple
win32-process win32-process
win32-security win32-security
BUNDLED WITH BUNDLED WITH
2.4.3 4.0.3

View File

@@ -1,14 +1,21 @@
![screenshot](https://raw.githubusercontent.com/cyberarm/w3d_hub_linux_launcher/development/screenshots/screenshot-games.webp)
# Cyberarm's Linux Friendly W3D Hub Launcher # Cyberarm's Linux Friendly W3D Hub Launcher
It runs natively on Linux! No mucking about trying to get .NET 4.6.1 or something installed in wine. It runs natively on Linux! No mucking about trying to get .NET 4.6.1 or something installed in wine.
Only requires OpenGL, Ruby, and a few gems. Only requires OpenGL, Ruby, and a few gems.
## Installing ## Download
* Install Ruby 3.0+, from your package manager. [Download pre-built binaries.](https://github.com/cyberarm/w3d_hub_linux_launcher/releases)
## Development
### Installing
* Install Ruby 3.4+, from your package manager.
* Install Gosu's [dependencies](https://github.com/gosu/gosu/wiki/Getting-Started-on-Linux). * Install Gosu's [dependencies](https://github.com/gosu/gosu/wiki/Getting-Started-on-Linux).
* Install required gems: `bundle install` * Install required gems: `bundle install`
## Usage ### Usage
`ruby w3d_hub_linux_launcher.rb` `ruby w3d_hub_linux_launcher.rb`
## Contributing ### Contributing
Contributors welcome, especially if anyone can lend a hand at reducing patching memory usage. Contributors welcome.

View File

@@ -1,38 +1,41 @@
class W3DHub class W3DHub
class Api class Api
LOG_TAG = "W3DHub::Api".freeze LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 30 # seconds API_TIMEOUT = 30 # seconds
USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze
DEFAULT_HEADERS = { DEFAULT_HEADERS = [
"User-Agent": USER_AGENT, ["user-agent", USER_AGENT],
"Accept": "application/json" ["accept", "application/json"]
}.freeze ].freeze
FORM_ENCODED_HEADERS = { FORM_ENCODED_HEADERS = [
"User-Agent": USER_AGENT, ["user-agent", USER_AGENT],
"Accept": "application/json", ["accept", "application/json"],
"Content-Type": "application/x-www-form-urlencoded" ["content-type", "application/x-www-form-urlencoded"]
}.freeze ].freeze
def self.on_thread(method, *args, &callback) def self.on_thread(method, *args, &callback)
BackgroundWorker.foreground_job(-> { Api.send(method, *args) }, callback) BackgroundWorker.foreground_job(-> { Api.send(method, *args) }, callback)
end end
class DummyResponse class Response
def initialize(error) def initialize(error: nil, status: -1, body: "")
@status = status
@body = body
@error = error @error = error
end end
def success? def success?
false @status == 200
end end
def status def status
-1 @status
end end
def body def body
"" @body
end end
def error def error
@@ -41,53 +44,86 @@ class W3DHub
end end
#! === W3D Hub API === !# #! === W3D Hub API === !#
W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
ALT_W3DHUB_API_ENDPOINT = "https://w3dhub-api.w3d.cyberarm.dev".freeze # "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
ENDPOINT = "https://w3dhub-api.w3d.cyberarm.dev" # "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze # HTTP_CLIENTS = {}
API_CONNECTION = Excon.new(ENDPOINT, persistent: true)
def self.async_http(method, path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
case backend
when :w3dhub
endpoint = W3DHUB_API_ENDPOINT
when :alt_w3dhub
endpoint = ALT_W3DHUB_API_ENDPOINT
when :gsh
endpoint = SERVER_LIST_ENDPOINT
end
# Handle arbitrary urls that may come through
url = nil
if path.start_with?("http")
uri = URI(path)
endpoint = uri.origin
path = uri.request_uri
else
url = "#{endpoint}#{path}"
end
def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil, api = :api)
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." } logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." }
# Inject Authorization header if account data is populated # Inject Authorization header if account data is populated
if Store.account if Store.account
logger.debug(LOG_TAG) { " Injecting Authorization header..." } logger.debug(LOG_TAG) { " Injecting Authorization header..." }
headers = headers.dup headers = headers.dup
headers["Authorization"] = "Bearer #{Store.account.access_token}" headers << ["authorization", "Bearer #{Store.account.access_token}"]
end end
connection = api == :api ? API_CONNECTION : GSH_CONNECTION Sync do
endpoint = api == :api ? ENDPOINT : SERVER_LIST_ENDPOINT
begin begin
connection.send( response = provision_http_client(endpoint).send(method, path, headers, body)
method,
path: url.sub(endpoint, ""), Response.new(status: response.status, body: response.read)
headers: headers, rescue Async::TimeoutError => e
body: body,
nonblock: true,
tcp_nodelay: true,
write_timeout: API_TIMEOUT,
read_timeout: API_TIMEOUT,
connect_timeout: API_TIMEOUT,
idempotent: true,
retry_limit: 3,
retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
)
rescue Excon::Errors::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" } logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e) Response.new(error: e)
rescue Excon::Error => e rescue StandardError => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" } logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e } logger.error(LOG_TAG) { e }
DummyResponse.new(e) Response.new(error: e)
ensure
response&.close
end
end end
end end
def self.post(url, headers = DEFAULT_HEADERS, body = nil, api = :api) def self.provision_http_client(hostname)
excon(:post, url, headers, body, api) # Pin http clients to their host Thread so the fiber scheduler doesn't get upset and raise an error
HTTP_CLIENTS[Thread.current] ||= {}
return HTTP_CLIENTS[Thread.current][hostname.downcase] if HTTP_CLIENTS[Thread.current][hostname.downcase]
ssl_context = W3DHub.ca_bundle_path ? OpenSSL::SSL::SSLContext.new : nil
ssl_context&.set_params(
ca_file: W3DHub.ca_bundle_path,
verify_mode: OpenSSL::SSL::VERIFY_PEER
)
endpoint = Async::HTTP::Endpoint.parse(hostname, ssl_context: ssl_context)
HTTP_CLIENTS[Thread.current][hostname.downcase] = Async::HTTP::Client.new(endpoint)
end
def self.post(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
async_http(:post, path, headers, body, backend)
end
def self.get(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
async_http(:get, path, headers, body, backend)
end
# Api.get but handles any URL instead of known hosts
def self.fetch(path, headers = DEFAULT_HEADERS, body = nil, backend = nil)
async_http(:get, path, headers, body, backend)
end end
# Method: POST # Method: POST
@@ -105,24 +141,16 @@ class W3DHub
# #
# On a failed login the service responds with: # On a failed login the service responds with:
# {"error":"login-failed"} # {"error":"login-failed"}
def self.refresh_user_login(refresh_token) def self.refresh_user_login(refresh_token, backend = :w3dhub)
body = "data=#{JSON.dump({refreshToken: refresh_token})}" body = URI.encode_www_form("data": JSON.dump({refreshToken: refresh_token}))
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body) response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true) user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error] return false if user_data[:error]
body = "data=#{JSON.dump({ id: user_data[:userid] })}" user_details_data = user_details(user_data[:userid]) || {}
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
if user_details.status == 200
user_details_data = JSON.parse(user_details.body, symbolize_names: true)
else
logger.error(LOG_TAG) { "Failed to fetch refresh user details:" }
logger.error(LOG_TAG) { user_details }
end
Account.new(user_data, user_details_data) Account.new(user_data, user_details_data)
else else
@@ -133,24 +161,16 @@ class W3DHub
end end
# See #user_refresh_token # See #user_refresh_token
def self.user_login(username, password) def self.user_login(username, password, backend = :w3dhub)
body = "data=#{JSON.dump({username: username, password: password})}" body = URI.encode_www_form("data": JSON.dump({username: username, password: password}))
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body) response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true) user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error] return false if user_data[:error]
body = "data=#{JSON.dump({ id: user_data[:userid] })}" user_details_data = user_details(user_data[:userid]) || {}
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
if user_details.status == 200
user_details_data = JSON.parse(user_details.body, symbolize_names: true)
else
logger.error(LOG_TAG) { "Failed to fetch user details:" }
logger.error(LOG_TAG) { user_details }
end
Account.new(user_data, user_details_data) Account.new(user_data, user_details_data)
else else
@@ -160,18 +180,27 @@ class W3DHub
end end
end end
# /apis/launcher/1/user-login # /apis/w3dhub/1/get-user-details
# Client sends an Authorization header bearer token which is received from logging in (Required?)
# #
# Response: avatar-uri (Image download uri), id, username # Response: avatar-uri (Image download uri), id, username
def self.user_details(id) def self.user_details(id, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({ id: id }))
user_details = post("/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body, backend)
if user_details.status == 200
JSON.parse(user_details.body, symbolize_names: true)
else
logger.error(LOG_TAG) { "Failed to fetch user details:" }
logger.error(LOG_TAG) { user_details }
false
end
end end
# /apis/w3dhub/1/get-service-status # /apis/w3dhub/1/get-service-status
# Service response: # Service response:
# {"services":{"authentication":true,"packageDownload":true}} # {"services":{"authentication":true,"packageDownload":true}}
def self.service_status def self.service_status(backend = :w3dhub)
response = post("#{ENDPOINT}/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS) response = post("/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS, nil, backend)
if response.status == 200 if response.status == 200
ServiceStatus.new(response.body) ServiceStatus.new(response.body)
@@ -186,11 +215,11 @@ class W3DHub
# Client sends an Authorization header bearer token which is received from logging in (Optional) # Client sends an Authorization header bearer token which is received from logging in (Optional)
# Launcher sends an empty data request: data={} # Launcher sends an empty data request: data={}
# Response is a list of applications/games # Response is a list of applications/games
def self.applications def self.applications(backend = :w3dhub)
response = post("#{ENDPOINT}/apis/launcher/1/get-applications") response = post("/apis/launcher/1/get-applications", DEFAULT_HEADERS, nil, backend)
if response.status == 200 if response.status == 200
Applications.new(response.body) Applications.new(response.body, backend)
else else
logger.error(LOG_TAG) { "Failed to fetch applications list:" } logger.error(LOG_TAG) { "Failed to fetch applications list:" }
logger.error(LOG_TAG) { response } logger.error(LOG_TAG) { response }
@@ -198,13 +227,82 @@ class W3DHub
end end
end end
# Populate applications list from primary and alternate backends
# (alternate only has latest public builds of _most_ games)
def self._applications
applications_primary = Store.account ? Api.applications(:w3dhub) : false
applications_alternate = Api.applications(:alt_w3dhub)
# Fail if we fail to fetch applications list from either backend
return false unless applications_primary || applications_alternate
return applications_alternate unless applications_primary
# Merge the two app lists together
apps = applications_alternate
if applications_primary
applications_primary.games.each do |game|
# Check if game exists in alternate list
_game = apps.games.find { |g| g.id == game.id }
unless _game
apps.games << game
# App didn't exist in alternates list
# comparing channels isn't useful
next
end
# If it does, check that all of its channels also exist in alternate list
# and that the primary versions are the same as the alternates list
game.channels.each do |channel|
_channel = _game.channels.find { |c| c.id == channel.id }
unless _channel
_game.channels << channel
# App didn't have channel in alternates list
# comparing channel isn't useful
next
end
# If channel versions and access levels match then all's well
if channel.current_version == _channel.current_version &&
channel.user_level == _channel.user_level
# All's Well!
next
end
# If the access levels don't match then overwrite alternate's channel with primary's channel
if channel.user_level != _channel.user_level
# Replace alternate's channel with primary's channel
_game.channels[_game.channels.index(_channel)] = channel
# Replaced, continue.
next
end
# If versions don't match then pick whichever one is higher
if Gem::Version.new(channel.current_version) > Gem::Version.new(_channel.current_version)
# Replace alternate's channel with primary's channel
_game.channels[_game.channels.index(_channel)] = channel
else
# Do nothing, alternate backend version is greater.
end
end
end
end
apps
end
# /apis/w3dhub/1/get-news # /apis/w3dhub/1/get-news
# Client sends an Authorization header bearer token which is received from logging in (Optional) # Client sends an Authorization header bearer token which is received from logging in (Optional)
# Client requests news for a specific application/game e.g.: data={"category":"ia"} ("launcher-home" retrieves the weekly hub updates) # Client requests news for a specific application/game e.g.: data={"category":"ia"} ("launcher-home" retrieves the weekly hub updates)
# Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one # Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one
def self.news(category) def self.news(category, backend = :w3dhub)
body = "data=#{JSON.dump({category: category})}" body = URI.encode_www_form("data": JSON.dump({category: category}))
response = post("#{ENDPOINT}/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body) response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
News.new(response.body) News.new(response.body)
@@ -220,9 +318,9 @@ class W3DHub
# /apis/launcher/1/get-package-details # /apis/launcher/1/get-package-details
# client requests package details: data={"packages":[{"category":"games","name":"apb.ico","subcategory":"apb","version":""}]} # client requests package details: data={"packages":[{"category":"games","name":"apb.ico","subcategory":"apb","version":""}]}
def self.package_details(packages) def self.package_details(packages, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({ packages: packages })) body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
response = post("#{ENDPOINT}/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body) response = post("/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true) hash = JSON.parse(response.body, symbolize_names: true)
@@ -247,9 +345,9 @@ class W3DHub
# /apis/w3dhub/1/get-events # /apis/w3dhub/1/get-events
# #
# clients requests events: data={"serverPath":"apb"} # clients requests events: data={"serverPath":"apb"}
def self.events(app_id) def self.events(app_id, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id })) body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
response = post("#{ENDPOINT}/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body) response = post("/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
array = JSON.parse(response.body, symbolize_names: true) array = JSON.parse(response.body, symbolize_names: true)
@@ -264,11 +362,6 @@ class W3DHub
# SERVER_LIST_ENDPOINT = "https://gsh.w3dhub.com".freeze # SERVER_LIST_ENDPOINT = "https://gsh.w3dhub.com".freeze
SERVER_LIST_ENDPOINT = "https://gsh.w3d.cyberarm.dev".freeze SERVER_LIST_ENDPOINT = "https://gsh.w3d.cyberarm.dev".freeze
# SERVER_LIST_ENDPOINT = "http://127.0.0.1:9292".freeze # SERVER_LIST_ENDPOINT = "http://127.0.0.1:9292".freeze
GSH_CONNECTION = Excon.new(SERVER_LIST_ENDPOINT, persistent: true)
def self.get(url, headers = DEFAULT_HEADERS, body = nil, api = :api)
excon(:get, url, headers, body, api)
end
# Method: GET # Method: GET
# FORMAT: JSON # FORMAT: JSON
@@ -287,8 +380,8 @@ class W3DHub
# id, name, score, kills, deaths # id, name, score, kills, deaths
# ...players[]: # ...players[]:
# nick, team (index of teams array), score, kills, deaths # nick, team (index of teams array), score, kills, deaths
def self.server_list(level = 1) def self.server_list(level = 1, backend = :gsh)
response = get("#{SERVER_LIST_ENDPOINT}/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, :gsh) response = get("/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
if response.status == 200 if response.status == 200
data = JSON.parse(response.body, symbolize_names: true) data = JSON.parse(response.body, symbolize_names: true)
@@ -309,10 +402,10 @@ class W3DHub
# id, name, score, kills, deaths # id, name, score, kills, deaths
# ...players[]: # ...players[]:
# nick, team (index of teams array), score, kills, deaths # nick, team (index of teams array), score, kills, deaths
def self.server_details(id, level) def self.server_details(id, level, backend = :gsh)
return false unless id && level return false unless id && level
response = get("#{SERVER_LIST_ENDPOINT}/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, :gsh) response = get("/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
if response.status == 200 if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true) hash = JSON.parse(response.body, symbolize_names: true)

View File

@@ -24,8 +24,9 @@ class W3DHub
def to_json(env) def to_json(env)
d = @data.dup d = @data.dup
d[:avatar_uri] = @avatar_uri d[:avatar_uri] = @avatar_uri
d[:access_token_expiry] = d[:access_token_expiry].to_i d[:access_token_expiry] = @access_token_expiry.to_i
d.to_json(env) d.to_json(env)
end end

View File

@@ -3,14 +3,14 @@ class W3DHub
class Applications class Applications
attr_reader :data attr_reader :data
def initialize(response) def initialize(response, source = nil)
@data = JSON.parse(response, symbolize_names: true) @data = JSON.parse(response, symbolize_names: true)
games = @data[:applications].select { |a| a[:category] == "games" } games = @data[:applications].select { |a| a[:category] == "games" }
@games = [] @games = []
games.each { |hash| @games << Game.new(hash) } games.each { |hash| @games << Game.new(hash, source) }
@games.sort_by!(&:name).reverse @games.sort_by!(&:name).reverse
end end
@@ -20,9 +20,11 @@ class W3DHub
class Game class Game
attr_reader :id, :name, :type, :category, :studio_id, :channels, :web_links, :color attr_reader :id, :name, :type, :category, :studio_id, :channels, :web_links, :color
attr_reader :___source
def initialize(hash) def initialize(hash, source = nil)
@data = hash @data = hash
@data[:___source] = source if source
@id = @data[:id].to_s @id = @data[:id].to_s
@name = @data[:name] @name = @data[:name]
@@ -31,7 +33,7 @@ class W3DHub
@studio_id = @data[:"studio-id"] @studio_id = @data[:"studio-id"]
# TODO: Do processing # TODO: Do processing
@channels = @data[:channels].map { |channel| Channel.new(channel) } @channels = @data[:channels].map { |channel| Channel.new(channel, source) }
@web_links = @data[:"web-links"]&.map { |link| WebLink.new(link) } || [] @web_links = @data[:"web-links"]&.map { |link| WebLink.new(link) } || []
@extended_data = @data[:"extended-data"] @extended_data = @data[:"extended-data"]
@@ -55,17 +57,34 @@ class W3DHub
@uses_ren_folder @uses_ren_folder
end end
def source
@data[:___source]&.to_sym || :w3dhub
end
def source=(sym)
@data[:___source] = sym
end
class Channel class Channel
attr_reader :id, :name, :user_level, :current_version attr_reader :id, :name, :user_level, :current_version
def initialize(hash) def initialize(hash, source = nil)
@data = hash @data = hash
@data[:___source] = source
@id = @data[:id].to_s @id = @data[:id].to_s
@name = @data[:name] @name = @data[:name]
@user_level = @data[:"user-level"] @user_level = @data[:"user-level"]
@current_version = @data[:"current-version"] @current_version = @data[:"current-version"]
end end
def source
@data[:___source]&.to_sym || :w3dhub
end
def source=(sym)
@data[:___source] = sym
end
end end
class WebLink class WebLink

View File

@@ -1,7 +1,10 @@
class W3DHub class W3DHub
class Api class Api
class ServerListServer class ServerListServer
attr_reader :id, :game, :address, :port, :region, :channel, :ping, :status NO_OR_BAD_PING = 1_000_000
NO_OR_DEFAULT_VERSION = "838"
attr_reader :id, :game, :address, :port, :region, :channel, :version, :ping, :status
def initialize(hash) def initialize(hash)
@data = hash @data = hash
@@ -12,7 +15,8 @@ class W3DHub
@port = @data[:port] @port = @data[:port]
@region = @data[:region] @region = @data[:region]
@channel = @data[:channel] || "release" @channel = @data[:channel] || "release"
@ping = -1 @version = @data[:version] || NO_OR_DEFAULT_VERSION
@ping = NO_OR_BAD_PING
@status = Status.new(@data[:status]) @status = Status.new(@data[:status])
@@ -55,7 +59,7 @@ class W3DHub
end end
end end
@ping = -1 if @ping.zero? @ping = NO_OR_BAD_PING if @ping.zero?
@ping @ping
end, end,

View File

@@ -2,6 +2,9 @@ class W3DHub
class Api class Api
class ServerListUpdater class ServerListUpdater
LOG_TAG = "W3DHub::Api::ServerListUpdater".freeze LOG_TAG = "W3DHub::Api::ServerListUpdater".freeze
TYPE_PING = 6
include CyberarmEngine::Common include CyberarmEngine::Common
@@instance = nil @@instance = nil
@@ -15,6 +18,7 @@ class W3DHub
def initialize def initialize
@auto_reconnect = false @auto_reconnect = false
@reconnection_delay = 1
@invocation_id = 0 @invocation_id = 0
@@ -24,12 +28,13 @@ class W3DHub
def run def run
Thread.new do Thread.new do
Sync do |task|
begin begin
connect @auto_reconnect = true
while W3DHub::BackgroundWorker.alive? while W3DHub::BackgroundWorker.alive?
connect if @auto_reconnect connect if @auto_reconnect
sleep 1 sleep @reconnection_delay
end end
rescue => e rescue => e
puts e puts e
@@ -39,6 +44,7 @@ class W3DHub
retry retry
end end
end end
end
logger.debug(LOG_TAG) { "Cleaning up..." } logger.debug(LOG_TAG) { "Cleaning up..." }
@@instance = nil @@instance = nil
@@ -48,13 +54,17 @@ class W3DHub
@auto_reconnect = false @auto_reconnect = false
logger.debug(LOG_TAG) { "Requesting connection token..." } logger.debug(LOG_TAG) { "Requesting connection token..." }
response = Api.post("#{Api::SERVER_LIST_ENDPOINT}/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh) response = Api.post("/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh)
if response.status != 200 if response.status != 200
@auto_reconnect = true @auto_reconnect = true
@reconnection_delay = @reconnection_delay * 2
@reconnection_delay = 60 if @reconnection_delay > 60
return return
end end
@reconnection_delay = 1
data = JSON.parse(response.body, symbolize_names: true) data = JSON.parse(response.body, symbolize_names: true)
@invocation_id = 0 if @invocation_id > 9095 @invocation_id = 0 if @invocation_id > 9095
@@ -63,7 +73,7 @@ class W3DHub
logger.debug(LOG_TAG) { "Connecting to websocket..." } logger.debug(LOG_TAG) { "Connecting to websocket..." }
this = self this = self
@ws = WebSocket::Client::Simple.connect(endpoint, headers: Api::DEFAULT_HEADERS) do |ws| @ws = WebSocketClient.new.connect(endpoint, headers: Api::DEFAULT_HEADERS) do |ws|
ws.on(:open) do ws.on(:open) do
logger.debug(LOG_TAG) { "Requesting json protocol, v1..." } logger.debug(LOG_TAG) { "Requesting json protocol, v1..." }
ws.send({ protocol: "json", version: 1 }.to_json + "\x1e") ws.send({ protocol: "json", version: 1 }.to_json + "\x1e")
@@ -78,16 +88,18 @@ class W3DHub
end end
ws.on(:message) do |msg| ws.on(:message) do |msg|
msg = msg.data.split("\x1e").first msg = msg.to_str.split("\x1e").first
hash = JSON.parse(msg, symbolize_names: true) hash = JSON.parse(msg, symbolize_names: true)
# pp hash if hash[:target] != "ServerStatusChanged" && hash[:type] != 6 && hash[:type] != 3 # pp hash if hash[:target] != "ServerStatusChanged" && hash[:type] != 6 && hash[:type] != 3
# Send PING(?) # Send PING(?)
if hash.empty? || hash[:type] == 6 if hash.empty? || hash[:type] == TYPE_PING
ws.send({ type: 6 }.to_json + "\x1e") ws.send({ type: TYPE_PING }.to_json + "\x1e")
else next
end
case hash[:type] case hash[:type]
when 1 when 1
case hash[:target] case hash[:target]
@@ -95,7 +107,12 @@ class W3DHub
data = hash[:arguments].first data = hash[:arguments].first
this.invocation_id += 1 this.invocation_id += 1
out = { "type": 1, "invocationId": "#{this.invocation_id}", "target": "SubscribeToServerStatusUpdates", "arguments": [data[:id], 1] } out = {
"type": 1,
"invocationId": "#{this.invocation_id}",
"target": "SubscribeToServerStatusUpdates",
"arguments": [data[:id], 1]
}
ws.send(out.to_json + "\x1e") ws.send(out.to_json + "\x1e")
BackgroundWorker.foreground_job( BackgroundWorker.foreground_job(
@@ -133,10 +150,9 @@ class W3DHub
end end
end end
end end
end
ws.on(:close) do |e| ws.on(:close) do
logger.error(LOG_TAG) { e } logger.error(LOG_TAG) { "Connection closed." }
this.auto_reconnect = true this.auto_reconnect = true
ws.close ws.close
end end
@@ -181,14 +197,24 @@ class W3DHub
# unsubscribe from removed servers # unsubscribe from removed servers
removed_servers.each do removed_servers.each do
@invocation_id += 1 @invocation_id += 1
out = { "type": 1, "invocationId": "#{@invocation_id}", "target": "SubscribeToServerStatusUpdates", "arguments": [server.id, 0] } out = {
"type": 1,
"invocationId": "#{@invocation_id}",
"target": "SubscribeToServerStatusUpdates",
"arguments": [server.id, 0]
}
ws.send(out.to_json + "\x1e") ws.send(out.to_json + "\x1e")
end end
# subscribe to new servers # subscribe to new servers
new_servers.each do new_servers.each do
@invocation_id += 1 @invocation_id += 1
out = { "type": 1, "invocationId": "#{@invocation_id}", "target": "SubscribeToServerStatusUpdates", "arguments": [server.id, 1] } out = {
"type": 1,
"invocationId": "#{@invocation_id}",
"target": "SubscribeToServerStatusUpdates",
"arguments": [server.id, 1]
}
ws.send(out.to_json + "\x1e") ws.send(out.to_json + "\x1e")
end end
end end

View File

@@ -5,6 +5,7 @@ class W3DHub
def initialize def initialize
@tasks = [] # :installer, :importer, :repairer, :uninstaller @tasks = [] # :installer, :importer, :repairer, :uninstaller
@running_applications = {}
end end
def install(app_id, channel) def install(app_id, channel)
@@ -22,9 +23,7 @@ class W3DHub
# unpack packages # unpack packages
# install dependencies (e.g. visual C runtime) # install dependencies (e.g. visual C runtime)
installer = Installer.new(app_id, channel) @tasks.push(Installer.new(app_id, channel))
@tasks.push(installer)
end end
def update(app_id, channel) def update(app_id, channel)
@@ -32,9 +31,7 @@ class W3DHub
return false unless installed?(app_id, channel) return false unless installed?(app_id, channel)
updater = Updater.new(app_id, channel) @tasks.push(Updater.new(app_id, channel))
@tasks.push(updater)
end end
def import(app_id, channel) def import(app_id, channel)
@@ -86,15 +83,15 @@ class W3DHub
# open wwconfig.exe or config.exe for ecw # open wwconfig.exe or config.exe for ecw
if (app_data = installed?(app_id, channel) && W3DHub.unix?) return unless (app_data = installed?(app_id, channel) && W3DHub.unix?)
exe = if Store.settings[:wine_prefix]
exe = if !Store.settings[:wine_prefix].to_s.empty?
"WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" winecfg" "WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" winecfg"
else else
"winecfg" "winecfg"
end end
Process.spawn("#{exe}") Process.spawn(exe)
end
end end
def repair(app_id, channel) def repair(app_id, channel)
@@ -169,11 +166,16 @@ class W3DHub
def wine_command(app_id, channel) def wine_command(app_id, channel)
return "" if W3DHub.windows? return "" if W3DHub.windows?
if Store.settings[:wine_prefix] "\"#{Store.settings[:wine_command]}\" "
"WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" \"#{Store.settings[:wine_command]}\" "
else
"#{Store.settings[:wine_command]} "
end end
def wine_enviroment_variables(app_id, channel)
vars = {}
return vars if W3DHub.windows?
vars["WINEPREFIX"] = Store.settings[:wine_prefix] unless Store.settings[:wine_prefix].to_s.empty?
vars
end end
def mangohud_command(app_id, channel) def mangohud_command(app_id, channel)
@@ -188,6 +190,13 @@ class W3DHub
end end
end end
def mangohud_enviroment_variables(app_id, channel)
vars = {}
return vars if W3DHub.windows?
vars
end
def dxvk_command(app_id, channel) def dxvk_command(app_id, channel)
return "" if W3DHub.windows? return "" if W3DHub.windows?
@@ -201,6 +210,13 @@ class W3DHub
end end
end end
def dxvk_enviroment_variables(app_id, channel)
vars = {}
return vars if W3DHub.windows?
vars
end
def start_command(path, exe) def start_command(path, exe)
if W3DHub.windows? if W3DHub.windows?
"start /D \"#{path}\" /B #{exe}" "start /D \"#{path}\" /B #{exe}"
@@ -212,16 +228,32 @@ class W3DHub
def run(app_id, channel, *args) def run(app_id, channel, *args)
if (app_data = installed?(app_id, channel)) if (app_data = installed?(app_id, channel))
install_directory = app_data[:install_directory] install_directory = app_data[:install_directory]
exe_path = app_id == "ecw" ? "#{install_directory}/game500.exe" : "#{install_directory}/game.exe" exe_path = app_id == "ecw" ? "#{install_directory}/game500.exe" : app_data[:install_path]
exe_path.gsub!("/", "\\") if W3DHub.windows? exe_path.gsub!("/", "\\") if W3DHub.windows?
exe_path.gsub!("\\", "/") if W3DHub.unix? exe_path.gsub!("\\", "/") if W3DHub.unix?
exe = File.basename(exe_path) exe = File.basename(exe_path)
path = File.dirname(exe_path) path = File.dirname(exe_path)
env = {}
if W3DHub.unix?
env.merge!(
dxvk_enviroment_variables(app_id, channel),
mangohud_enviroment_variables(app_id, channel),
wine_enviroment_variables(app_id, channel)
)
end
attempted = false attempted = false
begin begin
pid = Process.spawn("#{dxvk_command(app_id, channel)}#{mangohud_command(app_id, channel)}#{wine_command(app_id, channel)}#{attempted ? start_command(path, exe) : "\"#{exe_path}\""} -launcher #{args.join(' ')}") pid = Process.spawn(
env,
"#{dxvk_command(app_id, channel)}"\
"#{mangohud_command(app_id, channel)}"\
"#{wine_command(app_id, channel)}"\
"#{attempted ? start_command(path, exe) : "\"#{exe_path}\""} "\
"-launcher #{args.join(' ')}"
)
Process.detach(pid) Process.detach(pid)
rescue Errno::EINVAL => e rescue Errno::EINVAL => e
retryable = !attempted retryable = !attempted
@@ -235,25 +267,40 @@ class W3DHub
end end
end end
def join_server(app_id, channel, server, password = nil) def join_server(app_id, channel, server, username = Store.settings[:server_list_username], password = nil, multi = false)
if installed?(app_id, channel) && Store.settings[:server_list_username].to_s.length.positive? return unless installed?(app_id, channel) && username.to_s.length.positive?
run( run(
app_id, channel, app_id, channel,
"+connect #{server.address}:#{server.port} +netplayername #{Store.settings[:server_list_username]}#{password ? " +password \"#{password}\"" : ""}" "+connect #{server.address}:#{server.port} "\
"+netplayername #{username}#{password ? " +password \"#{password}\"" : ""}"\
"#{multi ? " +multi" : ""}"
) )
end end
end
def play_now_server(app_id, channel) def play_now_server(app_id, channel)
app_data = installed?(app_id, channel) app_data = installed?(app_id, channel)
return nil unless app_data return nil unless app_data
found_server = Store.server_list.select do |server| server_options = Store.server_list.select do |server|
server.game == app_id && server.channel == channel && !server.status.password && server.status.player_count < server.status.max_players server.game == app_id &&
end&.first server.channel == channel &&
!server.status.password &&
server.status.player_count < server.status.max_players
end
# sort by player count HIGH to LOW
# and by ping LOW to HIGH
server_options.sort! do |a, b|
[b.status.player_count, a.ping] <=> [a.status.player_count, b.ping]
end
found_server ? found_server : nil # try to find server with lowest ping and matching version
found_server = server_options.find { |server| server.version == app_data[:installed_version] }
# try to find server with lowest ping and undefined version
found_server ||= server_options.find { |server| server.version == Api::ServerListServer::NO_OR_DEFAULT_VERSION }
found_server
end end
def play_now(app_id, channel) def play_now(app_id, channel)
@@ -283,7 +330,7 @@ class W3DHub
end end
end end
def favorive(app_id, bool) def favorite(app_id, bool)
Store.settings[:favorites] ||= {} Store.settings[:favorites] ||= {}
if bool if bool

View File

@@ -112,6 +112,38 @@ class W3DHub
@task_state == :failed @task_state == :failed
end end
def normalize_path(path, base_path)
path = path.to_s.gsub("\\", "/")
return "#{base_path}/#{path}" if W3DHub.windows? # Windows is easy, or annoying, depending how you look at it...
constructed_path = base_path
lowercase_full_path = "#{base_path}/#{path}".downcase.strip.freeze
accepted_parts = 0
split_path = path.split("/")
split_path.each do |segment|
Dir.glob("#{constructed_path}/*").each do |part|
next unless "#{constructed_path}/#{segment}".downcase == part.downcase
# Handle edge case where a file with the same name is in a higher directory
next if File.file?(part) && part.downcase.strip != lowercase_full_path
constructed_path = part
accepted_parts += 1
break
end
end
# Find file if it exists else use provided path as cased
if constructed_path.downcase.strip == lowercase_full_path
constructed_path
elsif accepted_parts.positive?
"#{constructed_path}/#{split_path[accepted_parts..].join('/')}"
else
"#{base_path}/#{path}" # File doesn't exist, case doesn't matter.
end
end
def failure_reason def failure_reason
@task_failure_reason || "" @task_failure_reason || ""
end end
@@ -151,7 +183,8 @@ class W3DHub
# Wine present? # Wine present?
if W3DHub.unix? if W3DHub.unix?
wine_present = W3DHub.command("which #{Store.settings[:wine_command]}") wine_present = W3DHub.command("which #{Store.settings[:wine_command]}")
fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed. Will be unable to create prefixes or launch games.") unless wine_present fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed.\n\n"\
"Will be unable to launch game.\n\nCheck wine options in launcher's settings.") unless wine_present
end end
end end
@@ -208,11 +241,18 @@ class W3DHub
@manifests << manifest @manifests << manifest
until(manifest.full?) until(manifest.full?)
fetch_manifest("games", app_id, "manifest.xml", manifest.base_version) if fetch_manifest("games", app_id, "manifest.xml", manifest.base_version)
manifest = load_manifest("games", app_id, "manifest.xml", manifest.base_version) manifest = load_manifest("games", app_id, "manifest.xml", manifest.base_version)
manifests << manifest manifests << manifest
else
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{manifest.base_version}")
return []
end end
end end
else
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{@target_version}")
return []
end
@manifests @manifests
end end
@@ -252,7 +292,7 @@ class W3DHub
next if packages.detect do |pkg| next if packages.detect do |pkg|
pkg.category == "games" && pkg.category == "games" &&
pkg.subcategory == @app_id && pkg.subcategory == @app_id &&
pkg.name == file.package && pkg.name.to_s.casecmp?(file.package.to_s) &&
pkg.version == file.version pkg.version == file.version
end end
@@ -288,17 +328,13 @@ class W3DHub
@files.reverse.each do |file| @files.reverse.each do |file|
break unless folder_exists break unless folder_exists
safe_file_name = file.name.gsub("\\", "/") file_path = normalize_path(file.name, path)
# Fix borked Data -> data 'cause Windows don't care about capitalization
safe_file_name.sub!("Data/", "data/")
file_path = "#{path}/#{safe_file_name}"
processed_files += 1 processed_files += 1
@status.progress = processed_files.to_f / file_count @status.progress = processed_files.to_f / file_count
next if file.removed_since next if file.removed_since
next if accepted_files.key?(safe_file_name) next if accepted_files.key?(file_path)
unless File.exist?(file_path) unless File.exist?(file_path)
rejected_files << { file: file, manifest_version: file.version } rejected_files << { file: file, manifest_version: file.version }
@@ -318,7 +354,7 @@ class W3DHub
logger.info(LOG_TAG) { file.inspect } if file.checksum.nil? logger.info(LOG_TAG) { file.inspect } if file.checksum.nil?
if digest.hexdigest.upcase == file.checksum.upcase if digest.hexdigest.upcase == file.checksum.upcase
accepted_files[safe_file_name] = file.version accepted_files[file_path] = file.version
logger.info(LOG_TAG) { "[#{file.version}] Verified file: #{file_path}" } logger.info(LOG_TAG) { "[#{file.version}] Verified file: #{file_path}" }
else else
rejected_files << { file: file, manifest_version: file.version } rejected_files << { file: file, manifest_version: file.version }
@@ -362,7 +398,7 @@ class W3DHub
} }
end end
package_details = Api.package_details(hashes) package_details = Api.package_details(hashes, @channel.source || :w3dhub)
unless package_details unless package_details
fail!("Failed to fetch package details") fail!("Failed to fetch package details")
@@ -376,9 +412,9 @@ class W3DHub
end end
package = @packages.find do |pkg| package = @packages.find do |pkg|
pkg.category == rich.category && pkg.category.to_s.casecmp?(rich.category.to_s) &&
pkg.subcategory == rich.subcategory && pkg.subcategory.to_s.casecmp?(rich.subcategory.to_s) &&
"#{pkg.name}.zip" == rich.name && "#{pkg.name}.zip".casecmp?(rich.name) &&
pkg.version == rich.version pkg.version == rich.version
end end
@@ -522,7 +558,7 @@ class W3DHub
logger.info(LOG_TAG) { " #{file.name}" } logger.info(LOG_TAG) { " #{file.name}" }
path = Cache.install_path(@application, @channel) path = Cache.install_path(@application, @channel)
file_path = "#{path}/#{file.name}".sub('Data/', 'data/') file_path = normalize_path(file.name, path)
File.delete(file_path) if File.exist?(file_path) File.delete(file_path) if File.exist?(file_path)
@@ -555,7 +591,7 @@ class W3DHub
def write_paths_ini def write_paths_ini
path = Cache.install_path(@application, @channel) path = Cache.install_path(@application, @channel)
File.open("#{path}/data/paths.ini", "w") do |file| File.open(normalize_path("data/paths.ini", path), "w") do |file|
file.puts("[paths]") file.puts("[paths]")
file.puts("RegBase=W3D Hub") file.puts("RegBase=W3D Hub")
file.puts("RegClient=#{@application.category}\\#{@application.id}-#{@channel.id}") file.puts("RegClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
@@ -589,17 +625,17 @@ class W3DHub
# Check for and integrity of local manifest # Check for and integrity of local manifest
package = nil package = nil
array = Api.package_details([{ category: category, subcategory: subcategory, name: name, version: version }]) array = Api.package_details([{ category: category, subcategory: subcategory, name: name, version: version }], @channel.source || :w3dhub)
if array.is_a?(Array) if array.is_a?(Array)
package = array.first package = array.first
else else
fail!("Failed to fetch manifest package details! (#{category}:#{subcategory}:#{name}:#{version})") fail!("Failed to fetch manifest package details! (#{category}:#{subcategory}:#{name}:#{version})")
return return false
end end
if package.error? if package.error?
fail!("Failed to retrieve manifest package details! (#{category}:#{subcategory}:#{name}:#{version})\nError: #{package.error.gsub("-", " ").capitalize}") fail!("Failed to retrieve manifest package details! (#{category}:#{subcategory}:#{name}:#{version})\nError: #{package.error.gsub("-", " ").capitalize}")
return return false
end end
if File.exist?(Cache.package_path(category, subcategory, name, version)) if File.exist?(Cache.package_path(category, subcategory, name, version))
@@ -622,6 +658,8 @@ class W3DHub
end end
fail!("Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})") unless status_okay fail!("Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})") unless status_okay
status_okay
end end
def verify_package(package, &block) def verify_package(package, &block)
@@ -698,38 +736,52 @@ class W3DHub
logger.info(LOG_TAG) { " Unpacking patch \"#{package_path}\" in \"#{temp_path}\"" } logger.info(LOG_TAG) { " Unpacking patch \"#{package_path}\" in \"#{temp_path}\"" }
unzip(package_path, temp_path) unzip(package_path, temp_path)
# Fix borked Data -> data 'cause Windows don't care about capitalization file_path = normalize_path(manifest_file.name, path)
safe_file_name = "#{manifest_file.name.sub('Data/', 'data/')}" temp_file_path = normalize_path(manifest_file.name, temp_path)
logger.info(LOG_TAG) { " Loading #{temp_path}/#{safe_file_name}.patch..." } logger.info(LOG_TAG) { " Loading #{temp_file_path}.patch..." }
patch_mix = W3DHub::Mixer::Reader.new(file_path: "#{temp_path}/#{safe_file_name}.patch", ignore_crc_mismatches: false) patch_mix = W3DHub::WWMix.new(path: "#{temp_file_path}.patch")
patch_info = JSON.parse(patch_mix.package.files.find { |f| f.name.casecmp?(".w3dhub.patch") || f.name.casecmp?(".bhppatch") }.data, symbolize_names: true) unless patch_mix.load
raise patch_mix.error_reason
end
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)
logger.info(LOG_TAG) { " Loading #{path}/#{safe_file_name}..." } patch_info = JSON.parse(patch_entry.blob, symbolize_names: true)
target_mix = W3DHub::Mixer::Reader.new(file_path: "#{path}/#{safe_file_name}", ignore_crc_mismatches: false)
logger.info(LOG_TAG) { " Loading #{file_path}..." }
target_mix = W3DHub::WWMix.new(path: "#{file_path}")
unless target_mix.load
raise target_mix.error_reason
end
logger.info(LOG_TAG) { " Removing files..." } if patch_info[:removedFiles].size.positive? logger.info(LOG_TAG) { " Removing files..." } if patch_info[:removedFiles].size.positive?
patch_info[:removedFiles].each do |file| patch_info[:removedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" } logger.debug(LOG_TAG) { " #{file}" }
target_mix.package.files.delete_if { |f| f.name.casecmp?(file) } target_mix.entries.delete_if { |e| e.name.casecmp?(file) }
end end
logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive? logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive?
patch_info[:updatedFiles].each do |file| patch_info[:updatedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" } logger.debug(LOG_TAG) { " #{file}" }
patch = patch_mix.package.files.find { |f| f.name.casecmp?(file) } patch_mix.entries.each do |entry|
target = target_mix.package.files.find { |f| f.name.casecmp?(file) } target_mix.add_entry(entry: entry, replace: true)
if target
target_mix.package.files[target_mix.package.files.index(target)] = patch
else
target_mix.package.files << patch
end end
end end
logger.info(LOG_TAG) { " Writing updated #{path}/#{safe_file_name}..." } if patch_info[:updatedFiles].size.positive? logger.info(LOG_TAG) { " Writing updated #{file_path}..." } if patch_info[:updatedFiles].size.positive?
W3DHub::Mixer::Writer.new(file_path: "#{path}/#{safe_file_name}", package: target_mix.package, memory_buffer: true) temp_mix_path = "#{temp_path}/#{File.basename(file_path)}"
temp_mix = W3DHub::WWMix.new(path: temp_mix_path, encrypted: target_mix.encrypted?)
target_mix.entries.each { |e| temp_mix.add_entry(entry: e, replace: true) }
unless temp_mix.save
raise temp_mix.error_reason
end
# Overwrite target mix with temp mix
FileUtils.mv(temp_mix_path, file_path)
FileUtils.remove_dir(temp_path) FileUtils.remove_dir(temp_path)
@@ -740,17 +792,15 @@ class W3DHub
stream = Zip::InputStream.new(File.open(package_path)) stream = Zip::InputStream.new(File.open(package_path))
while (entry = stream.get_next_entry) while (entry = stream.get_next_entry)
# Normalize the path to handle case-insensitivity consistently
file_path = normalize_path(entry.name, path)
safe_file_name = entry.name.gsub("\\", "/") dir_path = File.dirname(file_path)
# Fix borked Data -> data 'cause Windows don't care about capitalization
safe_file_name.sub!("Data/", "data/")
dir_path = "#{path}/#{File.dirname(safe_file_name)}"
unless dir_path.end_with?("/.") || Dir.exist?(dir_path) unless dir_path.end_with?("/.") || Dir.exist?(dir_path)
FileUtils.mkdir_p(dir_path) FileUtils.mkdir_p(dir_path)
end end
File.open("#{path}/#{safe_file_name}", "wb") do |f| File.open(file_path, "wb") do |f|
i = entry.get_input_stream i = entry.get_input_stream
while (chunk = i.read(32_000_000)) # Read up to ~32 MB per chunk while (chunk = i.read(32_000_000)) # Read up to ~32 MB per chunk

View File

@@ -76,7 +76,7 @@ class W3DHub
end end
def save_config(config = @config) def save_config(config = @config)
File.write(CONFIG_PATH, config.to_json) File.write(CONFIG_PATH, JSON.pretty_generate(config))
end end
end end
end end

View File

@@ -23,7 +23,12 @@ class W3DHub
def self.verify_peer def self.verify_peer
no_verify.tap do |context| no_verify.tap do |context|
context.verify_mode = OpenSSL::SSL::VERIFY_PEER context.verify_mode = OpenSSL::SSL::VERIFY_PEER
context.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths) context.cert_store = OpenSSL::X509::Store.new
if (ca_file = W3DHub.ca_bundle_path)
context.cert_store.add_file(ca_file)
else
context.cert_store.set_default_paths
end
end end
end end

View File

@@ -1,7 +1,7 @@
class W3DHub class W3DHub
# Maybe add remote game launch from server list app? # Maybe add remote game launch from server list app?
class MulticastServer # Maybe add intranet package delivery?
MULTICAST_ADDR = "224.87.51.68" class BroadcastServer
PORT = 7050 PORT = 7050
def initialize def initialize

View File

@@ -9,18 +9,18 @@ class W3DHub
end end
# Fetch a generic uri # Fetch a generic uri
def self.fetch(uri:, force_fetch: false, async: true) def self.fetch(uri:, force_fetch: false, async: true, backend: :w3dhub)
path = path(uri) path = path(uri)
if !force_fetch && File.exist?(path) if !force_fetch && File.exist?(path)
path path
elsif async elsif async
BackgroundWorker.job( BackgroundWorker.job(
-> { Api.get(uri, W3DHub::Api::DEFAULT_HEADERS) }, -> { Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend) },
->(response) { File.open(path, "wb") { |f| f.write response.body } if response.status == 200 } ->(response) { File.open(path, "wb") { |f| f.write response.body } if response.status == 200 }
) )
else else
response = Api.get(uri, W3DHub::Api::DEFAULT_HEADERS) response = Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend)
File.open(path, "wb") { |f| f.write response.body } if response.status == 200 File.open(path, "wb") { |f| f.write response.body } if response.status == 200
end end
end end
@@ -50,10 +50,16 @@ class W3DHub
end end
# Download a W3D Hub package # Download a W3D Hub package
# TODO: More work needed to make this work reliably def self.async_fetch_package(package, block)
def self._async_fetch_package(package, block) endpoint_download_url = package.download_url || "#{Api::W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package"
if package.download_url
uri_path = package.download_url.split("/").last
endpoint_download_url = package.download_url.sub(uri_path, URI.encode_uri_component(uri_path))
end
path = package_path(package.category, package.subcategory, package.name, package.version) path = package_path(package.category, package.subcategory, package.name, package.version)
headers = Api::FORM_ENCODED_HEADERS headers = [["content-type", "application/x-www-form-urlencoded"], ["user-agent", Api::USER_AGENT]]
headers << ["authorization", "Bearer #{Store.account.access_token}"] if Store.account && !package.download_url
body = URI.encode_www_form("data": JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version }))
start_from_bytes = package.custom_partially_valid_at_bytes start_from_bytes = package.custom_partially_valid_at_bytes
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" } logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
@@ -63,78 +69,62 @@ class W3DHub
file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb") file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb")
if start_from_bytes.positive? if start_from_bytes.positive?
headers = Api::FORM_ENCODED_HEADERS + [["Range", "bytes=#{start_from_bytes}-"]] headers << ["range", "bytes=#{start_from_bytes}-"]
file.pos = start_from_bytes file.pos = start_from_bytes
end end
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}" result = false
Sync do
response = Api.post("#{Api::ENDPOINT}/apis/launcher/1/get-package", headers, body) uri = URI(endpoint_download_url)
response = W3DHub::Api.provision_http_client(uri.origin).send((package.download_url ? :get : :post), uri.request_uri, headers, body)
if response.success?
total_bytes = package.size total_bytes = package.size
remaining_bytes = total_bytes - start_from_bytes
response.each do |chunk| response.each do |chunk|
file.write(chunk) file.write(chunk)
remaining_bytes -= chunk.size block.call(chunk, total_bytes - file.pos, total_bytes)
block.call(chunk, remaining_bytes, total_bytes)
end end
response.status == 200 result = true
end
binding.irb unless response
if response&.status == 200 || response&.status == 206
result = true
else
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
result = false
end
rescue Async::Timeout => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" timed out after: #{W3DHub::Api::API_TIMEOUT} seconds" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
result = false
rescue StandardError => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" errored:" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
result = false
ensure ensure
file&.close file&.close
response&.close
end
result
end end
# Download a W3D Hub package # Download a W3D Hub package
def self.fetch_package(package, block) def self.fetch_package(package, block)
endpoint_download_url = package.download_url || "#{Api::ENDPOINT}/apis/launcher/1/get-package" async_fetch_package(package, block)
path = package_path(package.category, package.subcategory, package.name, package.version)
headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT }
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account && !package.download_url
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
start_from_bytes = package.custom_partially_valid_at_bytes
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
create_directories(path)
file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb")
if start_from_bytes.positive?
headers["Range"] = "bytes=#{start_from_bytes}-"
file.pos = start_from_bytes
end
streamer = lambda do |chunk, remaining_bytes, total_bytes|
file.write(chunk)
block.call(chunk, remaining_bytes, total_bytes)
end
# Create a new connection due to some weirdness somewhere in Excon
response = Excon.send(
package.download_url ? :get : :post,
endpoint_download_url,
tcp_nodelay: true,
headers: headers,
body: package.download_url ? "" : body,
chunk_size: 50_000,
response_block: streamer,
middlewares: Excon.defaults[:middlewares] + [Excon::Middleware::RedirectFollower]
)
if response.status == 200 || response.status == 206
return true
else
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response.status}" }
false
end
ensure
file&.close
end end
def self.acquire_net_lock(key) def self.acquire_net_lock(key)

View File

@@ -32,6 +32,15 @@ class W3DHub
linux? || mac? linux? || mac?
end end
# Detect system CA bundle path for SSL verification
def self.ca_bundle_path
[
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora/CentOS
"/etc/ssl/ca-bundle.pem" # Some other distros
].find { |path| File.exist?(path) }
end
def self.url(path) def self.url(path)
raise "Hazardous input: #{path}" if path.include?("&&") || path.include?(";") raise "Hazardous input: #{path}" if path.include?("&&") || path.include?(";")
@@ -52,10 +61,8 @@ class W3DHub
prefill: Store.settings[:server_list_username], prefill: Store.settings[:server_list_username],
accept_callback: accept_callback, accept_callback: accept_callback,
cancel_callback: cancel_callback, cancel_callback: cancel_callback,
# See: https://gitlab.com/danpaul88/brenbot/-/blob/master/Source/renlog.pm#L136-175
valid_callback: proc do |entry| valid_callback: proc do |entry|
entry.length > 1 && entry.length < 30 && (entry =~ /(:|!|&|%| )/i).nil? && entry.length.between?(3, 40) && (entry =~ /^[a-z0-9_\-\[\]]+$/i)
(entry =~ /[\001\002\037]/).nil? && (entry =~ /\\/).nil?
end end
) )
end end
@@ -72,15 +79,19 @@ class W3DHub
) )
end end
def self.join_server(server, password) def self.join_server(server:, username: Store.settings[:server_list_username], password: nil, multi: false)
if ( if (
(server.status.password && password.length.positive?) || (server.status.password && password.length.positive?) ||
!server.status.password) && !server.status.password) &&
Store.settings[:server_list_username].to_s.length.positive? username.to_s.length.positive?
Store.application_manager.join_server( Store.application_manager.join_server(
server.game, server.game,
server.channel, server, password server.channel,
server,
username,
password,
multi
) )
else else
CyberarmEngine::Window.instance.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?") CyberarmEngine::Window.instance.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?")
@@ -110,7 +121,6 @@ class W3DHub
process_info = Process.create(**hash) process_info = Process.create(**hash)
pid = process_info.process_id pid = process_info.process_id
status = -1
until (status = Process.get_exitcode(pid)) until (status = Process.get_exitcode(pid))
if block if block
@@ -127,8 +137,7 @@ class W3DHub
end end
status.zero? status.zero?
else elsif block
if block
IO.popen(command, "r") do |io| IO.popen(command, "r") do |io|
io.each_line do |line| io.each_line do |line|
block&.call(line) block&.call(line)
@@ -140,16 +149,17 @@ class W3DHub
system(command) system(command)
end end
end end
end
def self.home_directory def self.home_directory
File.expand_path("~") File.expand_path("~")
end end
def self.ask_file(title: "Open File", filter: "*game*.exe") def self.ask_file(title: "Open File", filter: "*game*.exe", filters: [])
filters << filter if filters.empty?
if W3DHub.unix? if W3DHub.unix?
# search for command # search for command
cmds = %w{ zenity matedialog qarma kdialog } cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd| command = cmds.find do |cmd|
cmd if system("which #{cmd}") cmd if system("which #{cmd}")
@@ -157,9 +167,10 @@ class W3DHub
path = case File.basename(command) path = case File.basename(command)
when "zenity", "matedialog", "qarma" when "zenity", "matedialog", "qarma"
`#{command} --file-selection --title "#{title}" --file-filter "#{filter}"` options = filters.map { |s| format("--file-filter=\"%s\"", s) }.join(" ")
`#{command} --file-selection --title \"#{title}\" #{options}`
when "kdialog" when "kdialog"
`#{command} --title "#{title}" --getopenfilename . "#{filter}"` `#{command} --title "#{title}" --getopenfilename . "#{filters.join(" ")}"`
else else
raise "No known command found for system file selection dialog!" raise "No known command found for system file selection dialog!"
end end
@@ -176,7 +187,7 @@ class W3DHub
def self.ask_folder(title: "Open Folder") def self.ask_folder(title: "Open Folder")
if W3DHub.unix? if W3DHub.unix?
# search for command # search for command
cmds = %w{ zenity matedialog qarma kdialog } cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd| command = cmds.find do |cmd|
cmd if system("which #{cmd}") cmd if system("which #{cmd}")
@@ -193,7 +204,7 @@ class W3DHub
path.strip path.strip
else else
result_ptr = LibUI.open_folder(window) result_ptr = LibUI.open_folder(LIBUI_WINDOW)
result = result_ptr.null? ? "" : result_ptr.to_s.gsub("\\", "/") result = result_ptr.null? ? "" : result_ptr.to_s.gsub("\\", "/")
result.strip result.strip

View File

@@ -2,7 +2,7 @@ class W3DHub
class HardwareSurvey class HardwareSurvey
attr_reader :data attr_reader :data
def initialize def initialize(displays_only: false)
@data = { @data = {
displays: [], displays: [],
system: { system: {
@@ -26,8 +26,6 @@ class W3DHub
} }
} }
# Hardware survey only works on Windows atm
if Gem::win_platform? if Gem::win_platform?
lib_dir = File.dirname($LOADED_FEATURES.find { |file| file.include?("gosu.so") }) lib_dir = File.dirname($LOADED_FEATURES.find { |file| file.include?("gosu.so") })
SDL.load_lib("#{lib_dir}64/SDL2.dll") SDL.load_lib("#{lib_dir}64/SDL2.dll")
@@ -36,11 +34,13 @@ class W3DHub
end end
query_displays query_displays
unless displays_only
query_motherboard query_motherboard
query_operating_system query_operating_system
query_cpus query_cpus
query_ram query_ram
query_gpus query_gpus
end
@data.freeze @data.freeze
end end
@@ -68,8 +68,8 @@ class W3DHub
end end
def query_motherboard def query_motherboard
return unless Gem::win_platform? if Gem::win_platform?
begin
Win32::Registry::HKEY_LOCAL_MACHINE.open("HARDWARE\\DESCRIPTION\\System\\BIOS", Win32::Registry::KEY_READ) do |reg| Win32::Registry::HKEY_LOCAL_MACHINE.open("HARDWARE\\DESCRIPTION\\System\\BIOS", Win32::Registry::KEY_READ) do |reg|
@data[:system][:motherboard][:manufacturer] = safe_reg(reg, "SystemManufacturer") @data[:system][:motherboard][:manufacturer] = safe_reg(reg, "SystemManufacturer")
@data[:system][:motherboard][:model] = safe_reg(reg, "SystemProductName") @data[:system][:motherboard][:model] = safe_reg(reg, "SystemProductName")
@@ -84,10 +84,16 @@ class W3DHub
@data[:system][:motherboard][:bios_release_date] = "Unknown" @data[:system][:motherboard][:bios_release_date] = "Unknown"
@data[:system][:motherboard][:bios_version] = "Unknown" @data[:system][:motherboard][:bios_version] = "Unknown"
end end
else # unix
@data[:system][:motherboard][:manufacturer] = safe_file("/sys/devices/virtual/dmi/id/board_vendor")
@data[:system][:motherboard][:model] = safe_file("/sys/devices/virtual/dmi/id/board_name")
@data[:system][:motherboard][:bios_version] = safe_file("/sys/devices/virtual/dmi/id/board_version")
end
end
def query_operating_system def query_operating_system
return unless Gem::win_platform? if Gem::win_platform?
begin
Win32::Registry::HKEY_LOCAL_MACHINE.open("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", Win32::Registry::KEY_READ) do |reg| Win32::Registry::HKEY_LOCAL_MACHINE.open("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", Win32::Registry::KEY_READ) do |reg|
@data[:system][:operating_system][:name] = safe_reg(reg, "ProductName") @data[:system][:operating_system][:name] = safe_reg(reg, "ProductName")
@data[:system][:operating_system][:build] = safe_reg(reg, "CurrentBuild") @data[:system][:operating_system][:build] = safe_reg(reg, "CurrentBuild")
@@ -100,6 +106,14 @@ class W3DHub
@data[:system][:operating_system][:version] = "Unknown" @data[:system][:operating_system][:version] = "Unknown"
@data[:system][:operating_system][:edition] = "Unknown" @data[:system][:operating_system][:edition] = "Unknown"
end end
else # unix
release_info = query_release_info
@data[:system][:operating_system][:name] = release_info["pretty_name"] || release_info["name"] || "Unknown"
@data[:system][:operating_system][:build] = release_info["version_codename"] || release_info["build_id"] || "Unknown"
@data[:system][:operating_system][:version] = release_info["version_id"] || release_info["build_id"] || "Unknown"
@data[:system][:operating_system][:edition] = release_info["id"] || release_info["id_like"] || "Unknown"
end
end
def query_cpus def query_cpus
if Gem::win_platform? if Gem::win_platform?
@@ -122,6 +136,16 @@ class W3DHub
end end
rescue Win32::Registry::Error rescue Win32::Registry::Error
end end
else
cpu_info = query_cpu_info
cpu_info.each do |cpu|
@data[:system][:cpus] << {
manufacturer: cpu["manufacturer"] || "Unknown",
model: cpu["model"] || "Unknown",
mhz: cpu["mhz"] || "Unknown",
family: cpu["family"] || "Unknown"
}
end
end end
instruction_sets = %w[ HasRDTSC HasAltiVec HasMMX Has3DNow HasSSE HasSSE2 HasSSE3 HasSSE41 HasSSE42 HasAVX HasAVX2 HasAVX512F HasARMSIMD HasNEON ] # HasLSX HasLASX # These cause a crash atm instruction_sets = %w[ HasRDTSC HasAltiVec HasMMX Has3DNow HasSSE HasSSE2 HasSSE3 HasSSE41 HasSSE42 HasAVX HasAVX2 HasAVX512F HasARMSIMD HasNEON ] # HasLSX HasLASX # These cause a crash atm
@@ -140,8 +164,8 @@ class W3DHub
end end
def query_gpus def query_gpus
return unless Gem::win_platform? if Gem::win_platform?
begin
Win32::Registry::HKEY_LOCAL_MACHINE.open("SYSTEM\\ControlSet001\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}", Win32::Registry::KEY_READ) do |reg| Win32::Registry::HKEY_LOCAL_MACHINE.open("SYSTEM\\ControlSet001\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}", Win32::Registry::KEY_READ) do |reg|
i = 0 i = 0
@@ -179,11 +203,149 @@ class W3DHub
end end
rescue Win32::Registry::Error rescue Win32::Registry::Error
end end
else # unix
gpu_info = query_glx_info
gpu_info.each do |gpu|
@data[:system][:gpus] << {
manufacturer: gpu["manufacturer"] || "Unknown",
model: gpu["model"] || "Unknown",
vram: gpu["vram"].to_i,
driver_date: gpu["driver_date"] || "Unknown",
driver_version: gpu["driver_version"] || "Unknown"
}
end
end
end
def safe_reg(reg, key, default_value = "Unknown") def safe_reg(reg, key, default_value = "Unknown")
reg[key] reg[key]
rescue Win32::Registry::Error rescue Win32::Registry::Error
default_value default_value
end end
def safe_file(path, default_value = "Unknown")
value = File.read(path).to_s.strip
return default_value if value.downcase == "default string"
value
rescue
default_value
end
def query_release_info
hash = {}
File.open("/etc/os-release") do |f|
f.each_line do |line|
line = line.strip
key, value = line.split("=", 2)
value.gsub!('"', "")
hash[key.downcase] = value
end
end
hash
rescue
hash
end
def query_cpu_info
cpus = []
cpu = {}
File.open("/proc/cpuinfo") do |f|
f.each_line do |line|
line = line.strip
if line.empty?
cpu["family"] = format(
"%s Family %s Model %s Stepping %s",
cpu["manufacturer"] || "Unknown",
cpu["_family"] || "Unknown",
cpu["_model"] || "Unknown",
cpu["_stepping"] || "Unknown",
)
cpus << cpu
cpu = {}
next
end
key, value = line.split(":", 2).map(&:strip)
case key.downcase
when "vendor_id"
cpu["manufacturer"] = value
when "model name"
cpu["model"] = value
when "cpu mhz"
cpu["mhz"] = value
when "cpu family"
cpu["_family"] = value
when "model"
cpu["_model"] = value
when "stepping"
cpu["_stepping"] = value
end
end
end
cpus
rescue
cpus
end
def query_glx_info
gpus = []
glxinfo = `glxinfo`
return gpus if glxinfo.empty?
gpu = {}
glxinfo.lines do |line|
line = line.strip
next if line.empty?
key, value = line.split(":", 2).map(&:strip)
mesa_info = false
gpu_memory_info = false
case key.downcase
when "opengl vendor string"
if mesa_info
gpus << gpu
gpu = {}
break
end
when /extended renderer info \(GLX_MESA_query_renderer\)/i
# Joy and happiness
mesa_info = true
when /Memory info \(GL_NVX_gpu_memory_info\)/i
# Happiness and joy
gpu_memory_info = true
when "vendor", "opengl vendor string"
gpu["manufacturer"] = value
when "device", "opengl renderer string"
gpu["model"] = value
when "version"
gpu["driver_version"] = value
when "video memory", "dedicated video memory"
gpu["vram"] = value.gsub(/[\D]+/, "")
when "opengl version string"
gpus << gpu
gpu = {}
break
end
end
gpus
end
end end
end end

102
lib/i18n.rb Normal file
View File

@@ -0,0 +1,102 @@
# The I18n gem is a real pain to work with when packaging with Ocra(n)
# and we're not using its 'advanced' features so emulate its API here.
require "yaml"
class I18n
class InvalidLocale < StandardError
end
@locale = :en
@default_locale = :en
@load_path = []
@translations = {}
def self.load_path
@load_path
end
def self.default_locale
@default_locale.to_sym
end
def self.default_locale=(locale)
@default_locale = locale.to_s
end
def self.locale
@locale.to_sym
end
def self.locale=(locale)
locale = locale.to_s
raise InvalidLocale unless valid_locale?(locale)
@locale = locale
end
def self.t(symbol)
return symbol.to_s unless valid_locale?(@locale)
@translations[@locale] || load_locale(@locale)
translations = @translations[@locale]
return translations[symbol] if translations
translation = @translations.dig(@default_locale, symbol)
return translation if translation
return symbol.to_s
end
def self.available_locales
@load_path.flatten.map { |f| File.basename(f, ".yml").to_s.downcase.to_sym }
end
private
def self.load_locale(locale)
locale = locale.to_s
return if @translations[locale] && !@translations[locale].empty?
if (file = valid_locale?(locale))
yaml = YAML.load_file(file)
raise InvalidLocale unless yaml[locale]
key = ""
hash = yaml[locale]
hash.each_pair do |key, v|
if v.is_a?(String)
@translations[locale] ||= {}
@translations[locale][key.to_sym] = v
else
load_locale_part(locale, key, v)
end
end
end
end
def self.load_locale_part(locale, key, part)
locale = locale.to_s
part.each_pair do |k, v|
if v.is_a?(String)
@translations[locale] ||= {}
@translations[locale]["#{key}.#{k}".to_sym] = v
else
load_locale_part(locale, "#{key}.#{k}", v)
end
end
end
def self.valid_locale?(locale)
locale = locale.to_s
@load_path.flatten.find do |file|
File.basename(file, ".yml").to_s.downcase.strip == locale
end
end
end

View File

@@ -1,370 +0,0 @@
require "digest"
require "stringio"
class W3DHub
# https://github.com/TheUnstoppable/MixLibrary used for reference
class Mixer
DEFAULT_BUFFER_SIZE = 32_000_000
class MixParserException < RuntimeError; end
class MixFormatException < RuntimeError; end
class MemoryBuffer
def initialize(file_path:, mode:, buffer_size:, encoding: Encoding::ASCII_8BIT)
@mode = mode
@file = File.open(file_path, mode == :read ? "rb" : "wb")
@file.pos = 0
@file_size = File.size(file_path)
@buffer_size = buffer_size
@chunk = 0
@last_chunk = 0
@max_chunks = @file_size / @buffer_size
@last_cached_chunk = nil
@encoding = encoding
@last_buffer_pos = 0
@buffer = @mode == :read ? StringIO.new(@file.read(@buffer_size)) : StringIO.new
@buffer.set_encoding(encoding)
# Cache frequently accessed chunks to reduce disk hits
@cache = {}
end
def pos
@chunk * @buffer_size + @buffer.pos
end
def pos=(offset)
last_chunk = @chunk
@chunk = offset / @buffer_size
raise "No backsies! #{offset} (#{@chunk}/#{last_chunk})" if @mode == :write && @chunk < last_chunk
fetch_chunk(@chunk) if @mode == :read
@buffer.pos = offset % @buffer_size
end
# string of bytes
def write(bytes)
length = bytes.length
# Crossing buffer boundry
if @buffer.pos + length > @buffer_size
edge_size = @buffer_size - @buffer.pos
buffer_edge = bytes[0...edge_size]
bytes_to_write = bytes.length - buffer_edge.length
chunks_to_write = (bytes_to_write / @buffer_size.to_f).ceil
bytes_written = buffer_edge.length
@buffer.write(buffer_edge)
flush_chunk
chunks_to_write.times do |i|
i += 1
@buffer.write(bytes[bytes_written...bytes_written + @buffer_size])
bytes_written += @buffer_size
flush_chunk if string.length == @buffer_size
end
else
@buffer.write(bytes)
end
bytes
end
def write_header(data_offset:, name_offset:)
flush_chunk
@file.pos = 4
write_i32(data_offset)
write_i32(name_offset)
@file.pos = 0
end
def write_i32(int)
@file.write([int].pack("l"))
end
def read(bytes = nil)
raise ArgumentError, "Cannot read whole file" if bytes.nil?
raise ArgumentError, "Cannot under read buffer" if bytes.negative?
# Long read, need to fetch next chunk while reading, mostly defeats this class...?
if @buffer.pos + bytes > buffered
buff = string[@buffer.pos..buffered]
bytes_to_read = bytes - buff.length
chunks_to_read = (bytes_to_read / @buffer_size.to_f).ceil
chunks_to_read.times do |i|
i += 1
fetch_chunk(@chunk + 1)
if i == chunks_to_read # read partial
already_read_bytes = (chunks_to_read - 1) * @buffer_size
bytes_more_to_read = bytes_to_read - already_read_bytes
buff << @buffer.read(bytes_more_to_read)
else
buff << @buffer.read
end
end
buff
else
fetch_chunk(@chunk) if @last_chunk != @chunk
@buffer.read(bytes)
end
end
def readbyte
fetch_chunk(@chunk + 1) if @buffer.pos + 1 > buffered
@buffer.readbyte
end
def fetch_chunk(chunk)
raise ArgumentError, "Cannot fetch chunk #{chunk}, only #{@max_chunks} exist!" if chunk > @max_chunks
@last_chunk = @chunk
@chunk = chunk
@last_buffer_pos = @buffer.pos
cached = @cache[chunk]
if cached
@buffer.string = cached
else
@file.pos = chunk * @buffer_size
buff = @buffer.string = @file.read(@buffer_size)
# Cache the active chunk (implementation bounces from @file_data_chunk and back to this for each 'file' processed)
if @chunk != @file_data_chunk && @chunk != @last_cached_chunk
@cache.delete(@last_cached_chunk) unless @last_cached_chunk == @file_data_chunk
@cache[@chunk] = buff
@last_cached_chunk = @chunk
end
buff
end
end
# This is accessed quite often, keep it around
def cache_file_data_chunk!
@file_data_chunk = @chunk
last_buffer_pos = @buffer.pos
@buffer.pos = 0
@cache[@chunk] = @buffer.read
@buffer.pos = last_buffer_pos
end
def flush_chunk
@last_chunk = @chunk
@chunk += 1
@file.pos = @last_chunk * @buffer_size
@file.write(string)
@buffer.string = "".force_encoding(@encoding)
end
def string
@buffer.string
end
def buffered
@buffer.string.length
end
def close
@file&.close
end
end
class Reader
attr_reader :package
def initialize(file_path:, ignore_crc_mismatches: false, metadata_only: false, buffer_size: DEFAULT_BUFFER_SIZE)
@package = Package.new
@buffer = MemoryBuffer.new(file_path: file_path, mode: :read, buffer_size: buffer_size)
@buffer.pos = 0
# Valid header
if read_i32 == 0x3158494D
file_data_offset = read_i32
file_names_offset = read_i32
@buffer.pos = file_names_offset
file_count = read_i32
file_count.times do
@package.files << Package::File.new(name: read_string)
end
@buffer.pos = file_data_offset
@buffer.cache_file_data_chunk!
_file_count = read_i32
file_count.times do |i|
file = @package.files[i]
file.mix_crc = read_u32.to_s(16).rjust(8, "0")
file.content_offset = read_u32
file.content_length = read_u32
if !ignore_crc_mismatches && file.mix_crc != file.file_crc
raise MixParserException, "CRC mismatch for #{file.name}. #{file.mix_crc.inspect} != #{file.file_crc.inspect}"
end
pos = @buffer.pos
@buffer.pos = file.content_offset
file.data = @buffer.read(file.content_length) unless metadata_only
@buffer.pos = pos
end
else
raise MixParserException, "Invalid MIX file"
end
ensure
@buffer&.close
@buffer = nil # let GC collect
end
def read_i32
@buffer.read(4).unpack1("l")
end
def read_u32
@buffer.read(4).unpack1("L")
end
def read_string
buffer = ""
length = @buffer.readbyte
length.times do
buffer << @buffer.readbyte
end
buffer.strip
end
end
class Writer
attr_reader :package
def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE)
@package = package
@buffer = MemoryBuffer.new(file_path: file_path, mode: :write, buffer_size: buffer_size)
@buffer.pos = 0
@buffer.write("MIX1")
files = @package.files.sort { |a, b| a.file_crc <=> b.file_crc }
@buffer.pos = 16
files.each do |file|
file.content_offset = @buffer.pos
file.content_length = file.data.length
@buffer.write(file.data)
@buffer.pos += -@buffer.pos & 7
end
file_data_offset = @buffer.pos
write_i32(files.count)
files.each do |file|
write_u32(file.file_crc.to_i(16))
write_u32(file.content_offset)
write_u32(file.content_length)
end
file_name_offset = @buffer.pos
write_i32(files.count)
files.each do |file|
write_byte(file.name.length + 1)
@buffer.write("#{file.name}\0")
end
@buffer.write_header(data_offset: file_data_offset, name_offset: file_name_offset)
ensure
@buffer&.close
end
def write_i32(int)
@buffer.write([int].pack("l"))
end
def write_u32(uint)
@buffer.write([uint].pack("L"))
end
def write_byte(byte)
@buffer.write([byte].pack("c"))
end
end
# Eager loads patch file and streams target file metadata (doen't load target file data or generate CRCs)
# after target file metadata is loaded, create a temp file and merge patched files into list then
# build ordered file list and stream patched files and target file chunks into temp file,
# after that is done, replace target file with temp file
class Patcher
def initialize(patch_files:, target_file:, temp_file:, buffer_size: DEFAULT_BUFFER_SIZE)
@patch_files = patch_files.to_a.map { |f| Reader.new(file_path: f) }
@target_file = File.open(target_file)
@temp_file = File.open(temp_file, "a+b")
@buffer_size = buffer_size
end
end
class Package
attr_reader :files
def initialize(files: [])
@files = files
end
class File
attr_accessor :name, :mix_crc, :content_offset, :content_length, :data
def initialize(name:, mix_crc: nil, content_offset: nil, content_length: nil, data: nil)
@name = name
@mix_crc = mix_crc
@content_offset = content_offset
@content_length = content_length
@data = data
end
def file_crc
return "e6fe46b8" if @name.downcase == ".w3dhub.patch"
Digest::CRC32.hexdigest(@name.upcase)
end
def data_crc
Digest::CRC32.hexdigest(@data)
end
end
end
end
end

View File

@@ -111,7 +111,7 @@ class W3DHub
return unless news return unless news
news.items[0..15].each do |item| news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false) Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
end end
@w3dhub_news = news @w3dhub_news = news
@@ -161,7 +161,13 @@ class W3DHub
flow(width: 1.0, max_width: 1230, height: 200, margin: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(0xff_252525))) do flow(width: 1.0, max_width: 1230, height: 200, margin: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(0xff_252525))) do
background 0x44_000000 background 0x44_000000
# Ensure the image file exists before trying to load it
if File.exist?(image_path)
image image_path, height: 1.0 image image_path, height: 1.0
else
logger.warn("W3DHub::Community") { "Image not found in cache: #{image_path}" }
image BLACK_IMAGE, height: 1.0
end
stack(fill: true, height: 1.0, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(0xff_252525))) do stack(fill: true, height: 1.0, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(0xff_252525))) do
tagline "<b>#{item.title}</b>", width: 1.0 tagline "<b>#{item.title}</b>", width: 1.0

View File

@@ -36,7 +36,7 @@ class W3DHub
background app_color background app_color
flow(width: 0.70, height: 1.0) do flow(width: 0.70, height: 1.0) do
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{task.app_id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{task.app_id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png" image_path = File.exist?("#{CACHE_PATH}/#{task.app_id}.png") ? "#{CACHE_PATH}/#{task.app_id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
@application_image = image image_path, height: 1.0 @application_image = image image_path, height: 1.0
stack(margin_left: 8, width: 0.75) do stack(margin_left: 8, width: 0.75) do

View File

@@ -17,7 +17,7 @@ class W3DHub
end end
# Game Menu # Game Menu
@game_page_container = stack(width: 1.0, fill: true, background_image: "#{GAME_ROOT_PATH}/media/textures/noiseb.png", background_image_mode: :tiled) do @game_page_container = stack(width: 1.0, fill: true) do
end end
end end
end end
@@ -85,7 +85,7 @@ class W3DHub
padding_left: 4, padding_right: 4, tip: game.name) do padding_left: 4, padding_right: 4, tip: game.name) do
background game.color if selected background game.color if selected
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{game.id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png" image_path = File.exist?("#{CACHE_PATH}/#{game.id}.png") ? "#{CACHE_PATH}/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_color = Store.application_manager.installed?(game.id, game.channels.first.id) ? 0xff_ffffff : 0x66_ffffff image_color = Store.application_manager.installed?(game.id, game.channels.first.id) ? 0xff_ffffff : 0x66_ffffff
flow(width: 1.0, height: 1.0, margin: 8, background_image: image_path, background_image_color: image_color, background_image_mode: :fill_height) do flow(width: 1.0, height: 1.0, margin: 8, background_image: image_path, background_image_color: image_color, background_image_mode: :fill_height) do
@@ -118,32 +118,30 @@ class W3DHub
@game_page_container.clear do @game_page_container.clear do
game_color = Gosu::Color.new(game.color) game_color = Gosu::Color.new(game.color)
game_color.alpha = 0x88 game_color.alpha = 0xaa
background game_color background_image_path = Cache.package_path(game.category, game.id, "background.png", "")
@game_page_container.style.background_image_color = game_color if File.exist?(background_image_path)
@game_page_container.style.default[:background_image_color] = game_color States::Interface.instance&.instance_variable_get(:"@interface_container")&.style&.background_image = get_image(background_image_path)
@game_page_container.update_background_image end
# Game Stuff # Game Stuff
flow(width: 1.0, fill: true) do flow(width: 1.0, fill: true) do
# background 0xff_9999ff
# Game options # Game options
stack(width: 360, height: 1.0, padding: 8, scroll: true, border_thickness_right: 1, border_color_right: W3DHub::BORDER_COLOR) do stack(width: 360, height: 1.0, padding: 8, scroll: true, background: game_color, border_thickness_right: 1, border_color_right: W3DHub::BORDER_COLOR) do
background 0x55_000000 # Game Logo
logo_image_path = Cache.package_path(game.category, game.id, "logo.png", "")
# Game Banner if File.exist?(logo_image_path)
image_path = "#{GAME_ROOT_PATH}/media/banners/#{game.id}.png" image logo_image_path, width: 1.0
if File.exist?(image_path)
image image_path, width: 1.0
else else
banner game.name unless File.exist?(image_path) banner game.name unless File.exist?(logo_image_path)
end end
stack(width: 1.0, fill: true, scroll: true, margin_top: 32) do stack(width: 1.0, fill: true, scroll: true, margin_top: 32) do
if Store.application_manager.installed?(game.id, channel.id) if Store.application_manager.installed?(game.id, channel.id)
para "v#{Store.application_manager.installed?(game.id, channel.id)[:installed_version]}"
Hash.new.tap { |hash| Hash.new.tap { |hash|
# hash[I18n.t(:"games.game_settings")] = { icon: "gear", block: proc { Store.application_manager.settings(game.id, channel.id) } } # hash[I18n.t(:"games.game_settings")] = { icon: "gear", block: proc { Store.application_manager.settings(game.id, channel.id) } }
# hash[I18n.t(:"games.wine_configuration")] = { icon: "gear", block: proc { Store.application_manager.wine_configuration(game.id, channel.id) } } if W3DHub.unix? # hash[I18n.t(:"games.wine_configuration")] = { icon: "gear", block: proc { Store.application_manager.wine_configuration(game.id, channel.id) } } if W3DHub.unix?
@@ -279,7 +277,7 @@ class W3DHub
end end
# Game Events # Game Events
@game_events_container = flow(width: 1.0, height: 128, padding: 8, visible: false) do @game_events_container = stack(width: 1.0, height: 128, padding: 8, scroll: true, visible: false) do
end end
# Game News # Game News
@@ -331,11 +329,6 @@ class W3DHub
def populate_all_games_view def populate_all_games_view
@game_page_container.clear do @game_page_container.clear do
background 0x88_353535
@game_page_container.style.background_image_color = 0x88_353535
@game_page_container.style.default[:background_image_color] = 0x88_353535
@game_page_container.update_background_image
@focused_game = nil @focused_game = nil
@focused_channel = nil @focused_channel = nil
@@ -375,7 +368,7 @@ class W3DHub
flow(width: 1.0, height: 28, padding: 8) do flow(width: 1.0, height: 28, padding: 8) do
para "Favorite", fill: true para "Favorite", fill: true
toggle_button checked: Store.application_manager.favorite?(game.id), height: 18, padding_top: 3, padding_right: 3, padding_bottom: 3, padding_left: 3 do |btn| toggle_button checked: Store.application_manager.favorite?(game.id), height: 18, padding_top: 3, padding_right: 3, padding_bottom: 3, padding_left: 3 do |btn|
Store.application_manager.favorive(game.id, btn.value) Store.application_manager.favorite(game.id, btn.value)
Store.settings.save_settings Store.settings.save_settings
populate_games_list populate_games_list
@@ -383,7 +376,7 @@ class W3DHub
end end
container = stack(fill: true, width: 1.0, padding: 8) do container = stack(fill: true, width: 1.0, padding: 8) do
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{game.id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png" image_path = File.exist?("#{CACHE_PATH}/#{game.id}.png") ? "#{CACHE_PATH}/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
flow(width: 1.0, margin_top: 8) do flow(width: 1.0, margin_top: 8) do
flow(fill: true) flow(fill: true)
image image_path, width: 0.5 image image_path, width: 0.5
@@ -425,7 +418,7 @@ class W3DHub
return false unless news return false unless news
news.items[0..15].each do |item| news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false) Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
end end
@game_news[game.id] = news @game_news[game.id] = news
@@ -438,6 +431,9 @@ class W3DHub
return unless @focused_game == game return unless @focused_game == game
if (feed = @game_news[game.id]) if (feed = @game_news[game.id])
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0xaa
@game_news_container.clear do @game_news_container.clear do
# Patch Notes # Patch Notes
if false # Patch notes if false # Patch notes
@@ -467,9 +463,7 @@ class W3DHub
feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item| feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item|
image_path = Cache.path(item.image) image_path = Cache.path(item.image)
flow(width: 1.0, max_width: 869, height: 200, margin: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do flow(width: 1.0, max_width: 869, height: 200, margin: 8, background: game_color, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
background 0x44_000000
if File.file?(image_path) if File.file?(image_path)
image image_path, height: 1.0 image image_path, height: 1.0
end end
@@ -518,9 +512,10 @@ class W3DHub
@game_events_container.show unless events.empty? @game_events_container.show unless events.empty?
@game_events_container.hide if events.empty? @game_events_container.hide if events.empty?
return unless (event = events.flatten.first)
@game_events_container.clear do @game_events_container.clear do
events.flatten.each do |event| stack(width: 1.0, fill: true, margin_left: 8, margin_right: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
stack(fill: true, height: 1.0, margin_left: 8, margin_right: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
background 0x44_000000 background 0x44_000000
title event.title, width: 1.0, text_align: :center title event.title, width: 1.0, text_align: :center
@@ -530,7 +525,6 @@ class W3DHub
end end
end end
end end
end
def populate_game_modifications(application, channel) def populate_game_modifications(application, channel)
@game_news_container.clear do @game_news_container.clear do

View File

@@ -44,8 +44,10 @@ class W3DHub
Store.settings[:account][:data] = account Store.settings[:account][:data] = account
Store.settings.save_settings Store.settings.save_settings
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false) if account if account
applications = Api.applications if account Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
applications = Api._applications
end
end end
[account, applications] [account, applications]
@@ -79,7 +81,7 @@ class W3DHub
if Store.account if Store.account
BackgroundWorker.foreground_job( BackgroundWorker.foreground_job(
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false) }, -> { Cache.fetch(uri: Store.account.avatar_uri, async: false, backend: :w3dhub) },
->(result) { ->(result) {
populate_account_info populate_account_info
page(W3DHub::Pages::Games) page(W3DHub::Pages::Games)
@@ -152,7 +154,7 @@ class W3DHub
Store.account = nil Store.account = nil
BackgroundWorker.foreground_job( BackgroundWorker.foreground_job(
-> { Api.applications }, -> { Api._applications },
lambda do |applications| lambda do |applications|
if applications if applications
Store.applications = applications Store.applications = applications

View File

@@ -3,7 +3,7 @@ class W3DHub
class ServerBrowser < Page class ServerBrowser < Page
def setup def setup
@server_locked_icons = {} @server_locked_icons = {}
@refresh_server_list = false @refresh_server_list_at_ms = nil
refresh_server = false refresh_server = false
@selected_server ||= nil @selected_server ||= nil
@@ -43,7 +43,7 @@ class W3DHub
app = Store.applications.games.find { |a| a.id == app_id.to_s } app = Store.applications.games.find { |a| a.id == app_id.to_s }
next unless app next unless app
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{app_id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{app_id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png" image_path = File.exist?("#{CACHE_PATH}/#{app.id}.png") ? "#{CACHE_PATH}/#{app.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image image_path, tip: "#{app.name}", height: 1.0, image image_path, tip: "#{app.name}", height: 1.0,
border_thickness_bottom: 1, border_color_bottom: 0x00_000000, border_thickness_bottom: 1, border_color_bottom: 0x00_000000,
@@ -138,8 +138,8 @@ class W3DHub
def update def update
super super
if @refresh_server_list && Gosu.milliseconds >= @refresh_server_list if @refresh_server_list_at_ms && Gosu.milliseconds >= @refresh_server_list_at_ms
@refresh_server_list = nil @refresh_server_list_at_ms = nil
# populate_server_list # populate_server_list
reorder_server_list reorder_server_list
@@ -196,11 +196,11 @@ class W3DHub
def ping_icon(server) def ping_icon(server)
case server.ping case server.ping
when 0..160 when 0..150
@ping_icons[:good] @ping_icons[:good]
when 161..250 when 151..200
@ping_icons[:fair] @ping_icons[:fair]
when 251..1_000 when 201..1_000
@ping_icons[:poor] @ping_icons[:poor]
when 1_001..5_000 when 1_001..5_000
@ping_icons[:bad] @ping_icons[:bad]
@@ -210,19 +210,7 @@ class W3DHub
end end
def ping_tip(server) def ping_tip(server)
server.ping.negative? ? "Ping failed" : "Ping #{server.ping}ms" server.ping == W3DHub::Api::ServerListServer::NO_OR_BAD_PING ? "Ping failed" : "Ping #{server.ping}ms"
end
def find_element_by_tag(container, tag, list = [])
return unless container
container.children.each do |child|
list << child if child.style.tag == tag
find_element_by_tag(child, tag, list) if child.is_a?(CyberarmEngine::Element::Container)
end
return list.first
end end
def refresh_server_list(server, mode = :update) # :remove, :refresh_all def refresh_server_list(server, mode = :update) # :remove, :refresh_all
@@ -231,7 +219,7 @@ class W3DHub
return return
end end
@refresh_server_list = Gosu.milliseconds + 3_000 @refresh_server_list_at_ms = Gosu.milliseconds + 3_000
@refresh_server = server if @selected_server&.id == server.id @refresh_server = server if @selected_server&.id == server.id
server_container = find_element_by_tag(@server_list_container, server.id) server_container = find_element_by_tag(@server_list_container, server.id)
@@ -283,24 +271,23 @@ class W3DHub
def stylize_selected_server(server_container) def stylize_selected_server(server_container)
server_container.style.background = @selected_color server_container.style.background = @selected_color
server_container.style.default[:background] = @selected_color server_container.style.hover.background = @selected_color
server_container.style.hover[:background] = @selected_color server_container.style.active.background = @selected_color
server_container.style.active[:background] = @selected_color
end end
def reorder_server_list def reorder_server_list
@server_list_container.children.sort_by! do |child| @server_list_container.children.sort_by! do |child|
s = Store.server_list.find { |s| s.id == child.style.tag } s = Store.server_list.find { |s| s.id == child.style.tag }
[s.status.player_count, s.id] [s.status.player_count, -s.ping]
end.reverse!.each_with_index do |child, i| end.reverse!.each_with_index do |child, i|
next if @selected_server_container && child == @selected_server_container next if @selected_server_container && child == @selected_server_container
child.style.hover[:background] = 0xaa_555566 child.style.hover.background = 0xaa_555566
child.style.hover[:active] = 0xaa_555588 child.style.active.background = 0xaa_555588
child.style.default[:background] = 0xaa_333333 if i.even? child.style.background = 0xaa_333333 if i.even?
child.style.default[:background] = 0x00_000000 if i.odd? child.style.background = 0x00_000000 if i.odd?
end end
@server_list_container.recalculate @server_list_container.recalculate
@@ -333,7 +320,8 @@ class W3DHub
para server&.status&.name, tag: :server_name, font: BOLD_FONT, text_wrap: :none para server&.status&.name, tag: :server_name, font: BOLD_FONT, text_wrap: :none
flow(width: 1.0, height: 1.0) do flow(width: 1.0, height: 1.0) do
para Store.application_manager.channel_name(server.game, server.channel).to_s, width: 172, margin_right: 8, tag: :server_channel para server.version, margin_right: 8, tag: :server_version
para Store.application_manager.channel_name(server.game, server.channel).to_s, width: 148, margin_right: 8, tag: :server_channel
para server.region, tag: :server_region para server.region, tag: :server_region
end end
end end
@@ -389,11 +377,12 @@ class W3DHub
flow(width: 1.0, height: 46, margin_top: 16, margin_bottom: 16) do flow(width: 1.0, height: 46, margin_top: 16, margin_bottom: 16) do
game_installed = Store.application_manager.installed?(server.game, server.channel) game_installed = Store.application_manager.installed?(server.game, server.channel)
game_updatable = Store.application_manager.updateable?(server.game, server.channel) game_updatable = Store.application_manager.updateable?(server.game, server.channel)
matching_version = (game_installed && game_installed[:installed_version] == server.version) || server.version == Api::ServerListServer::NO_OR_DEFAULT_VERSION
channel = Store.application_manager.channel(server.game, server.channel) channel = Store.application_manager.channel(server.game, server.channel)
style = ((channel && channel.user_level.downcase.strip == "public") || server.channel == "release") ? {} : TESTING_BUTTON style = ((channel && channel.user_level.downcase.strip == "public") || server.channel == "release") ? {} : TESTING_BUTTON
flow(fill: true) flow(fill: true)
button "<b>#{I18n.t(:"server_browser.join_server")}</b>", enabled: (game_installed && !game_updatable), **style do button "<b>#{I18n.t(:"server_browser.join_server")}</b>", enabled: (game_installed && !game_updatable && matching_version), **style do
# Check for nickname # Check for nickname
# prompt for nickname # prompt for nickname
# !abort unless nickname set # !abort unless nickname set
@@ -410,11 +399,11 @@ class W3DHub
if server.status.password if server.status.password
W3DHub.prompt_for_password( W3DHub.prompt_for_password(
accept_callback: proc do |password| accept_callback: proc do |password|
W3DHub.join_server(server, password) W3DHub.join_server(server: server, password: password)
end end
) )
else else
W3DHub.join_server(server, nil) W3DHub.join_server(server: server)
end end
end end
) )
@@ -422,18 +411,24 @@ class W3DHub
if server.status.password if server.status.password
W3DHub.prompt_for_password( W3DHub.prompt_for_password(
accept_callback: proc do |password| accept_callback: proc do |password|
W3DHub.join_server(server, password) W3DHub.join_server(server: server, password: password)
end end
) )
else else
W3DHub.join_server(server, nil) W3DHub.join_server(server: server)
end end
end end
end end
if W3DHUB_DEVELOPER if W3DHUB_DEVELOPER
list_box(items: (1..12).to_a.map(&:to_s), margin_left: 16, width: 72, tip: "Number of game clients", enabled: (game_installed && !game_updatable), **TESTING_BUTTON) client_instances = list_box(items: (1..12).to_a.map(&:to_s), margin_left: 16, width: 72, tip: "Number of game clients", enabled: (game_installed && !game_updatable), **TESTING_BUTTON)
button "Multijoin", tip: "Launch multiple clients with configured username_\#{number}", enabled: (game_installed && !game_updatable), **TESTING_BUTTON button("Multijoin", tip: "Launch multiple clients with configured username_\#{number}", enabled: (game_installed && !game_updatable), **TESTING_BUTTON) do
username = Store.settings[:server_list_username]
client_instances.value.to_i.times do |i|
W3DHub.join_server(server: server, username: format("%s_%d", username, i), multi: true)
end
end
end end
flow(fill: true) flow(fill: true)
@@ -534,7 +529,7 @@ class W3DHub
end end
def game_icon(server) def game_icon(server)
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{server.game.nil? ? 'ren' : server.game}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{server.game.nil? ? 'ren' : server.game}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png" image_path = File.exist?("#{CACHE_PATH}/#{server.game.nil? ? 'ren' : server.game}.png") ? "#{CACHE_PATH}/#{server.game.nil? ? 'ren' : server.game}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
if server.status.password if server.status.password
@server_locked_icons[server.game] ||= Gosu.render(96, 96) do @server_locked_icons[server.game] ||= Gosu.render(96, 96) do

View File

@@ -3,82 +3,92 @@ class W3DHub
class Settings < Page class Settings < Page
def setup def setup
body.clear do body.clear do
stack(width: 1.0, height: 1.0, padding: 16, scroll: true) do stack(width: 1.0, height: 1.0, padding: 16) do
background 0xaa_252525 background 0xaa_252525
para "<b>Language</b>" stack(width: 1.0, fill: true, max_width: 720, h_align: :center, scroll: true) do
flow(width: 1.0, height: 0.12) do tagline "Launcher Language"
para "<b>Launcher Language</b>", width: 0.249, margin_left: 32, margin_top: 12 @language_menu = list_box items: I18n.available_locales.map { |l| expand_language_code(l.to_s) }, choose: expand_language_code(Store.settings[:language]), width: 1.0, margin_left: 16
stack(width: 0.75) do para "Select the UI language you'd like to use in the W3D Hub Launcher.", margin_left: 16
@language_menu = list_box items: I18n.available_locales.map { |l| expand_language_code(l.to_s) }, choose: expand_language_code(Store.settings[:language]), width: 1.0
para "Select the UI language you'd like to use in the W3D Hub Launcher."
tagline "Launcher Directories", margin_top: 16
caption "Applications Install Directory", margin_left: 16
flow(width: 1.0, margin_left: 16) do
@app_install_dir_input = edit_line Store.settings[:app_install_dir], fill: true
button "Browse...", width: 128, tip: "Browse for applications install directory" do
path = W3DHub.ask_folder
@app_install_dir_input.value = path unless path.empty?
end end
end end
para "<b>Folder Paths</b>", margin_top: 8, padding_top: 8, border_thickness_top: 2, border_color_top: 0xee_ffffff, width: 1.0 caption "Package Cache Directory", margin_left: 16, margin_top: 16
stack(width: 1.0, height: 0.3) do flow(width: 1.0, margin_left: 16) do
flow(width: 1.0, height: 0.5) do @package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
para "<b>App Install Folder</b>", width: 0.249, margin_left: 32, margin_top: 12 button "Browse...", width: 128, tip: "Browse for package cache directory" do
path = W3DHub.ask_folder
stack(width: 0.75) do @package_cache_dir_input.value = path unless path.empty?
@app_install_dir_input = edit_line Store.settings[:app_install_dir], width: 1.0
para "The folder into which new games and apps will be installed by the launcher"
end end
end end
flow(width: 1.0, margin_top: 16) do if W3DHub.unix?
para "<b>Package Cache Folder</b>", width: 0.249, margin_left: 32, margin_top: 12 tagline "Wine - Windows compatibility layer", margin_top: 16
caption "Wine Command", margin_left: 16
stack(width: 0.75) do flow(width: 1.0, margin_left: 16) do
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], width: 1.0 @wine_command_input = edit_line Store.settings[:wine_command], fill: true
para "A folder which will be used to cache downloaded packages used to install games and apps" button "Browse...", width: 128, tip: "Browse for wine executable" do
path = W3DHub.ask_file(filters: %w[wine proton])
@wine_command_input.value = path unless path.empty?
end end
end end
para "Command to use to for Windows compatiblity layer.", margin_left: 16
caption "Wine Prefix", margin_left: 16, margin_top: 16
flow(width: 1.0, margin_left: 16) do
@wine_prefix_input = edit_line Store.settings[:wine_prefix], fill: true
button "Browse...", width: 128, tip: "Browse for wine prefix directory" do
path = W3DHub.ask_folder
@wine_prefix_input.value = path unless path.empty?
end
end
para "Leave empty to use default global prefix.", margin_left: 16
link "Wiki: Getting Started With Wine", tip: "https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine", margin_top: 16, margin_left: 16, border_color_bottom: 0xff_777777 do
W3DHub.url("https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine")
end
# TODO: support winetricks stuff
# tagline "Winetricks", margin_top: 16
# caption "Winetricks Command", margin_left: 16
# flow(width: 1.0, margin_left: 16) do
# @winetricks_command_input = edit_line Store.settings[:winetricks_command], fill: true, enabled: false
# button "Browse...", width: 128, tip: "Browse for winetricks executable", enabled: false do
# path = W3DHub.ask_file(filters: %w[winetricks protontricks])
# @winetricks_command_input.value = path unless path.empty?
# end
# end
# caption "Fixups", margin_left: 16, margin_top: 16
# button "Install d3dcompiler_47", margin_left: 16, enabled: false
# para "Fixes games instantly crashing at startup due to not being able to compile shaders.", margin_left: 16
# button "Install DXVK", margin_left: 16, margin_top: 16, enabled: false
# para "Use Vulkan-based DirectX translation layers.", margin_left: 16
# para "WARNING: Games will stop working if your hardware does not support Vulkan!", margin_left: 16
end
end end
if true # W3DHub.unix? flow(width: 256, height: 64, h_align: :center, margin_top: 16) do
para "<b>Wine</b>", margin_top: 8, padding_top: 8, border_thickness_top: 2, border_color_top: 0xee_ffffff, width: 1.0 button "Save", width: 1.0 do
flow(width: 1.0, height: 0.12) do save_settings!
para "<b>Wine Command</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0
para "Command to use to for Windows compatiblity layer"
end end
flow(fill: true)
end end
flow(width: 1.0, height: 0.13, margin_top: 16) do button("Clear package cache: #{W3DHub.format_size(Dir.glob("#{Store.settings[:package_cache_dir]}/**/**").map { |f| File.file?(f) ? File.size(f) : 0}.sum)}", tip: "Purge #{Store.settings[:package_cache_dir]}", **DANGEROUS_BUTTON) do |btn|
para "<b>Wine Prefix</b>", width: 0.249, margin_left: 32, margin_top: 12 logger.info(LOG_TAG) { "Purging cache (#{Store.settings[:package_cache_dir]})..." }
stack(width: 0.75) do FileUtils.remove_dir(Store.settings[:package_cache_dir], force: true)
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix] btn.value = "Clear package cache: #{W3DHub.format_size(Dir.glob("#{Store.settings[:package_cache_dir]}/**/**").map { |f| File.file?(f) ? File.size(f) : 0}.sum)}"
para "Whether each game gets its own prefix. Uses global/default prefix by default."
end
end
end
button "Save" do
old_language = Store.settings[:language]
Store.settings[:language] = language_code(@language_menu.value)
Store.settings[:app_install_dir] = @app_install_dir_input.value
Store.settings[:package_cache_dir] = @package_cache_dir_input.value
Store.settings[:wine_command] = @wine_command_input.value
Store.settings[:wine_prefix] = @wine_prefix_toggle.value
Store.settings.save_settings
begin
I18n.locale = Store.settings[:language]
rescue I18n::InvalidLocale
I18n.locale = :en
end
if old_language == Store.settings[:language]
page(Pages::Games)
else
# pop back to Boot state which will immediately push a new instance of Interface
pop_state
end
end end
end end
end end
@@ -108,7 +118,37 @@ class W3DHub
when "es" when "es"
"Español" "Español"
else else
raise "Unknown language error" logger.warn("W3DHub::Settings") { "Unknown language code: #{string.inspect}" }
"UNKNOWN"
end
end
def save_settings!
old_language = Store.settings[:language]
Store.settings[:language] = language_code(@language_menu.value)
Store.settings[:app_install_dir] = @app_install_dir_input.value
Store.settings[:package_cache_dir] = @package_cache_dir_input.value
Store.settings[:wine_command] = @wine_command_input.value
Store.settings[:wine_prefix] = @wine_prefix_input.value
Store.settings[:winetricks_command] = @winetricks_command_input.value if @winetricks_command_input
Store.settings.save_settings
begin
I18n.locale = Store.settings[:language]
rescue I18n::InvalidLocale
I18n.locale = :en
end
if old_language == Store.settings[:language]
page(Pages::Games)
else
# pop back to Boot state which will immediately push a new instance of Interface
pop_state
end end
end end
end end

View File

@@ -2,12 +2,13 @@ class W3DHub
class Settings class Settings
def self.defaults def self.defaults
{ {
language: Gosu.user_languages.first.split("_").first, language: Gosu.user_languages.first&.split("_")&.first || "en",
app_install_dir: default_app_install_dir, app_install_dir: default_app_install_dir,
package_cache_dir: default_package_cache_dir, package_cache_dir: default_package_cache_dir,
parallel_downloads: 4, parallel_downloads: 4,
wine_command: "wine", wine_command: "wine",
create_wine_prefixes: true, wine_prefix: "",
winetricks_command: "winetricks",
allow_diagnostic_reports: false, allow_diagnostic_reports: false,
server_list_username: "", server_list_username: "",
server_list_filters: {}, server_list_filters: {},
@@ -66,10 +67,18 @@ class W3DHub
def load_settings def load_settings
@settings = JSON.parse(File.read(SETTINGS_FILE_PATH), symbolize_names: true) @settings = JSON.parse(File.read(SETTINGS_FILE_PATH), symbolize_names: true)
# FIXUPS
# FOR: v0.9.0
@settings.delete(:create_wine_prefixes)
@settings[:wine_prefix] ||= ""
@settings[:winetricks_command] ||= "winetricks"
@settings
end end
def save_settings def save_settings
File.write(SETTINGS_FILE_PATH, @settings.to_json) File.write(SETTINGS_FILE_PATH, JSON.pretty_generate(@settings))
end end
def save_application_cache(json) def save_application_cache(json)

View File

@@ -11,19 +11,22 @@ class W3DHub
@fraction = 0.0 @fraction = 0.0
@w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png") @w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png")
@tasks = { @tasks = {
# connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com? connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com?
# launcher_updater: { started: false, complete: false },
server_list: { started: false, complete: false }, server_list: { started: false, complete: false },
refresh_user_token: { started: false, complete: false }, refresh_user_token: { started: false, complete: false },
service_status: { started: false, complete: false }, service_status: { started: false, complete: false },
applications: { started: false, complete: false }, applications: { started: false, complete: false },
app_icons: { started: false, complete: false } app_icons: { started: false, complete: false },
app_logos_and_backgrounds: { started: false, complete: false }
} }
@offline_mode = false @offline_mode = false
@task_index = 0 @task_index = 0
stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR, background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR,
background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do
stack(width: 1.0, fill: true) do stack(width: 1.0, fill: true) do
end end
@@ -39,7 +42,8 @@ class W3DHub
end end
def draw def draw
Gosu.draw_circle(window.width / 2, window.height / 2, @w3dhub_logo.width * (0.6 + Math.cos(Gosu.milliseconds / 1000.0 * Math::PI).abs * 0.05), 128, 0xaa_353535, 32) Gosu.draw_circle(window.width / 2, window.height / 2,
@w3dhub_logo.width * (0.6 + Math.cos(Gosu.milliseconds / 1000.0 * Math::PI).abs * 0.05), 128, 0xaa_353535, 32)
@w3dhub_logo.draw_rot(window.width / 2, window.height / 2, 32) @w3dhub_logo.draw_rot(window.width / 2, window.height / 2, 32)
super super
@@ -127,7 +131,7 @@ class W3DHub
Store.settings[:account][:data] = account Store.settings[:account][:data] = account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false) Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
else else
Store.settings[:account] = {} Store.settings[:account] = {}
end end
@@ -137,6 +141,39 @@ class W3DHub
@tasks[:refresh_user_token][:complete] = true @tasks[:refresh_user_token][:complete] = true
end end
def connectivity_check
domains = {
"w3dhub-api.w3d.cyberarm.dev": false,
"s3.w3d.cyberarm.dev": false,
"secure.w3dhub.com": false
}
@status_label.value = "Checking uplink..."
domains.each do |key, _value|
Resolv.getaddress(key.to_s)
rescue StandardError => e
logger.error(LOG_TAG) { "Failed to resolve hostname: #{key}" }
logger.error(LOG_TAG) { e }
push_state(
ConfirmDialog,
title: "DNS Resolution Failure",
message: "Failed to resolve: #{key}\n\nTry disabling VPN or proxy if in use.\n\n\nContinue offline?",
cancel_callback: -> { window.close },
accept_callback: lambda {
@offline_mode = true
Store.offline_mode = true
@tasks[:connectivity_check][:complete] = true
}
)
# Prevent task from being marked as completed
return false
end
@tasks[:connectivity_check][:complete] = true
end
def service_status def service_status
Api.on_thread(:service_status) do |service_status| Api.on_thread(:service_status) do |service_status|
@service_status = service_status @service_status = service_status
@@ -150,7 +187,9 @@ class W3DHub
@tasks[:service_status][:complete] = true @tasks[:service_status][:complete] = true
else else
BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = I18n.t(:"boot.w3dhub_service_is_down") }) BackgroundWorker.foreground_job(-> {}, lambda { |_|
@status_label.value = I18n.t(:"boot.w3dhub_service_is_down")
})
@tasks[:service_status][:complete] = true @tasks[:service_status][:complete] = true
@offline_mode = true @offline_mode = true
@@ -159,17 +198,43 @@ class W3DHub
end end
end end
def launcher_updater
@status_label.value = "Checking for Launcher updates..." # I18n.t(:"boot.checking_for_updates")
Api.on_thread(:fetch, "https://api.github.com/repos/cyberarm/w3d_hub_linux_launcher/releases/latest") do |response|
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
available_version = hash[:tag_name].downcase.sub("v", "")
pp Gem::Version.new(available_version) > Gem::Version.new(W3DHub::VERSION)
pp [Gem::Version.new(available_version), Gem::Version.new(W3DHub::VERSION)]
push_state(
LauncherUpdaterDialog,
release_data: hash,
available_version: available_version,
cancel_callback: -> { @tasks[:launcher_updater][:complete] = true },
accept_callback: -> { @tasks[:launcher_updater][:complete] = true }
)
else
# Failed to retrieve release data from github
log "Failed to retrieve release data from Github"
@tasks[:launcher_updater][:complete] = true
end
end
end
def applications def applications
@status_label.value = I18n.t(:"boot.checking_for_updates") @status_label.value = I18n.t(:"boot.checking_for_updates")
Api.on_thread(:applications) do |applications| Api.on_thread(:_applications) do |applications|
if applications if applications
Store.applications = applications Store.applications = applications
Store.settings.save_application_cache(applications.data.to_json) Store.settings.save_application_cache(JSON.pretty_generate(applications.data))
@tasks[:applications][:complete] = true @tasks[:applications][:complete] = true
else else
# FIXME: Failed to retreive! # FIXME: Failed to retreive!
BackgroundWorker.foreground_job(-> {}, ->(_){ @status_label.value = "FAILED TO RETREIVE APPS LIST" }) BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = "FAILED TO RETREIVE APPS LIST" })
@offline_mode = true @offline_mode = true
Store.offline_mode = true Store.offline_mode = true
@@ -180,37 +245,80 @@ class W3DHub
def app_icons def app_icons
return unless Store.applications return unless Store.applications
@status_label.value = "Retrieving application icons, this might take a moment..." # I18n.t(:"boot.checking_for_updates")
packages = [] packages = []
failure = false
Store.applications.games.each do |app| Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" } packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" }
end end
Api.on_thread(:package_details, packages) do |package_details| Api.on_thread(:package_details, packages, :alt_w3dhub) do |package_details|
package_details ||= nil package_details ||= nil
package_details&.each do |package| package_details&.each do |package|
next if package.error? next if package.error?
path = Cache.package_path(package.category, package.subcategory, package.name, package.version) path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
generated_icon_path = "#{GAME_ROOT_PATH}/media/icons/#{package.subcategory}.png" generated_icon_path = "#{CACHE_PATH}/#{package.subcategory}.png"
regenerate = false regenerate = false
broken_or_out_dated_icon = Digest::SHA256.new.hexdigest(File.binread(path)).upcase != package.checksum.upcase if File.exist?(path) if File.exist?(path)
broken_or_out_dated_icon = Digest::SHA256.new.hexdigest(File.binread(path)).upcase != package.checksum.upcase
end
if File.exist?(path) && !broken_or_out_dated_icon if File.exist?(path) && !broken_or_out_dated_icon
regenerate = !File.exist?(generated_icon_path) regenerate = !File.exist?(generated_icon_path)
else else
begin
Cache.fetch_package(package, proc {}) Cache.fetch_package(package, proc {})
regenerate = true regenerate = true
end rescue Errno::EACCES => e
failure = true
if regenerate push_state(MessageDialog, title: "Fatal Error",
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, ->(result) { result.save(result.images.max_by(&:width), generated_icon_path) }) message: "Directory Permission Error (#{e.class}):\n#{e}.\n\nIs the required drive mounted?",
accept_callback: -> { window.close })
end end
end end
@tasks[:app_icons][:complete] = true next unless regenerate
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, lambda { |result|
result.save(result.images.max_by(&:width), generated_icon_path)
})
end
@tasks[:app_icons][:complete] = true unless failure
end
end
def app_logos_and_backgrounds
return unless Store.applications
@status_label.value = "Retrieving application image assets, this might take a moment..." # I18n.t(:"boot.checking_for_updates")
packages = []
Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "logo.png", version: "" }
packages << { category: app.category, subcategory: app.id, name: "background.png", version: "" }
end
Api.on_thread(:package_details, packages, :alt_w3dhub) do |package_details|
package_details ||= nil
package_details&.each do |package|
next if package.error?
package_cache_path = Cache.package_path(package.category, package.subcategory, package.name,
package.version)
missing_or_broken_image = File.exist?(package_cache_path) ? Digest::SHA256.new.hexdigest(File.binread(package_cache_path)).upcase != package.checksum.upcase : true
Cache.fetch_package(package, proc {}) if missing_or_broken_image
end
@tasks[:app_logos_and_backgrounds][:complete] = true
end end
end end
@@ -263,7 +371,7 @@ class W3DHub
"web-links": [], "web-links": [],
"extended-data": [ "extended-data": [
{ name: "colour", value: game[:colour] }, { name: "colour", value: game[:colour] },
{ name: "usesEngineCfg", value: game[:uses_engine_cfg] }, { name: "usesEngineCfg", value: game[:uses_engine_cfg] }
] ]
} }

View File

@@ -0,0 +1,62 @@
class W3DHub
class States
class LauncherUpdaterDialog < Dialog
BUTTON_STYLE = { text_size: 18, padding_top: 3, padding_bottom: 3, padding_left: 3, padding_right: 3, height: 18 }
LIST_ITEM_THEME = Marshal.load(Marshal.dump(THEME))
BUTTON_STYLE.each do |key, value|
LIST_ITEM_THEME[:Button][key] = value
end
def setup
window.show_cursor = true
theme(THEME)
background 0xaa_525252
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 640, v_align: :center, h_align: :center, background: 0xee_222222, border_thickness: 2, border_color: 0xee_222222, padding: 16) do
flow(width: 1.0, height: 36, padding: 8) do
background 0xff_0052c0
title @options[:title] || "Launcher Update Available", fill: true, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, margin_top: 14) do
subtitle "Release Notes - #{@options[:available_version]}"
# case launcher_release_type
# when :git
# when :tebako
# end
pp @options[:release_data]
stack(width: 1.0, fill: true, scroll: true, padding: 8, border_thickness: 1, border_color: 0x44_ffffff) do
# para @options[:release_data][:body], width: 1.0
# FIXME: Finish this bit
@options[:release_data][:body].lines.each do |line|
line.strip
end
end
end
flow(width: 1.0, height: 46, margin_top: 16) do
background 0xff_ffffff
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call
end
flow(fill: true)
button "Update", width: 0.25 do
pop_state
@options[:accept_callback]&.call
end
end
end
end
end
end
end

View File

@@ -24,6 +24,7 @@ class W3DHub
stack(width: 1.0, height: 46, padding: 8) do stack(width: 1.0, height: 46, padding: 8) do
button "Okay", width: 1.0 do button "Okay", width: 1.0 do
pop_state pop_state
@options[:accept_callback]&.call
end end
end end
end end

View File

@@ -4,6 +4,8 @@ class W3DHub
APPLICATIONS_UPDATE_INTERVAL = 10 * 60 * 1000 # ten minutes APPLICATIONS_UPDATE_INTERVAL = 10 * 60 * 1000 # ten minutes
SERVER_LIST_UPDATE_INTERVAL = 5 * 60 * 1000 # five minutes SERVER_LIST_UPDATE_INTERVAL = 5 * 60 * 1000 # five minutes
DEFAULT_BACKGROUND_IMAGE = "#{GAME_ROOT_PATH}/media/banners/background.png".freeze
attr_accessor :interface_task_update_pending attr_accessor :interface_task_update_pending
@@instance = nil @@instance = nil
@@ -21,6 +23,7 @@ class W3DHub
@service_status = @options[:service_status] @service_status = @options[:service_status]
@applications = @options[:applications] @applications = @options[:applications]
@account_expire = Gosu.milliseconds
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes @applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # 5 minutes @server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # 5 minutes
@@ -33,10 +36,12 @@ class W3DHub
theme(W3DHub::THEME) theme(W3DHub::THEME)
@interface_container = stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR, background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do @interface_container = stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR, background_image: DEFAULT_BACKGROUND_IMAGE, background_image_mode: :fill) do
background 0xff_252525 background 0xff_252525
@header_container = flow(width: 1.0, height: 84, padding: 4, border_thickness_bottom: 1, border_color_bottom: W3DHub::BORDER_COLOR) do @header_container = flow(width: 1.0, height: 84, padding: 4, border_thickness_bottom: 1, border_color_bottom: W3DHub::BORDER_COLOR) do
background 0xaa_151515
flow(width: 148, height: 1.0) do flow(width: 148, height: 1.0) do
flow(fill: true) flow(fill: true)
image "#{GAME_ROOT_PATH}/media/icons/app.png", height: 84 image "#{GAME_ROOT_PATH}/media/icons/app.png", height: 84
@@ -54,18 +59,22 @@ class W3DHub
end end
link I18n.t(:"interface.servers").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.servers").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::ServerBrowser) page(W3DHub::Pages::ServerBrowser)
end end
link I18n.t(:"interface.community").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.community").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Community) page(W3DHub::Pages::Community)
end end
link I18n.t(:"interface.downloads").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.downloads").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::DownloadManager) page(W3DHub::Pages::DownloadManager)
end end
link I18n.t(:"interface.settings").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.settings").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Settings) page(W3DHub::Pages::Settings)
end end
end end
@@ -120,6 +129,13 @@ class W3DHub
end end
hide_application_taskbar hide_application_taskbar
every(3_000) do
# NOTE: each method called, internally checks whether it should act.
refresh_account_token
refresh_applications
refresh_server_list
end
end end
def draw def draw
@@ -134,36 +150,6 @@ class W3DHub
@page&.update @page&.update
update_interface_task_status(@interface_task_update_pending) if @interface_task_update_pending update_interface_task_status(@interface_task_update_pending) if @interface_task_update_pending
if Gosu.milliseconds >= @applications_expire
@applications_expire = Gosu.milliseconds + 30_000
Api.on_thread(:applications) do |applications|
if applications
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
Store.applications = applications
# TODO: Signal Games and ServerBrowser that applications have been updated
end
end
end
if Gosu.milliseconds >= @server_list_expire
@server_list_expire = Gosu.milliseconds + 30_000
Api.on_thread(:server_list, 2) do |list|
if list
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # five minutes
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance.refresh_server_list(list)
BackgroundWorker.foreground_job(-> {}, ->(_) { States::Interface.instance&.update_server_browser(nil, :refresh_all) })
end
end
end
end end
def button_down(id) def button_down(id)
@@ -262,6 +248,63 @@ class W3DHub
end end
end end
end end
def refresh_account_token
return if Gosu.milliseconds < @account_expire
return unless account = Store.account
@account_expire = Gosu.milliseconds + 30_000
if (account.access_token_expiry - Time.now) / 60 <= 60 * 3 # Refresh if token expires within 3 hours
logger.info(LOG_TAG) { "Refreshing user login..." }
Api.on_thread(:refresh_user_login, account.refresh_token) do |refreshed_account|
if refreshed_account
Store.account = refreshed_account
Store.settings[:account][:data] = refreshed_account
else
Store.settings[:account] = {}
end
Store.settings.save_settings
end
end
end
def refresh_applications
return if Gosu.milliseconds < @applications_expire
@applications_expire = Gosu.milliseconds + 30_000
Api.on_thread(:_applications) do |applications|
if applications
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
Store.applications = applications
# TODO: Signal Games and ServerBrowser that applications have been updated
end
end
end
def refresh_server_list
return if Gosu.milliseconds < @server_list_expire
@server_list_expire = Gosu.milliseconds + 30_000
Api.on_thread(:server_list, 2) do |list|
if list
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # five minutes
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance.refresh_server_list(list)
BackgroundWorker.foreground_job(-> {}, ->(_) { States::Interface.instance&.update_server_browser(nil, :refresh_all) })
end
end
end
end end
end end
end end

View File

@@ -10,7 +10,7 @@ class W3DHub
flow(width: 1.0, height: 1.0, background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do flow(width: 1.0, height: 1.0, background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do
flow(fill: true) flow(fill: true)
@card_container = stack(width: 1.0, max_width: MAX_PAGE_WIDTH, height: 1.0, max_height: 720, margin: 128, padding: 16) do @card_container = stack(width: 1.0, max_width: MAX_PAGE_WIDTH, height: 1.0, max_height: 720, margin: 64, v_align: :center, h_align: :center, padding: 16) do
background 0xaa_353535 background 0xaa_353535
end end
@@ -24,9 +24,12 @@ class W3DHub
def card_welcome def card_welcome
stack(width: 1.0, fill: true) do stack(width: 1.0, fill: true) do
banner "Welcome", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000 banner "Welcome", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_0074e0
title "Welcome to the #{I18n.t(:app_name_simple)}" title "Welcome to the #{I18n.t(:app_name_simple)}"
caption "The #{I18n.t(:app_name_simple)} is a one-stop shop for your W3D gaming needs, providing game downloads, automatic updating, an integrated server browser, and centralized management of in-game options.", width: 1.0, margin_left: 32 caption "The #{I18n.t(:app_name_simple)} is a one-stop shop for your W3D gaming needs, providing game downloads, "\
"automatic updating, an integrated server browser, and centralized management of in-game options.", width: 1.0, margin_left: 32
image "#{GAME_ROOT_PATH}/media/icons/app.png", height: 256
end end
flow(width: 1.0, height: 46) do flow(width: 1.0, height: 46) do
@@ -44,14 +47,25 @@ class W3DHub
def card_getting_started def card_getting_started
stack(width: 1.0, fill: true) do stack(width: 1.0, fill: true) do
banner "Getting Started", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000 banner "Getting Started", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_0074e0
title "Import C&C Renegade" title "Import Command & Conquer: Renegade"
caption "You can import your installed copy of Renegade if it wasn't automatically imported from the Games tab. If you need to procure a copy of Renegade, EA's Origin Store has the Command & Conquer The Ultimate Collection available. We cannot provide Renegade for installation.", width: 1.0, margin_left: 32 caption "You can import your installed copy of Renegade if it wasn't automatically imported from the Games tab.\n"\
"If you need to procure a copy of Renegade, Both Steam and the EA App have the Command & Conquer The Ultimate Collection available for purchase. "\
"We cannot provide Renegade for installation.", width: 1.0, margin_left: 32
stack(width: 1.0, height: 2, background: 0x88_ffffff) stack(width: 1.0, height: 2, background: 0xff_0074e0, margin_top: 16, margin_bottom: 16)
title "Install one of our standalone games" title "Install one of our standalone games"
caption "Browse our selection of games from the left panel of the Games tab.\n• Interim Apex - Renegade but with hundreds of vehicles and characters.\n• Red Alert: A Path Beyond - DESCRIPTION\n• Tiberian Sun: Reborn - DESCRIPTION\n\nAnd more... Check out the left panel on the Games tab.", width: 1.0, margin_left: 32 stack(width: 1.0, fill: true, margin_left: 32) do
tagline "Interim Apex"
caption "An expanded boots on the ground conflict set after the advent of Tiberian Dawn and the inter-war period between Tiberian Dawn and Tiberian Sun.", margin_left: 16
tagline "Red Alert 2: Apocalypse Rising"
caption "A multiplayer first-and-third-person shooter set in the vibrant universe of Command & Conquer: Red Alert 2. ", margin_left: 16
tagline "Tiberian Sun: Reborn"
caption "A standalone first-person shooter set in the Tiberian Sun universe.", margin_left: 16
para ""
caption "And more games! See them all on the Games tab."
end
end end
flow(width: 1.0, height: 46) do flow(width: 1.0, height: 46) do
@@ -66,25 +80,22 @@ class W3DHub
end end
button "Next >" do button "Next >" do
@card_container.clear { card_communitiy } @card_container.clear { W3DHub.unix? ? card_wine : card_community }
end end
end end
end end
def card_communitiy def card_wine
stack(width: 1.0, fill: true) do stack(width: 1.0, fill: true) do
banner "W3D Hub Community", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000 banner "Wine - Windows compatibility layer", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_0074e0
title "Forums" stack(width: 1.0, fill: true, margin_left: 32) do
caption "Join our forum community", margin_left: 32 title "Got Wine?"
caption "The launcher requires a windows compatibility tool like wine in order to run the games.", margin_left: 32
title "Facebook" caption "Install wine and winetricks through your distribution's package manager or use a wine manager like Bottles.", margin_left: 32
caption "Like us on Facebook", margin_left: 32 link "See most up to date instructions on the wiki.", tip: "https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine", margin_top: 16, margin_left: 32, border_color_bottom: 0xff_777777 do
W3DHub.url("https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine")
title "Discord" end
caption "Join our Discord community server", margin_left: 32 end
title "YouTube"
caption "Subscribe to our YouTube channel", margin_left: 32
end end
flow(width: 1.0, height: 46) do flow(width: 1.0, height: 46) do
@@ -92,6 +103,52 @@ class W3DHub
button "< Back" do button "< Back" do
@card_container.clear { card_getting_started } @card_container.clear { card_getting_started }
end end
link "Skip", border_color_bottom: 0xff_777777, margin_left: 16 do
pop_state
end
end
button "Next >" do
@card_container.clear { card_community }
end
end
end
def card_community
stack(width: 1.0, fill: true) do
banner "W3D Hub Community", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_0074e0
title "W3D Hub"
link "Visit website", tip: "https://w3dhub.com", margin_left: 32, border_color_bottom: 0xff_777777 do
W3DHub.url("https://w3dhub.com")
end
title "Forum"
link "Join our forum community", tip: "https://w3dhub.com/forum", margin_left: 32, border_color_bottom: 0xff_777777 do
W3DHub.url("https://w3dhub.com/forum")
end
title "Facebook"
link "Like us on Facebook", tip: "https://www.facebook.com/w3dhub/", margin_left: 32, border_color_bottom: 0xff_777777 do
W3DHub.url("https://www.facebook.com/w3dhub/")
end
title "Discord"
link "Join our Discord community server", tip: "https://discord.gg/jMmmRa2", margin_left: 32, border_color_bottom: 0xff_777777 do
W3DHub.url("https://discord.gg/jMmmRa2")
end
title "YouTube"
link "Subscribe to our YouTube channel", tip: "https://www.youtube.com/@w3dhub-official", margin_left: 32, border_color_bottom: 0xff_777777 do
W3DHub.url("https://www.youtube.com/@w3dhub-official")
end
end
flow(width: 1.0, height: 46) do
flow(fill: true, height: 1.0) do
button "< Back" do
@card_container.clear { W3DHub.unix? ? card_wine : card_getting_started }
end
end end
button "Done" do button "Done" do

View File

@@ -1,4 +1,4 @@
class W3DHub class W3DHub
DIR_NAME = "W3DHubAlt" DIR_NAME = "W3DHubAlt".freeze
VERSION = "0.6.0" VERSION = "0.9.2".freeze
end end

75
lib/websocket_client.rb Normal file
View File

@@ -0,0 +1,75 @@
class W3DHub
class WebSocketClient
def initialize
@errored = nil
@connection = nil
@events = {
open: nil,
message: nil,
close: nil,
error: nil
}
end
def connect(endpoint, headers: nil, &block)
yield(self)
Sync do |task|
ssl_context = W3DHub.ca_bundle_path ? OpenSSL::SSL::SSLContext.new : nil
ssl_context&.alpn_protocols = Async::HTTP::Protocol::HTTP11.names
ssl_context&.set_params(
ca_file: W3DHub.ca_bundle_path,
verify_mode: OpenSSL::SSL::VERIFY_PEER
)
endpoint = Async::HTTP::Endpoint.parse(endpoint, alpn_protocols: Async::HTTP::Protocol::HTTP11.names, ssl_context: ssl_context)
Async::WebSocket::Client.connect(endpoint, headers: headers) do |connection|
@connection = connection
@events[:open]&.call
while message = connection.read
@events[:message].call(message)
end
# FIXME: Don't rescue for all ta errors?
rescue => error
@errored = true
@events[:error]&.call(error)
ensure
@events[:close]&.call unless @errored
@connection = nil
@errored = false
end
end
self
end
def on(event, &block)
raise "Event must be a symbol" unless event.is_a?(Symbol)
raise "Unknown event: #{event.inspect}" unless @events.keys.include?(event)
raise "No block given for #{event.inspect}" unless block_given?
@events[event] = block
end
def send(data, type: :text)
@connection&.write(data)
@connection&.flush
end
def close
@connection&.close
end
def open?
!closed?
end
def closed?
@connection&.closed?
end
end
end

View File

@@ -2,7 +2,7 @@ class W3DHub
class Window < CyberarmEngine::Window class Window < CyberarmEngine::Window
def setup def setup
self.show_stats_plotter = false self.show_stats_plotter = false
self.caption = I18n.t(:app_name) self.caption = "#{I18n.t(:app_name)} v#{VERSION}"
Store[:server_list] = [] Store[:server_list] = []
Store[:settings] = Settings.new Store[:settings] = Settings.new
@@ -17,8 +17,8 @@ class W3DHub
end end
# push_state(W3DHub::States::DemoInputDelay) # push_state(W3DHub::States::DemoInputDelay)
# push_state(W3DHub::States::Welcome)
push_state(W3DHub::States::Boot) push_state(W3DHub::States::Boot)
push_state(W3DHub::States::Welcome) unless File.exist?(SETTINGS_FILE_PATH)
# push_state(W3DHub::States::DirectConnectDialog) # push_state(W3DHub::States::DirectConnectDialog)
# push_state(W3DHub::Asterisk::States::IRCProfileForm) # push_state(W3DHub::Asterisk::States::IRCProfileForm)
end end

287
lib/ww_mix.rb Normal file
View File

@@ -0,0 +1,287 @@
require "digest"
require "stringio"
class W3DHub
# Reimplementating MIX1 reader/writer with years more
# experience working with these formats and having then
# 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.

Before

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -14,18 +14,28 @@ require "logger"
require "time" require "time"
require "base64" require "base64"
require "zip" require "zip"
require "excon" require "async"
require "async/http/endpoint"
require "async/websocket/client"
require "async/http/internet/instance"
class W3DHub class W3DHub
W3DHUB_DEBUG = ARGV.join.include?("--debug") W3DHUB_DEBUG = ARGV.join.include?("--debug")
W3DHUB_DEVELOPER = ARGV.join.include?("--developer") W3DHUB_DEVELOPER = ARGV.join.include?("--developer")
GAME_ROOT_PATH = File.expand_path(".", __dir__) # Use the real working directory as the root for runtime data/logs
GAME_ROOT_PATH = Dir.pwd
CACHE_PATH = "#{GAME_ROOT_PATH}/data/cache" CACHE_PATH = "#{GAME_ROOT_PATH}/data/cache"
LOGS_PATH = "#{GAME_ROOT_PATH}/data/logs"
SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.json" SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.json"
APPLICATIONS_CACHE_FILE_PATH = "#{GAME_ROOT_PATH}/data/applications_cache.json" APPLICATIONS_CACHE_FILE_PATH = "#{GAME_ROOT_PATH}/data/applications_cache.json"
LOGGER = Logger.new("#{GAME_ROOT_PATH}/data/logs/w3d_hub_linux_launcher.log", "daily") # Ensure data/cache and data/logs exist
FileUtils.mkdir_p(CACHE_PATH) unless Dir.exist?(CACHE_PATH)
FileUtils.mkdir_p(LOGS_PATH) unless Dir.exist?(LOGS_PATH)
LOGGER = W3DHUB_DEBUG ? Logger.new(STDOUT) : Logger.new("#{LOGS_PATH}/w3d_hub_linux_launcher.log", "daily")
LOGGER.level = Logger::Severity::DEBUG # W3DHUB_DEBUG ? Logger::Severity::DEBUG : Logger::Severity::WARN LOGGER.level = Logger::Severity::DEBUG # W3DHUB_DEBUG ? Logger::Severity::DEBUG : Logger::Severity::WARN
LOG_TAG = "W3DHubLinuxLauncher" LOG_TAG = "W3DHubLinuxLauncher"
@@ -77,11 +87,10 @@ class W3DHub
BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff") BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff")
end end
require "i18n"
require "websocket-client-simple"
require "English" require "English"
require "sdl2" require "sdl2"
require_relative "lib/i18n"
I18n.load_path << Dir["#{W3DHub::GAME_ROOT_PATH}/locales/*.yml"] I18n.load_path << Dir["#{W3DHub::GAME_ROOT_PATH}/locales/*.yml"]
I18n.default_locale = :en I18n.default_locale = :en
@@ -95,11 +104,12 @@ require_relative "lib/store"
require_relative "lib/window" require_relative "lib/window"
require_relative "lib/cache" require_relative "lib/cache"
require_relative "lib/settings" require_relative "lib/settings"
require_relative "lib/mixer" require_relative "lib/ww_mix"
require_relative "lib/ico" require_relative "lib/ico"
require_relative "lib/multicast_server" require_relative "lib/broadcast_server"
require_relative "lib/hardware_survey" require_relative "lib/hardware_survey"
require_relative "lib/game_settings" require_relative "lib/game_settings"
require_relative "lib/websocket_client"
require_relative "lib/background_worker" require_relative "lib/background_worker"
require_relative "lib/application_manager" require_relative "lib/application_manager"
require_relative "lib/application_manager/manifest" require_relative "lib/application_manager/manifest"
@@ -121,6 +131,7 @@ require_relative "lib/states/dialogs/confirm_dialog"
require_relative "lib/states/dialogs/direct_connect_dialog" require_relative "lib/states/dialogs/direct_connect_dialog"
require_relative "lib/states/dialogs/game_settings_dialog" require_relative "lib/states/dialogs/game_settings_dialog"
require_relative "lib/states/dialogs/import_game_dialog" require_relative "lib/states/dialogs/import_game_dialog"
require_relative "lib/states/dialogs/launcher_updater_dialog"
require_relative "lib/api" require_relative "lib/api"
require_relative "lib/api/service_status" require_relative "lib/api/service_status"