75 Commits

Author SHA1 Message Date
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
46 changed files with 1632 additions and 924 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

View File

@@ -1,16 +1,15 @@
source "https://rubygems.org"
gem "base64"
gem "excon"
gem "async-http"
gem "async-websocket"
gem "cyberarm_engine"
gem "sdl2-bindings"
gem "libui", platforms: [:windows]
gem "digest-crc"
gem "i18n"
gem "ircparser"
gem "rexml"
gem "rubyzip"
gem "websocket-client-simple"
gem "win32-process", platforms: [:windows]
gem "win32-security", platforms: [:windows]

View File

@@ -1,35 +1,77 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.2.0)
concurrent-ruby (1.3.4)
cyberarm_engine (0.24.4)
async (2.35.2)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-http (0.94.0)
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.1)
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.2)
fiber-annotation
fiber-local (~> 1.1)
json
cyberarm_engine (0.25.0)
gosu (~> 1.1)
digest-crc (0.6.5)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
event_emitter (0.2.6)
excon (1.2.3)
ffi (1.17.1-x64-mingw-ucrt)
ffi (1.17.1-x86_64-linux-gnu)
ffi-win32-extensions (1.0.4)
ffi
ffi (1.17.0)
ffi-win32-extensions (1.1.0)
ffi (>= 1.15.5, <= 1.17.0)
fiber-annotation (0.2.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.1)
fiddle (1.1.8)
gosu (1.4.6)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
io-endpoint (0.16.0)
io-event (1.14.2)
io-stream (0.11.1)
ircparser (1.0.0)
libui (0.1.2-x64-mingw)
mutex_m (0.3.0)
rake (13.2.1)
rexml (3.4.0)
rubyzip (2.4.1)
json (2.18.0)
libui (0.2.0-x64-mingw-ucrt)
fiddle
metrics (0.15.0)
protocol-hpack (1.5.1)
protocol-http (0.58.0)
protocol-http1 (0.36.0)
protocol-http (~> 0.58)
protocol-http2 (0.24.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.47)
protocol-rack (0.21.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.4)
rake (13.3.1)
rexml (3.4.4)
rubyzip (3.2.2)
sdl2-bindings (0.2.3)
ffi (~> 1.15)
websocket (1.2.11)
websocket-client-simple (0.9.0)
base64
event_emitter
mutex_m
websocket
traces (0.18.2)
win32-process (0.10.0)
ffi (>= 1.0.0)
win32-security (0.5.0)
@@ -41,19 +83,18 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
async-http
async-websocket
base64
cyberarm_engine
digest-crc
excon
i18n
ircparser
libui
rexml
rubyzip
sdl2-bindings
websocket-client-simple
win32-process
win32-security
BUNDLED WITH
2.4.3
2.6.8

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
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.
## Installing
* Install Ruby 3.0+, from your package manager.
## Download
[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 required gems: `bundle install`
## Usage
### Usage
`ruby w3d_hub_linux_launcher.rb`
## Contributing
Contributors welcome, especially if anyone can lend a hand at reducing patching memory usage.
### Contributing
Contributors welcome.

View File

@@ -1,38 +1,41 @@
class W3DHub
class Api
LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 30 # seconds
USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze
DEFAULT_HEADERS = {
"User-Agent": USER_AGENT,
"Accept": "application/json"
}.freeze
FORM_ENCODED_HEADERS = {
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded"
}.freeze
DEFAULT_HEADERS = [
["user-agent", USER_AGENT],
["accept", "application/json"]
].freeze
FORM_ENCODED_HEADERS = [
["user-agent", USER_AGENT],
["accept", "application/json"],
["content-type", "application/x-www-form-urlencoded"]
].freeze
def self.on_thread(method, *args, &callback)
BackgroundWorker.foreground_job(-> { Api.send(method, *args) }, callback)
end
class DummyResponse
def initialize(error)
class Response
def initialize(error: nil, status: -1, body: "")
@status = status
@body = body
@error = error
end
def success?
false
@status == 200
end
def status
-1
@status
end
def body
""
@body
end
def error
@@ -41,53 +44,86 @@ class W3DHub
end
#! === 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 #
API_CONNECTION = Excon.new(ENDPOINT, persistent: true)
HTTP_CLIENTS = {}
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}\"..." }
# Inject Authorization header if account data is populated
if Store.account
logger.debug(LOG_TAG) { " Injecting Authorization header..." }
headers = headers.dup
headers["Authorization"] = "Bearer #{Store.account.access_token}"
headers << ["authorization", "Bearer #{Store.account.access_token}"]
end
connection = api == :api ? API_CONNECTION : GSH_CONNECTION
endpoint = api == :api ? ENDPOINT : SERVER_LIST_ENDPOINT
Sync do
begin
connection.send(
method,
path: url.sub(endpoint, ""),
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::Errors::Timeout => e
response = provision_http_client(endpoint).send(method, path, headers, body)
Response.new(status: response.status, body: response.read)
rescue Async::TimeoutError => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e)
rescue Excon::Error => e
Response.new(error: e)
rescue StandardError => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
DummyResponse.new(e)
Response.new(error: e)
ensure
response&.close
end
end
end
def self.post(url, headers = DEFAULT_HEADERS, body = nil, api = :api)
excon(:post, url, headers, body, api)
def self.provision_http_client(hostname)
# 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
# Method: POST
@@ -105,24 +141,16 @@ class W3DHub
#
# On a failed login the service responds with:
# {"error":"login-failed"}
def self.refresh_user_login(refresh_token)
body = "data=#{JSON.dump({refreshToken: refresh_token})}"
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
def self.refresh_user_login(refresh_token, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({refreshToken: refresh_token}))
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error]
body = "data=#{JSON.dump({ id: 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
user_details_data = user_details(user_data[:userid]) || {}
Account.new(user_data, user_details_data)
else
@@ -133,24 +161,16 @@ class W3DHub
end
# See #user_refresh_token
def self.user_login(username, password)
body = "data=#{JSON.dump({username: username, password: password})}"
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
def self.user_login(username, password, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({username: username, password: password}))
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error]
body = "data=#{JSON.dump({ id: 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
user_details_data = user_details(user_data[:userid]) || {}
Account.new(user_data, user_details_data)
else
@@ -160,18 +180,27 @@ class W3DHub
end
end
# /apis/launcher/1/user-login
# Client sends an Authorization header bearer token which is received from logging in (Required?)
# /apis/w3dhub/1/get-user-details
#
# 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
# /apis/w3dhub/1/get-service-status
# Service response:
# {"services":{"authentication":true,"packageDownload":true}}
def self.service_status
response = post("#{ENDPOINT}/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS)
def self.service_status(backend = :w3dhub)
response = post("/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS, nil, backend)
if response.status == 200
ServiceStatus.new(response.body)
@@ -186,11 +215,11 @@ class W3DHub
# Client sends an Authorization header bearer token which is received from logging in (Optional)
# Launcher sends an empty data request: data={}
# Response is a list of applications/games
def self.applications
response = post("#{ENDPOINT}/apis/launcher/1/get-applications")
def self.applications(backend = :w3dhub)
response = post("/apis/launcher/1/get-applications", DEFAULT_HEADERS, nil, backend)
if response.status == 200
Applications.new(response.body)
Applications.new(response.body, backend)
else
logger.error(LOG_TAG) { "Failed to fetch applications list:" }
logger.error(LOG_TAG) { response }
@@ -198,13 +227,82 @@ class W3DHub
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
# 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)
# Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one
def self.news(category)
body = "data=#{JSON.dump({category: category})}"
response = post("#{ENDPOINT}/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body)
def self.news(category, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({category: category}))
response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200
News.new(response.body)
@@ -220,9 +318,9 @@ class W3DHub
# /apis/launcher/1/get-package-details
# 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 }))
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
hash = JSON.parse(response.body, symbolize_names: true)
@@ -247,9 +345,9 @@ class W3DHub
# /apis/w3dhub/1/get-events
#
# 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 }))
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
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.w3d.cyberarm.dev".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
# FORMAT: JSON
@@ -287,8 +380,8 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# nick, team (index of teams array), score, kills, deaths
def self.server_list(level = 1)
response = get("#{SERVER_LIST_ENDPOINT}/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, :gsh)
def self.server_list(level = 1, backend = :gsh)
response = get("/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
if response.status == 200
data = JSON.parse(response.body, symbolize_names: true)
@@ -309,10 +402,10 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# 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
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
hash = JSON.parse(response.body, symbolize_names: true)

View File

@@ -24,8 +24,9 @@ class W3DHub
def to_json(env)
d = @data.dup
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)
end

View File

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

View File

@@ -1,7 +1,10 @@
class W3DHub
class Api
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)
@data = hash
@@ -12,7 +15,8 @@ class W3DHub
@port = @data[:port]
@region = @data[:region]
@channel = @data[:channel] || "release"
@ping = -1
@version = @data[:version] || NO_OR_DEFAULT_VERSION
@ping = NO_OR_BAD_PING
@status = Status.new(@data[:status])
@@ -55,7 +59,7 @@ class W3DHub
end
end
@ping = -1 if @ping.zero?
@ping = NO_OR_BAD_PING if @ping.zero?
@ping
end,

View File

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

View File

@@ -86,15 +86,15 @@ class W3DHub
# open wwconfig.exe or config.exe for ecw
if (app_data = installed?(app_id, channel) && W3DHub.unix?)
exe = if Store.settings[:wine_prefix]
return unless (app_data = installed?(app_id, channel) && W3DHub.unix?)
exe = if !Store.settings[:wine_prefix].to_s.empty?
"WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" winecfg"
else
"winecfg"
end
Process.spawn("#{exe}")
end
Process.spawn(exe)
end
def repair(app_id, channel)
@@ -169,7 +169,7 @@ class W3DHub
def wine_command(app_id, channel)
return "" if W3DHub.windows?
if Store.settings[:wine_prefix]
if !Store.settings[:wine_prefix].to_s.empty?
"WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" \"#{Store.settings[:wine_command]}\" "
else
"#{Store.settings[:wine_command]} "
@@ -235,11 +235,11 @@ class W3DHub
end
end
def join_server(app_id, channel, server, password = nil)
if installed?(app_id, channel) && Store.settings[:server_list_username].to_s.length.positive?
def join_server(app_id, channel, server, username = Store.settings[:server_list_username], password = nil, multi = false)
if installed?(app_id, channel) && username.to_s.length.positive?
run(
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
@@ -249,9 +249,17 @@ class W3DHub
return nil unless app_data
found_server = Store.server_list.select do |server|
server.game == app_id && server.channel == channel && !server.status.password && server.status.player_count < server.status.max_players
end&.first
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
end
# 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 ? found_server : nil
end
@@ -283,7 +291,7 @@ class W3DHub
end
end
def favorive(app_id, bool)
def favorite(app_id, bool)
Store.settings[:favorites] ||= {}
if bool

View File

@@ -112,6 +112,38 @@ class W3DHub
@task_state == :failed
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
@task_failure_reason || ""
end
@@ -151,7 +183,8 @@ class W3DHub
# Wine present?
if W3DHub.unix?
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
@@ -208,11 +241,18 @@ class W3DHub
@manifests << manifest
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)
manifests << manifest
else
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{manifest.base_version}")
return []
end
end
else
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{@target_version}")
return []
end
@manifests
end
@@ -252,7 +292,7 @@ class W3DHub
next if packages.detect do |pkg|
pkg.category == "games" &&
pkg.subcategory == @app_id &&
pkg.name == file.package &&
pkg.name.to_s.casecmp?(file.package.to_s) &&
pkg.version == file.version
end
@@ -288,17 +328,13 @@ class W3DHub
@files.reverse.each do |file|
break unless folder_exists
safe_file_name = file.name.gsub("\\", "/")
# Fix borked Data -> data 'cause Windows don't care about capitalization
safe_file_name.sub!("Data/", "data/")
file_path = "#{path}/#{safe_file_name}"
file_path = normalize_path(file.name, path)
processed_files += 1
@status.progress = processed_files.to_f / file_count
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)
rejected_files << { file: file, manifest_version: file.version }
@@ -318,7 +354,7 @@ class W3DHub
logger.info(LOG_TAG) { file.inspect } if file.checksum.nil?
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}" }
else
rejected_files << { file: file, manifest_version: file.version }
@@ -362,7 +398,7 @@ class W3DHub
}
end
package_details = Api.package_details(hashes)
package_details = Api.package_details(hashes, @channel.source || :w3dhub)
unless package_details
fail!("Failed to fetch package details")
@@ -376,9 +412,9 @@ class W3DHub
end
package = @packages.find do |pkg|
pkg.category == rich.category &&
pkg.subcategory == rich.subcategory &&
"#{pkg.name}.zip" == rich.name &&
pkg.category.to_s.casecmp?(rich.category.to_s) &&
pkg.subcategory.to_s.casecmp?(rich.subcategory.to_s) &&
"#{pkg.name}.zip".casecmp?(rich.name) &&
pkg.version == rich.version
end
@@ -522,7 +558,7 @@ class W3DHub
logger.info(LOG_TAG) { " #{file.name}" }
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)
@@ -555,7 +591,7 @@ class W3DHub
def write_paths_ini
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("RegBase=W3D Hub")
file.puts("RegClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
@@ -589,17 +625,17 @@ class W3DHub
# Check for and integrity of local manifest
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)
package = array.first
else
fail!("Failed to fetch manifest package details! (#{category}:#{subcategory}:#{name}:#{version})")
return
return false
end
if package.error?
fail!("Failed to retrieve manifest package details! (#{category}:#{subcategory}:#{name}:#{version})\nError: #{package.error.gsub("-", " ").capitalize}")
return
return false
end
if File.exist?(Cache.package_path(category, subcategory, name, version))
@@ -622,6 +658,8 @@ class W3DHub
end
fail!("Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})") unless status_okay
status_okay
end
def verify_package(package, &block)
@@ -698,38 +736,55 @@ class W3DHub
logger.info(LOG_TAG) { " Unpacking patch \"#{package_path}\" in \"#{temp_path}\"" }
unzip(package_path, temp_path)
# Fix borked Data -> data 'cause Windows don't care about capitalization
safe_file_name = "#{manifest_file.name.sub('Data/', 'data/')}"
file_path = normalize_path(manifest_file.name, path)
temp_file_path = normalize_path(manifest_file.name, temp_path)
logger.info(LOG_TAG) { " Loading #{temp_path}/#{safe_file_name}.patch..." }
patch_mix = W3DHub::Mixer::Reader.new(file_path: "#{temp_path}/#{safe_file_name}.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)
logger.info(LOG_TAG) { " Loading #{temp_file_path}.patch..." }
patch_mix = W3DHub::WWMix.new(path: "#{temp_file_path}.patch")
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
logger.info(LOG_TAG) { " Loading #{path}/#{safe_file_name}..." }
target_mix = W3DHub::Mixer::Reader.new(file_path: "#{path}/#{safe_file_name}", ignore_crc_mismatches: false)
patch_info = JSON.parse(patch_entry.blob, symbolize_names: true)
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?
patch_info[:removedFiles].each do |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
logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive?
patch_info[:updatedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" }
patch = patch_mix.package.files.find { |f| f.name.casecmp?(file) }
target = target_mix.package.files.find { |f| f.name.casecmp?(file) }
patch = patch_mix.entries.find { |e| e.name.casecmp?(file) }
target = target_mix.entries.find { |e| e.name.casecmp?(file) }
if target
target_mix.package.files[target_mix.package.files.index(target)] = patch
target_mix.entries[target_mix.entries.index(target)] = patch
else
target_mix.package.files << patch
target_mix.entries << patch
end
end
logger.info(LOG_TAG) { " Writing updated #{path}/#{safe_file_name}..." } if patch_info[:updatedFiles].size.positive?
W3DHub::Mixer::Writer.new(file_path: "#{path}/#{safe_file_name}", package: target_mix.package, memory_buffer: true)
logger.info(LOG_TAG) { " Writing updated #{file_path}..." } if patch_info[:updatedFiles].size.positive?
temp_mix_path = "#{temp_path}/#{File.basename(file_path)}"
temp_mix = W3DHub::WWMix.new(path: temp_mix_path)
target_mix.entries.each { |e| temp_mix.add_entry(entry: e) }
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)
@@ -740,17 +795,15 @@ class W3DHub
stream = Zip::InputStream.new(File.open(package_path))
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("\\", "/")
# 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)}"
dir_path = File.dirname(file_path)
unless dir_path.end_with?("/.") || Dir.exist?(dir_path)
FileUtils.mkdir_p(dir_path)
end
File.open("#{path}/#{safe_file_name}", "wb") do |f|
File.open(file_path, "wb") do |f|
i = entry.get_input_stream
while (chunk = i.read(32_000_000)) # Read up to ~32 MB per chunk

View File

@@ -23,7 +23,12 @@ class W3DHub
def self.verify_peer
no_verify.tap do |context|
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

View File

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

View File

@@ -9,18 +9,18 @@ class W3DHub
end
# 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)
if !force_fetch && File.exist?(path)
path
elsif async
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 }
)
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
end
end
@@ -50,10 +50,16 @@ class W3DHub
end
# 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)
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
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")
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
end
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)
result = false
Sync do
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
remaining_bytes = total_bytes - start_from_bytes
response.each do |chunk|
file.write(chunk)
remaining_bytes -= chunk.size
block.call(chunk, remaining_bytes, total_bytes)
block.call(chunk, total_bytes - file.pos, total_bytes)
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
file&.close
response&.close
end
result
end
# Download a W3D Hub package
def self.fetch_package(package, block)
endpoint_download_url = package.download_url || "#{Api::ENDPOINT}/apis/launcher/1/get-package"
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
async_fetch_package(package, block)
end
def self.acquire_net_lock(key)

