48 Commits

Author SHA1 Message Date
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
40 changed files with 858 additions and 264 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

13
Gemfile
View File

@@ -6,7 +6,6 @@ gem "cyberarm_engine"
gem "sdl2-bindings" gem "sdl2-bindings"
gem "libui", platforms: [:windows] gem "libui", platforms: [:windows]
gem "digest-crc" gem "digest-crc"
gem "i18n"
gem "ircparser" gem "ircparser"
gem "rexml" gem "rexml"
gem "rubyzip" gem "rubyzip"
@@ -19,9 +18,9 @@ gem "win32-security", platforms: [:windows]
# use `bundle _x.y.z_ COMMAND` to use this one... # use `bundle _x.y.z_ COMMAND` to use this one...
# NOTE: Releasy needs to be installed as a system gem i.e. `rake install` # NOTE: Releasy needs to be installed as a system gem i.e. `rake install`
# NOTE: contents of the `gemhome` folder in the packaged folder need to be moved into the lib/ruby/gems\<RUBY_VERSION> folder # NOTE: contents of the `gemhome` folder in the packaged folder need to be moved into the lib/ruby/gems\<RUBY_VERSION> folder
# group :windows_packaging do group :windows_packaging do
# gem "bundler", "~>2.4.3" gem "bundler", "~>2.4.3"
# gem "rake" gem "rake"
# gem "ocran" gem "ocran"
# gem "releasy"#, path: "../releasy" gem "releasy"#, path: "../releasy"
# end end

View File

@@ -1,27 +1,38 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
base64 (0.2.0) base64 (0.3.0)
concurrent-ruby (1.3.4) concurrent-ruby (1.3.5)
cyberarm_engine (0.24.4) cri (2.15.12)
cyberarm_engine (0.24.5)
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) event_emitter (0.2.6)
excon (1.2.3) excon (1.3.2)
ffi (1.17.1-x64-mingw-ucrt) logger
ffi (1.17.1-x86_64-linux-gnu) ffi (1.17.0)
ffi-win32-extensions (1.0.4) ffi-win32-extensions (1.1.0)
ffi ffi (>= 1.15.5, <= 1.17.0)
fiddle (1.1.8)
gosu (1.4.6) gosu (1.4.6)
i18n (1.14.6) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
ircparser (1.0.0) ircparser (1.0.0)
libui (0.1.2-x64-mingw) libui (0.2.0-x64-mingw-ucrt)
fiddle
logger (1.7.0)
mutex_m (0.3.0) mutex_m (0.3.0)
rake (13.2.1) ocran (1.3.17)
rexml (3.4.0) fiddle (~> 1.0)
rubyzip (2.4.1) rake (13.3.1)
releasy (0.2.4)
bundler (>= 1.2.1)
cri (~> 2.15.0)
ocran (~> 1.3.0)
rake (>= 0.9.2.2)
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) websocket (1.2.11)
@@ -38,16 +49,19 @@ GEM
PLATFORMS PLATFORMS
x64-mingw-ucrt x64-mingw-ucrt
x86_64-linux
DEPENDENCIES DEPENDENCIES
base64 base64
bundler (~> 2.4.3)
cyberarm_engine cyberarm_engine
digest-crc digest-crc
excon excon
i18n i18n
ircparser ircparser
libui libui
ocran
rake
releasy
rexml rexml
rubyzip rubyzip
sdl2-bindings sdl2-bindings
@@ -56,4 +70,4 @@ DEPENDENCIES
win32-security win32-security
BUNDLED WITH BUNDLED WITH
2.4.3 2.4.22

View File