View File

@@ -32,6 +32,15 @@ class W3DHub
linux? || mac?
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)
raise "Hazardous input: #{path}" if path.include?("&&") || path.include?(";")
@@ -72,15 +81,19 @@ class W3DHub
)
end
def self.join_server(server, password)
def self.join_server(server:, username: Store.settings[:server_list_username], password: nil, multi: false)
if (
(server.status.password && password.length.positive?) ||
!server.status.password) &&
Store.settings[:server_list_username].to_s.length.positive?
username.to_s.length.positive?
Store.application_manager.join_server(
server.game,
server.channel, server, password
server.channel,
server,
username,
password,
multi
)
else
CyberarmEngine::Window.instance.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?")
@@ -127,8 +140,7 @@ class W3DHub
end
status.zero?
else
if block
elsif block
IO.popen(command, "r") do |io|
io.each_line do |line|
block&.call(line)
@@ -140,16 +152,17 @@ class W3DHub
system(command)
end
end
end
def self.home_directory
File.expand_path("~")
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?
# search for command
cmds = %w{ zenity matedialog qarma kdialog }
cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd|
cmd if system("which #{cmd}")
@@ -157,9 +170,10 @@ class W3DHub
path = case File.basename(command)
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"
`#{command} --title "#{title}" --getopenfilename . "#{filter}"`
`#{command} --title "#{title}" --getopenfilename . "#{filters.join(" ")}"`
else
raise "No known command found for system file selection dialog!"
end
@@ -176,7 +190,7 @@ class W3DHub
def self.ask_folder(title: "Open Folder")
if W3DHub.unix?
# search for command
cmds = %w{ zenity matedialog qarma kdialog }
cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd|
cmd if system("which #{cmd}")
@@ -193,7 +207,7 @@ class W3DHub
path.strip
else
result_ptr = LibUI.open_folder(window)
result_ptr = LibUI.open_folder(LIBUI_WINDOW)
result = result_ptr.null? ? "" : result_ptr.to_s.gsub("\\", "/")
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

@@ -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
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false)
Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
end
@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
background 0x44_000000
# 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
tagline "<b>#{item.title}</b>", width: 1.0

View File

@@ -36,7 +36,7 @@ class W3DHub
background app_color
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
stack(margin_left: 8, width: 0.75) do

View File

@@ -17,7 +17,7 @@ class W3DHub
end
# 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
@@ -85,7 +85,7 @@ class W3DHub
padding_left: 4, padding_right: 4, tip: game.name) do
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
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,31 @@ class W3DHub
@game_page_container.clear do
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0x88
game_color.alpha = 0xaa
background game_color
@game_page_container.style.background_image_color = game_color
@game_page_container.style.default[:background_image_color] = game_color
@game_page_container.update_background_image
background_image_path = Cache.package_path(game.category, game.id, "background.png", "")
if File.exist?(background_image_path)
States::Interface.instance&.instance_variable_get(:"@interface_container")&.style&.background_image = get_image(background_image_path)
States::Interface.instance&.instance_variable_get(:"@interface_container")&.style&.default[:background_image] = get_image(background_image_path)
end
# Game Stuff
flow(width: 1.0, fill: true) do
# background 0xff_9999ff
# Game options
stack(width: 360, height: 1.0, padding: 8, scroll: true, border_thickness_right: 1, border_color_right: W3DHub::BORDER_COLOR) do
background 0x55_000000
stack(width: 360, height: 1.0, padding: 8, scroll: true, background: game_color, border_thickness_right: 1, border_color_right: W3DHub::BORDER_COLOR) do
# Game Logo
logo_image_path = Cache.package_path(game.category, game.id, "logo.png", "")
# Game Banner
image_path = "#{GAME_ROOT_PATH}/media/banners/#{game.id}.png"
if File.exist?(image_path)
image image_path, width: 1.0
if File.exist?(logo_image_path)
image logo_image_path, width: 1.0
else
banner game.name unless File.exist?(image_path)
banner game.name unless File.exist?(logo_image_path)
end
stack(width: 1.0, fill: true, scroll: true, margin_top: 32) do
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[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?
@@ -279,7 +278,7 @@ class W3DHub
end
# 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
# Game News
@@ -331,11 +330,6 @@ class W3DHub
def populate_all_games_view
@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_channel = nil
@@ -375,7 +369,7 @@ class W3DHub
flow(width: 1.0, height: 28, padding: 8) do
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|
Store.application_manager.favorive(game.id, btn.value)
Store.application_manager.favorite(game.id, btn.value)
Store.settings.save_settings
populate_games_list
@@ -383,7 +377,7 @@ class W3DHub
end
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(fill: true)
image image_path, width: 0.5
@@ -425,7 +419,7 @@ class W3DHub
return false unless news
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false)
Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
end
@game_news[game.id] = news
@@ -438,6 +432,9 @@ class W3DHub
return unless @focused_game == game
if (feed = @game_news[game.id])
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0xaa
@game_news_container.clear do
# Patch Notes
if false # Patch notes
@@ -467,9 +464,7 @@ class W3DHub
feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item|
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
background 0x44_000000
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
if File.file?(image_path)
image image_path, height: 1.0
end
@@ -518,9 +513,10 @@ class W3DHub
@game_events_container.show unless events.empty?
@game_events_container.hide if events.empty?
return unless (event = events.flatten.first)
@game_events_container.clear do
events.flatten.each do |event|
stack(fill: true, height: 1.0, margin_left: 8, margin_right: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
stack(width: 1.0, fill: true, margin_left: 8, margin_right: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
background 0x44_000000
title event.title, width: 1.0, text_align: :center
@@ -530,7 +526,6 @@ class W3DHub
end
end
end
end
def populate_game_modifications(application, channel)
@game_news_container.clear do

View File

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

View File

@@ -3,7 +3,7 @@ class W3DHub
class ServerBrowser < Page
def setup
@server_locked_icons = {}
@refresh_server_list = false
@refresh_server_list_at_ms = nil
refresh_server = false
@selected_server ||= nil
@@ -43,7 +43,7 @@ class W3DHub
app = Store.applications.games.find { |a| a.id == app_id.to_s }
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,
border_thickness_bottom: 1, border_color_bottom: 0x00_000000,
@@ -138,8 +138,8 @@ class W3DHub
def update
super
if @refresh_server_list && Gosu.milliseconds >= @refresh_server_list
@refresh_server_list = nil
if @refresh_server_list_at_ms && Gosu.milliseconds >= @refresh_server_list_at_ms
@refresh_server_list_at_ms = nil
# populate_server_list
reorder_server_list
@@ -196,11 +196,11 @@ class W3DHub
def ping_icon(server)
case server.ping
when 0..160
when 0..150
@ping_icons[:good]
when 161..250
when 151..200
@ping_icons[:fair]
when 251..1_000
when 201..1_000
@ping_icons[:poor]
when 1_001..5_000
@ping_icons[:bad]
@@ -210,19 +210,7 @@ class W3DHub
end
def ping_tip(server)
server.ping.negative? ? "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
server.ping == W3DHub::Api::ServerListServer::NO_OR_BAD_PING ? "Ping failed" : "Ping #{server.ping}ms"
end
def refresh_server_list(server, mode = :update) # :remove, :refresh_all
@@ -231,7 +219,7 @@ class W3DHub
return
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
server_container = find_element_by_tag(@server_list_container, server.id)
@@ -292,7 +280,7 @@ class W3DHub
@server_list_container.children.sort_by! do |child|
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|
next if @selected_server_container && child == @selected_server_container
@@ -333,7 +321,8 @@ class W3DHub
para server&.status&.name, tag: :server_name, font: BOLD_FONT, text_wrap: :none
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
end
end
@@ -389,11 +378,12 @@ class W3DHub
flow(width: 1.0, height: 46, margin_top: 16, margin_bottom: 16) do
game_installed = Store.application_manager.installed?(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)
style = ((channel && channel.user_level.downcase.strip == "public") || server.channel == "release") ? {} : TESTING_BUTTON
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
# prompt for nickname
# !abort unless nickname set
@@ -410,11 +400,11 @@ class W3DHub
if server.status.password
W3DHub.prompt_for_password(
accept_callback: proc do |password|
W3DHub.join_server(server, password)
W3DHub.join_server(server: server, password: password)
end
)
else
W3DHub.join_server(server, nil)
W3DHub.join_server(server: server)
end
end
)
@@ -422,18 +412,24 @@ class W3DHub
if server.status.password
W3DHub.prompt_for_password(
accept_callback: proc do |password|
W3DHub.join_server(server, password)
W3DHub.join_server(server: server, password: password)
end
)
else
W3DHub.join_server(server, nil)
W3DHub.join_server(server: server)
end
end
end
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)
button "Multijoin", tip: "Launch multiple clients with configured username_\#{number}", 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) 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
flow(fill: true)
@@ -534,7 +530,7 @@ class W3DHub
end
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
@server_locked_icons[server.game] ||= Gosu.render(96, 96) do