@@ -3,7 +3,7 @@ It runs natively on Linux! No mucking about trying to get .NET 4.6.1 or somethin
Only requires OpenGL, Ruby, and a few gems. Only requires OpenGL, Ruby, and a few gems.
## Installing ## Installing
* Install Ruby 3.0+, from your package manager. * 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`

View File

@@ -1,5 +1,11 @@
class W3DHub class W3DHub
class Api class Api
# Set Excon default CA file if found
if (ca_file = W3DHub.ca_bundle_path)
Excon.defaults[:ssl_ca_file] = ca_file
end
LOG_TAG = "W3DHub::Api".freeze LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 30 # seconds API_TIMEOUT = 30 # seconds
@@ -41,12 +47,26 @@ 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 #
W3DHUB_API_CONNECTION = Excon.new(W3DHUB_API_ENDPOINT, persistent: true)
ENDPOINT = "https://w3dhub-api.w3d.cyberarm.dev" # "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 #
API_CONNECTION = Excon.new(ENDPOINT, persistent: true) ALT_W3DHUB_API_API_CONNECTION = Excon.new(ALT_W3DHUB_API_ENDPOINT, persistent: true)
def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil, api = :api) def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." } case backend
when :w3dhub
connection = W3DHUB_API_CONNECTION
endpoint = W3DHUB_API_ENDPOINT
when :alt_w3dhub
connection = ALT_W3DHUB_API_API_CONNECTION
endpoint = ALT_W3DHUB_API_ENDPOINT
when :gsh
connection = GSH_CONNECTION
endpoint = SERVER_LIST_ENDPOINT
end
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{endpoint}#{url}\"..." }
# Inject Authorization header if account data is populated # Inject Authorization header if account data is populated
if Store.account if Store.account
@@ -55,9 +75,6 @@ class W3DHub
headers["Authorization"] = "Bearer #{Store.account.access_token}" headers["Authorization"] = "Bearer #{Store.account.access_token}"
end end
connection = api == :api ? API_CONNECTION : GSH_CONNECTION
endpoint = api == :api ? ENDPOINT : SERVER_LIST_ENDPOINT
begin begin
connection.send( connection.send(
method, method,
@@ -74,7 +91,7 @@ class W3DHub
retry_interval: 1, retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
) )
rescue Excon::Errors::Timeout => e rescue Excon::Error::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) DummyResponse.new(e)
@@ -86,8 +103,48 @@ class W3DHub
end end
end end
def self.post(url, headers = DEFAULT_HEADERS, body = nil, api = :api) def self.post(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
excon(:post, url, headers, body, api) excon(:post, url, headers, body, backend)
end
def self.get(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
excon(:get, url, headers, body, backend)
end
# Api.get but handles any URL instead of known hosts
def self.fetch(url, headers = DEFAULT_HEADERS, body = nil, backend = nil)
uri = URI(url)
# Use Api.get for `W3DHUB_API_ENDPOINT` URL's to exploit keep alive and connection reuse (faster responses)
return excon(:get, url, headers, body, backend) if "#{uri.scheme}://#{uri.host}" == W3DHUB_API_ENDPOINT
logger.debug(LOG_TAG) { "Fetching GET \"#{url}\"..." }
begin
Excon.get(
url,
headers: headers,
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::Error::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e)
rescue Excon::Error => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
DummyResponse.new(e)
end
end end
# Method: POST # Method: POST
@@ -105,24 +162,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 = "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 +182,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 = "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 +201,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 = "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 +236,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 +248,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 = "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 +339,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 +366,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)
@@ -266,10 +385,6 @@ class W3DHub
# 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) 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 +402,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 +424,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,6 +1,8 @@
class W3DHub class W3DHub
class Api class Api
class ServerListServer class ServerListServer
NO_OR_BAD_PING = 1_000_000
attr_reader :id, :game, :address, :port, :region, :channel, :ping, :status attr_reader :id, :game, :address, :port, :region, :channel, :ping, :status
def initialize(hash) def initialize(hash)
@@ -12,7 +14,7 @@ class W3DHub
@port = @data[:port] @port = @data[:port]
@region = @data[:region] @region = @data[:region]
@channel = @data[:channel] || "release" @channel = @data[:channel] || "release"
@ping = -1 @ping = NO_OR_BAD_PING
@status = Status.new(@data[:status]) @status = Status.new(@data[:status])
@@ -55,7 +57,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

@@ -48,7 +48,7 @@ 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

View File

@@ -235,11 +235,11 @@ 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? if 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 end

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
@@ -208,10 +240,17 @@ 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
else
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{@target_version}")
return []
end end
@manifests @manifests
@@ -252,7 +291,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 +327,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 +353,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 +397,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 +411,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 +557,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 +590,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 +624,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 +657,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,15 +735,15 @@ 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::Mixer::Reader.new(file_path: "#{temp_file_path}.patch", ignore_crc_mismatches: false)
patch_info = JSON.parse(patch_mix.package.files.find { |f| f.name.casecmp?(".w3dhub.patch") || f.name.casecmp?(".bhppatch") }.data, symbolize_names: true) patch_info = JSON.parse(patch_mix.package.files.find { |f| f.name.casecmp?(".w3dhub.patch") || f.name.casecmp?(".bhppatch") }.data, symbolize_names: true)
logger.info(LOG_TAG) { " Loading #{path}/#{safe_file_name}..." } logger.info(LOG_TAG) { " Loading #{file_path}..." }
target_mix = W3DHub::Mixer::Reader.new(file_path: "#{path}/#{safe_file_name}", ignore_crc_mismatches: false) target_mix = W3DHub::Mixer::Reader.new(file_path: "#{file_path}", ignore_crc_mismatches: false)
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|
@@ -728,8 +765,8 @@ class W3DHub
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) W3DHub::Mixer::Writer.new(file_path: "#{file_path}", package: target_mix.package, memory_buffer: true, encrypted: target_mix.encrypted?)
FileUtils.remove_dir(temp_path) FileUtils.remove_dir(temp_path)
@@ -740,17 +777,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

@@ -53,4 +53,4 @@ class W3DHub
end end
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

@@ -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
@@ -69,7 +69,7 @@ class W3DHub
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}" body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
response = Api.post("#{Api::ENDPOINT}/apis/launcher/1/get-package", headers, body) response = Api.post("/apis/launcher/1/get-package", headers, body)
total_bytes = package.size total_bytes = package.size
remaining_bytes = total_bytes - start_from_bytes remaining_bytes = total_bytes - start_from_bytes
@@ -89,7 +89,11 @@ class W3DHub
# 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" 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 = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT } 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 headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account && !package.download_url
@@ -129,10 +133,24 @@ class W3DHub
return true return true
else else
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" } 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}" } logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
false return false
end end
rescue Excon::Error::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}" }
return false
rescue Excon::Error => 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}" }
return false
ensure ensure
file&.close file&.close
end end

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?(";")
@@ -72,15 +81,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: "?")
@@ -193,7 +206,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

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

@@ -6,6 +6,8 @@ class W3DHub
# https://github.com/TheUnstoppable/MixLibrary used for reference # https://github.com/TheUnstoppable/MixLibrary used for reference
class Mixer class Mixer
DEFAULT_BUFFER_SIZE = 32_000_000 DEFAULT_BUFFER_SIZE = 32_000_000
MIX1_HEADER = 0x3158494D
MIX2_HEADER = 0x3258494D
class MixParserException < RuntimeError; end class MixParserException < RuntimeError; end
class MixFormatException < RuntimeError; end class MixFormatException < RuntimeError; end
@@ -203,8 +205,12 @@ class W3DHub
@buffer.pos = 0 @buffer.pos = 0
@encrypted = false
# Valid header # Valid header
if read_i32 == 0x3158494D if (mime = read_i32) && (mime == MIX1_HEADER || mime == MIX2_HEADER)
@encrypted = mime == MIX2_HEADER
file_data_offset = read_i32 file_data_offset = read_i32
file_names_offset = read_i32 file_names_offset = read_i32
@@ -237,7 +243,7 @@ class W3DHub
@buffer.pos = pos @buffer.pos = pos
end end
else else
raise MixParserException, "Invalid MIX file" raise MixParserException, "Invalid MIX file: Expected \"#{MIX1_HEADER}\" or \"#{MIX2_HEADER}\", got \"0x#{mime.to_s(16).upcase}\"\n(#{file_path})"
end end
ensure ensure
@@ -264,18 +270,24 @@ class W3DHub
buffer.strip buffer.strip
end end
def encrypted?
@encrypted
end
end end
class Writer class Writer
attr_reader :package attr_reader :package
def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE) def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE, encrypted: false)
@package = package @package = package
@buffer = MemoryBuffer.new(file_path: file_path, mode: :write, buffer_size: buffer_size) @buffer = MemoryBuffer.new(file_path: file_path, mode: :write, buffer_size: buffer_size)
@buffer.pos = 0 @buffer.pos = 0
@buffer.write("MIX1") @encrypted = encrypted
@buffer.write(encrypted? ? "MIX2" : "MIX1")
files = @package.files.sort { |a, b| a.file_crc <=> b.file_crc } files = @package.files.sort { |a, b| a.file_crc <=> b.file_crc }
@@ -322,6 +334,10 @@ class W3DHub
def write_byte(byte) def write_byte(byte)
@buffer.write([byte].pack("c")) @buffer.write([byte].pack("c"))
end end
def encrypted?
@encrypted
end
end end
# Eager loads patch file and streams target file metadata (doen't load target file data or generate CRCs) # Eager loads patch file and streams target file metadata (doen't load target file data or generate CRCs)

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
image image_path, height: 1.0 # Ensure the image file exists before trying to load it
if File.exist?(image_path)
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,28 +118,25 @@ 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 States::Interface.instance&.instance_variable_get(:"@interface_container")&.style&.default[:background_image] = get_image(background_image_path)
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
@@ -331,11 +328,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
@@ -383,7 +375,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 +417,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 +430,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 +462,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

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

@@ -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,
@@ -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,7 +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 end
def find_element_by_tag(container, tag, list = []) def find_element_by_tag(container, tag, list = [])
@@ -292,7 +292,7 @@ class W3DHub
@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
@@ -410,11 +410,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 +422,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 +540,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,64 @@ 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 stack(width: 1.0, height: 112) do
para "<b>Launcher Language</b>", width: 0.249, margin_left: 32, margin_top: 12 tagline "Launcher Language"
stack(width: 0.75) do @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
@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.", margin_left: 16
para "Select the UI language you'd like to use in the W3D Hub Launcher."
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
stack(width: 1.0, height: 0.3) do
flow(width: 1.0, height: 0.5) do
para "<b>App Install Folder</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do stack(width: 1.0, height: 200, margin_top: 16) do
@app_install_dir_input = edit_line Store.settings[:app_install_dir], width: 1.0 tagline "Launcher Directories"
para "The folder into which new games and apps will be installed by the launcher" caption "Applications Install Directory", margin_left: 16
flow(width: 1.0, fill: true, 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
caption "Package Cache Directory", margin_left: 16, margin_top: 16
flow(width: 1.0, fill: true, margin_left: 16) do
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
button "Browse...", width: 128, tip: "Browse for package cache directory" do
path = W3DHub.ask_folder
@package_cache_dir_input.value = path unless path.empty?
end
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 stack(width: 1.0, height: 224, margin_top: 16) do
tagline "Wine - Windows compatibility layer"
caption "Wine Command", margin_left: 16
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0, margin_left: 16
para "Command to use to for Windows compatiblity layer.", margin_left: 16
stack(width: 0.75) do caption "Wine Prefix", margin_left: 16, margin_top: 16
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], width: 1.0 flow(width: 1.0, height: 48, margin_left: 16) do
para "A folder which will be used to cache downloaded packages used to install games and apps" @wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix], enabled: false
para "Whether each game gets its own prefix. Uses global/default prefix by default."
end
end end
end 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 end
flow(width: 1.0, height: 0.13, margin_top: 16) do flow(fill: true)
para "<b>Wine Prefix</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix]
para "Whether each game gets its own prefix. Uses global/default prefix by default."
end
end
end end
button("Clear package cache: #{W3DHub.format_size(Dir.glob("#{Store.settings[:package_cache_dir]}/**/**").map { |f| File.file?(f) ? File.size(f) : 0}.sum)}", **DANGEROUS_BUTTON) do
button "Save" do # TODO.
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 +90,35 @@ 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_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

View File

@@ -2,7 +2,7 @@ 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,

View File

@@ -11,12 +11,14 @@ 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
@@ -127,7 +129,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 +139,41 @@ 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|
begin
Resolv.getaddress(key.to_s)
rescue => e
logger.error(LOG_TAG) {"Failed to resolve hostname: #{key.to_s}"}
logger.error(LOG_TAG) {e}
push_state(
ConfirmDialog,
title: "DNS Resolution Failure",
message: "Failed to resolve: #{key.to_s}\n\nTry disabling VPN or proxy if in use.\n\n\nContinue offline?",
cancel_callback: ->() { window.close },
accept_callback: ->() {
@offline_mode = true
Store.offline_mode = true
@tasks[:connectivity_check][:complete] = true
}
)
# Prevent task from being marked as completed
return false
end
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
@@ -159,10 +196,36 @@ 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/Inq8/CAmod/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(applications.data.to_json)
@@ -180,19 +243,21 @@ 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 = []
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
@@ -214,6 +279,34 @@ class W3DHub
end end
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
def server_list def server_list
@status_label.value = I18n.t(:"server_browser.fetching_server_list") @status_label.value = I18n.t(:"server_browser.fetching_server_list")

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
@@ -33,10 +35,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 +58,26 @@ 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
@interface_container.style.default[: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
@interface_container.style.default[: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
@interface_container.style.default[: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
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Settings) page(W3DHub::Pages::Settings)
end end
end end
@@ -138,7 +150,7 @@ class W3DHub
if Gosu.milliseconds >= @applications_expire if Gosu.milliseconds >= @applications_expire
@applications_expire = Gosu.milliseconds + 30_000 @applications_expire = Gosu.milliseconds + 30_000
Api.on_thread(:applications) do |applications| Api.on_thread(:_applications) do |applications|
if applications if applications
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes @applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes

View File

@@ -1,4 +1,4 @@
class W3DHub class W3DHub
DIR_NAME = "W3DHubAlt" DIR_NAME = "W3DHubAlt".freeze
VERSION = "0.6.0" VERSION = "0.8.1".freeze
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

View File

@@ -20,12 +20,19 @@ 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 = 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 +84,11 @@ 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 "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
@@ -121,6 +128,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"