View File

@@ -3,82 +3,92 @@ class W3DHub
class Settings < Page
def setup
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
para "<b>Language</b>"
flow(width: 1.0, height: 0.12) do
para "<b>Launcher Language</b>", width: 0.249, margin_left: 32, margin_top: 12
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
para "Select the UI language you'd like to use in the W3D Hub Launcher."
stack(width: 1.0, fill: true, max_width: 720, h_align: :center, scroll: true) do
tagline "Launcher Language"
@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
para "Select the UI language you'd like to use in the W3D Hub Launcher.", margin_left: 16
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
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
@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"
caption "Package Cache Directory", margin_left: 16, margin_top: 16
flow(width: 1.0, 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
flow(width: 1.0, margin_top: 16) do
para "<b>Package Cache Folder</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], width: 1.0
para "A folder which will be used to cache downloaded packages used to install games and apps"
if W3DHub.unix?
tagline "Wine - Windows compatibility layer", margin_top: 16
caption "Wine Command", margin_left: 16
flow(width: 1.0, margin_left: 16) do
@wine_command_input = edit_line Store.settings[:wine_command], fill: true
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
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
if true # W3DHub.unix?
para "<b>Wine</b>", margin_top: 8, padding_top: 8, border_thickness_top: 2, border_color_top: 0xee_ffffff, width: 1.0
flow(width: 1.0, height: 0.12) do
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"
flow(width: 256, height: 64, h_align: :center, margin_top: 16) do
button "Save", width: 1.0 do
save_settings!
end
flow(fill: true)
end
flow(width: 1.0, height: 0.13, margin_top: 16) do
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
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
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|
logger.info(LOG_TAG) { "Purging cache (#{Store.settings[:package_cache_dir]})..." }
FileUtils.remove_dir(Store.settings[:package_cache_dir], force: true)
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)}"
end
end
end
@@ -108,7 +118,37 @@ class W3DHub
when "es"
"Español"
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

View File

@@ -2,12 +2,13 @@ class W3DHub
class Settings
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,
package_cache_dir: default_package_cache_dir,
parallel_downloads: 4,
wine_command: "wine",
create_wine_prefixes: true,
wine_prefix: "",
winetricks_command: "winetricks",
allow_diagnostic_reports: false,
server_list_username: "",
server_list_filters: {},
@@ -66,6 +67,14 @@ class W3DHub
def load_settings
@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
def save_settings

View File

@@ -11,19 +11,22 @@ class W3DHub
@fraction = 0.0
@w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png")
@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 },
refresh_user_token: { started: false, complete: false },
service_status: { 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
@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
end
@@ -39,7 +42,8 @@ class W3DHub
end
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)
super
@@ -127,7 +131,7 @@ class W3DHub
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
Store.settings[:account] = {}
end
@@ -137,6 +141,39 @@ class W3DHub
@tasks[:refresh_user_token][:complete] = true
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
Api.on_thread(:service_status) do |service_status|
@service_status = service_status
@@ -150,7 +187,9 @@ class W3DHub
@tasks[:service_status][:complete] = true
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
@offline_mode = true
@@ -159,10 +198,36 @@ class W3DHub
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
@status_label.value = I18n.t(:"boot.checking_for_updates")
Api.on_thread(:applications) do |applications|
Api.on_thread(:_applications) do |applications|
if applications
Store.applications = applications
Store.settings.save_application_cache(applications.data.to_json)
@@ -180,37 +245,80 @@ class W3DHub
def app_icons
return unless Store.applications
@status_label.value = "Retrieving application icons, this might take a moment..." # I18n.t(:"boot.checking_for_updates")
packages = []
failure = false
Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" }
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&.each do |package|
next if package.error?
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
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
regenerate = !File.exist?(generated_icon_path)
else
begin
Cache.fetch_package(package, proc {})
regenerate = true
end
if regenerate
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, ->(result) { result.save(result.images.max_by(&:width), generated_icon_path) })
rescue Errno::EACCES => e
failure = true
push_state(MessageDialog, title: "Fatal Error",
message: "Directory Permission Error (#{e.class}):\n#{e}.\n\nIs the required drive mounted?",
accept_callback: -> { window.close })
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
@@ -263,7 +371,7 @@ class W3DHub
"web-links": [],
"extended-data": [
{ 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
button "Okay", width: 1.0 do
pop_state
@options[:accept_callback]&.call
end
end
end

View File

@@ -4,6 +4,8 @@ class W3DHub
APPLICATIONS_UPDATE_INTERVAL = 10 * 60 * 1000 # ten 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
@@instance = nil
@@ -33,10 +35,12 @@ class W3DHub
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
@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(fill: true)
image "#{GAME_ROOT_PATH}/media/icons/app.png", height: 84
@@ -54,18 +58,26 @@ class W3DHub
end
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)
end
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)
end
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)
end
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)
end
end
@@ -138,7 +150,7 @@ class W3DHub
if Gosu.milliseconds >= @applications_expire
@applications_expire = Gosu.milliseconds + 30_000
Api.on_thread(:applications) do |applications|
Api.on_thread(:_applications) do |applications|
if applications
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes

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(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
end
@@ -24,9 +24,12 @@ class W3DHub
def card_welcome
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)}"
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
flow(width: 1.0, height: 46) do
@@ -44,14 +47,25 @@ class W3DHub
def card_getting_started
stack(width: 1.0, fill: true) do
banner "Getting Started", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000
title "Import C&C 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
banner "Getting Started", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_0074e0
title "Import Command & Conquer: Renegade"
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"
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
flow(width: 1.0, height: 46) do
@@ -66,25 +80,22 @@ class W3DHub
end
button "Next >" do
@card_container.clear { card_communitiy }
@card_container.clear { W3DHub.unix? ? card_wine : card_community }
end
end
end
def card_communitiy
def card_wine
stack(width: 1.0, fill: true) do
banner "W3D Hub Community", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000
title "Forums"
caption "Join our forum community", margin_left: 32
title "Facebook"
caption "Like us on Facebook", margin_left: 32
title "Discord"
caption "Join our Discord community server", margin_left: 32
title "YouTube"
caption "Subscribe to our YouTube channel", margin_left: 32
banner "Wine - Windows compatibility layer", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_0074e0
stack(width: 1.0, fill: true, margin_left: 32) do
title "Got Wine?"
caption "The launcher requires a windows compatibility tool like wine in order to run the games.", margin_left: 32
caption "Install wine and winetricks through your distribution's package manager or use a wine manager like Bottles.", 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")
end
end
end
flow(width: 1.0, height: 46) do
@@ -92,6 +103,52 @@ class W3DHub
button "< Back" do
@card_container.clear { card_getting_started }
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
button "Done" do

View File

@@ -1,4 +1,4 @@
class W3DHub
DIR_NAME = "W3DHubAlt"
VERSION = "0.6.0"
DIR_NAME = "W3DHubAlt".freeze
VERSION = "0.9.0".freeze
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
def setup
self.show_stats_plotter = false
self.caption = I18n.t(:app_name)
self.caption = "#{I18n.t(:app_name)} v#{VERSION}"
Store[:server_list] = []
Store[:settings] = Settings.new
@@ -17,8 +17,8 @@ class W3DHub
end
# push_state(W3DHub::States::DemoInputDelay)
# push_state(W3DHub::States::Welcome)
push_state(W3DHub::States::Boot)
push_state(W3DHub::States::Welcome) unless File.exist?(SETTINGS_FILE_PATH)
# push_state(W3DHub::States::DirectConnectDialog)
# push_state(W3DHub::Asterisk::States::IRCProfileForm)
end

280
lib/ww_mix.rb Normal file
View File

@@ -0,0 +1,280 @@
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:)
return false unless File.exist?(path)
return false if File.directory?(path)
info = EntryInfoHeader.new(0, 0, File.size(path))
@entries << Entry.new(name: File.basename(path), path: path, info: info)
true
end
def add_blob(path:, blob:)
info = EntryInfoHeader.new(0, 0, blob.size)
@entries << Entry.new(name: File.basename(path), path: path, info: info, blob: blob)
into.crc32 = @entries.last.calculate_crc32
true
end
def add_entry(entry:)
@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 "base64"
require "zip"
require "excon"
require "async"
require "async/http/endpoint"
require "async/websocket/client"
require "async/http/internet/instance"
class W3DHub
W3DHUB_DEBUG = ARGV.join.include?("--debug")
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"
LOGS_PATH = "#{GAME_ROOT_PATH}/data/logs"
SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.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
LOG_TAG = "W3DHubLinuxLauncher"
@@ -77,11 +87,10 @@ class W3DHub
BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff")
end
require "i18n"
require "websocket-client-simple"
require "English"
require "sdl2"
require_relative "lib/i18n"
I18n.load_path << Dir["#{W3DHub::GAME_ROOT_PATH}/locales/*.yml"]
I18n.default_locale = :en
@@ -95,11 +104,12 @@ require_relative "lib/store"
require_relative "lib/window"
require_relative "lib/cache"
require_relative "lib/settings"
require_relative "lib/mixer"
require_relative "lib/ww_mix"
require_relative "lib/ico"
require_relative "lib/multicast_server"
require_relative "lib/broadcast_server"
require_relative "lib/hardware_survey"
require_relative "lib/game_settings"
require_relative "lib/websocket_client"
require_relative "lib/background_worker"
require_relative "lib/application_manager"
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/game_settings_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/service_status"