37 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
29 changed files with 1098 additions and 842 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']

View File

@@ -2,7 +2,7 @@ name: Build Launcher Binary
on: on:
push: push:
branches: [ master, test ] branches: [master]
workflow_dispatch: workflow_dispatch:
jobs: jobs:

17
Gemfile
View File

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

View File

@@ -1,37 +1,77 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
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) base64 (0.3.0)
concurrent-ruby (1.3.5) console (1.34.2)
cyberarm_engine (0.24.5) fiber-annotation
fiber-local (~> 1.1)
json
cyberarm_engine (0.25.0)
gosu (~> 1.1) gosu (~> 1.1)
digest-crc (0.7.0) digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
event_emitter (0.2.6) ffi (1.17.0)
excon (1.3.0) ffi-win32-extensions (1.1.0)
logger ffi (>= 1.15.5, <= 1.17.0)
ffi (1.17.2-x64-mingw-ucrt) fiber-annotation (0.2.0)
ffi (1.17.2-x86_64-linux-gnu) fiber-local (1.1.0)
ffi-win32-extensions (1.0.4) fiber-storage
ffi fiber-storage (1.0.1)
fiddle (1.1.8)
gosu (1.4.6) gosu (1.4.6)
i18n (1.14.7) io-endpoint (0.16.0)
concurrent-ruby (~> 1.0) io-event (1.14.2)
io-stream (0.11.1)
ircparser (1.0.0) ircparser (1.0.0)
libui (0.1.2-x64-mingw) json (2.18.0)
logger (1.7.0) libui (0.2.0-x64-mingw-ucrt)
mutex_m (0.3.0) fiddle
rake (13.3.0) metrics (0.15.0)
rexml (3.4.2) protocol-hpack (1.5.1)
rubyzip (3.0.2) 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) sdl2-bindings (0.2.3)
ffi (~> 1.15) ffi (~> 1.15)
websocket (1.2.11) traces (0.18.2)
websocket-client-simple (0.9.0)
base64
event_emitter
mutex_m
websocket
win32-process (0.10.0) win32-process (0.10.0)
ffi (>= 1.0.0) ffi (>= 1.0.0)
win32-security (0.5.0) win32-security (0.5.0)
@@ -43,17 +83,16 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
async-http
async-websocket
base64 base64
cyberarm_engine cyberarm_engine
digest-crc digest-crc
excon
i18n
ircparser ircparser
libui libui
rexml rexml
rubyzip rubyzip
sdl2-bindings sdl2-bindings
websocket-client-simple
win32-process win32-process
win32-security win32-security

View File

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

View File

@@ -1,44 +1,41 @@
class W3DHub class W3DHub
class Api class Api
# Set Excon default CA file if found
if (ca_file = W3DHub.ca_bundle_path)
Excon.defaults[:ssl_ca_file] = ca_file
end
LOG_TAG = "W3DHub::Api".freeze LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 30 # seconds API_TIMEOUT = 30 # seconds
USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze
DEFAULT_HEADERS = { DEFAULT_HEADERS = [
"User-Agent": USER_AGENT, ["user-agent", USER_AGENT],
"Accept": "application/json" ["accept", "application/json"]
}.freeze ].freeze
FORM_ENCODED_HEADERS = { FORM_ENCODED_HEADERS = [
"User-Agent": USER_AGENT, ["user-agent", USER_AGENT],
"Accept": "application/json", ["accept", "application/json"],
"Content-Type": "application/x-www-form-urlencoded" ["content-type", "application/x-www-form-urlencoded"]
}.freeze ].freeze
def self.on_thread(method, *args, &callback) def self.on_thread(method, *args, &callback)
BackgroundWorker.foreground_job(-> { Api.send(method, *args) }, callback) BackgroundWorker.foreground_job(-> { Api.send(method, *args) }, callback)
end end
class DummyResponse class Response
def initialize(error) def initialize(error: nil, status: -1, body: "")
@status = status
@body = body
@error = error @error = error
end end
def success? def success?
false @status == 200
end end
def status def status
-1 @status
end end
def body def body
"" @body
end end
def error def error
@@ -48,103 +45,85 @@ class W3DHub
#! === W3D Hub API === !# #! === W3D Hub API === !#
W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze # W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
W3DHUB_API_CONNECTION = Excon.new(W3DHUB_API_ENDPOINT, persistent: true)
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 # 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 #
ALT_W3DHUB_API_API_CONNECTION = Excon.new(ALT_W3DHUB_API_ENDPOINT, persistent: true)
def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub) HTTP_CLIENTS = {}
def self.async_http(method, path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
case backend case backend
when :w3dhub when :w3dhub
connection = W3DHUB_API_CONNECTION
endpoint = W3DHUB_API_ENDPOINT endpoint = W3DHUB_API_ENDPOINT
when :alt_w3dhub when :alt_w3dhub
connection = ALT_W3DHUB_API_API_CONNECTION
endpoint = ALT_W3DHUB_API_ENDPOINT endpoint = ALT_W3DHUB_API_ENDPOINT
when :gsh when :gsh
connection = GSH_CONNECTION
endpoint = SERVER_LIST_ENDPOINT endpoint = SERVER_LIST_ENDPOINT
end end
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{endpoint}#{url}\"..." } # 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
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." }
# Inject Authorization header if account data is populated # Inject Authorization header if account data is populated
if Store.account if Store.account
logger.debug(LOG_TAG) { " Injecting Authorization header..." } logger.debug(LOG_TAG) { " Injecting Authorization header..." }
headers = headers.dup headers = headers.dup
headers["Authorization"] = "Bearer #{Store.account.access_token}" headers << ["authorization", "Bearer #{Store.account.access_token}"]
end end
begin Sync do
connection.send( begin
method, response = provision_http_client(endpoint).send(method, path, headers, body)
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::Error::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e) Response.new(status: response.status, body: response.read)
rescue Excon::Error => e rescue Async::TimeoutError => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" } logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
logger.error(LOG_TAG) { e }
DummyResponse.new(e) Response.new(error: e)
rescue StandardError => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
Response.new(error: e)
ensure
response&.close
end
end end
end end
def self.post(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub) def self.provision_http_client(hostname)
excon(:post, url, headers, body, backend) # 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 end
def self.get(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub) def self.post(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
excon(:get, url, headers, body, backend) 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 end
# Api.get but handles any URL instead of known hosts # Api.get but handles any URL instead of known hosts
def self.fetch(url, headers = DEFAULT_HEADERS, body = nil, backend = nil) def self.fetch(path, headers = DEFAULT_HEADERS, body = nil, backend = nil)
uri = URI(url) async_http(:get, path, headers, body, backend)
# Use Api.get for `W3DHUB_API_ENDPOINT` URL's to exploit keep alive and connection reuse (faster responses)
return excon(:get, url, headers, body, backend) if "#{uri.scheme}://#{uri.host}" == W3DHUB_API_ENDPOINT
logger.debug(LOG_TAG) { "Fetching GET \"#{url}\"..." }
begin
Excon.get(
url,
headers: headers,
body: body,
nonblock: true,
tcp_nodelay: true,
write_timeout: API_TIMEOUT,
read_timeout: API_TIMEOUT,
connect_timeout: API_TIMEOUT,
idempotent: true,
retry_limit: 3,
retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
)
rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e)
rescue Excon::Error => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
DummyResponse.new(e)
end
end end
# Method: POST # Method: POST
@@ -163,7 +142,7 @@ class W3DHub
# On a failed login the service responds with: # On a failed login the service responds with:
# {"error":"login-failed"} # {"error":"login-failed"}
def self.refresh_user_login(refresh_token, backend = :w3dhub) def self.refresh_user_login(refresh_token, backend = :w3dhub)
body = "data=#{JSON.dump({refreshToken: refresh_token})}" body = URI.encode_www_form("data": JSON.dump({refreshToken: refresh_token}))
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend) response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
@@ -183,7 +162,7 @@ class W3DHub
# See #user_refresh_token # See #user_refresh_token
def self.user_login(username, password, backend = :w3dhub) def self.user_login(username, password, backend = :w3dhub)
body = "data=#{JSON.dump({username: username, password: password})}" body = URI.encode_www_form("data": JSON.dump({username: username, password: password}))
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend) response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
@@ -205,7 +184,7 @@ class W3DHub
# #
# Response: avatar-uri (Image download uri), id, username # Response: avatar-uri (Image download uri), id, username
def self.user_details(id, backend = :w3dhub) def self.user_details(id, backend = :w3dhub)
body = "data=#{JSON.dump({ id: id })}" body = URI.encode_www_form("data": JSON.dump({ id: id }))
user_details = post("/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body, backend) user_details = post("/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body, backend)
if user_details.status == 200 if user_details.status == 200
@@ -322,7 +301,7 @@ class W3DHub
# Client requests news for a specific application/game e.g.: data={"category":"ia"} ("launcher-home" retrieves the weekly hub updates) # Client requests news for a specific application/game e.g.: data={"category":"ia"} ("launcher-home" retrieves the weekly hub updates)
# Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one # Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one
def self.news(category, backend = :w3dhub) def self.news(category, backend = :w3dhub)
body = "data=#{JSON.dump({category: category})}" body = URI.encode_www_form("data": JSON.dump({category: category}))
response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend) response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 if response.status == 200
@@ -383,7 +362,6 @@ class W3DHub
# SERVER_LIST_ENDPOINT = "https://gsh.w3dhub.com".freeze # SERVER_LIST_ENDPOINT = "https://gsh.w3dhub.com".freeze
SERVER_LIST_ENDPOINT = "https://gsh.w3d.cyberarm.dev".freeze SERVER_LIST_ENDPOINT = "https://gsh.w3d.cyberarm.dev".freeze
# SERVER_LIST_ENDPOINT = "http://127.0.0.1:9292".freeze # SERVER_LIST_ENDPOINT = "http://127.0.0.1:9292".freeze
GSH_CONNECTION = Excon.new(SERVER_LIST_ENDPOINT, persistent: true)
# Method: GET # Method: GET
# FORMAT: JSON # FORMAT: JSON

View File

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

View File

@@ -2,8 +2,9 @@ class W3DHub
class Api class Api
class ServerListServer class ServerListServer
NO_OR_BAD_PING = 1_000_000 NO_OR_BAD_PING = 1_000_000
NO_OR_DEFAULT_VERSION = "838"
attr_reader :id, :game, :address, :port, :region, :channel, :ping, :status attr_reader :id, :game, :address, :port, :region, :channel, :version, :ping, :status
def initialize(hash) def initialize(hash)
@data = hash @data = hash
@@ -14,6 +15,7 @@ class W3DHub
@port = @data[:port] @port = @data[:port]
@region = @data[:region] @region = @data[:region]
@channel = @data[:channel] || "release" @channel = @data[:channel] || "release"
@version = @data[:version] || NO_OR_DEFAULT_VERSION
@ping = NO_OR_BAD_PING @ping = NO_OR_BAD_PING
@status = Status.new(@data[:status]) @status = Status.new(@data[:status])

View File

@@ -2,6 +2,9 @@ class W3DHub
class Api class Api
class ServerListUpdater class ServerListUpdater
LOG_TAG = "W3DHub::Api::ServerListUpdater".freeze LOG_TAG = "W3DHub::Api::ServerListUpdater".freeze
TYPE_PING = 6
include CyberarmEngine::Common include CyberarmEngine::Common
@@instance = nil @@instance = nil
@@ -15,6 +18,7 @@ class W3DHub
def initialize def initialize
@auto_reconnect = false @auto_reconnect = false
@reconnection_delay = 1
@invocation_id = 0 @invocation_id = 0
@@ -24,19 +28,21 @@ class W3DHub
def run def run
Thread.new do Thread.new do
begin Sync do |task|
connect begin
@auto_reconnect = true
while W3DHub::BackgroundWorker.alive? while W3DHub::BackgroundWorker.alive?
connect if @auto_reconnect connect if @auto_reconnect
sleep 1 sleep @reconnection_delay
end
rescue => e
puts e
puts e.backtrace
sleep 30
retry
end end
rescue => e
puts e
puts e.backtrace
sleep 30
retry
end end
end end
@@ -52,9 +58,13 @@ class W3DHub
if response.status != 200 if response.status != 200
@auto_reconnect = true @auto_reconnect = true
@reconnection_delay = @reconnection_delay * 2
@reconnection_delay = 60 if @reconnection_delay > 60
return return
end end
@reconnection_delay = 1
data = JSON.parse(response.body, symbolize_names: true) data = JSON.parse(response.body, symbolize_names: true)
@invocation_id = 0 if @invocation_id > 9095 @invocation_id = 0 if @invocation_id > 9095
@@ -63,7 +73,7 @@ class W3DHub
logger.debug(LOG_TAG) { "Connecting to websocket..." } logger.debug(LOG_TAG) { "Connecting to websocket..." }
this = self this = self
@ws = WebSocket::Client::Simple.connect(endpoint, headers: Api::DEFAULT_HEADERS) do |ws| @ws = WebSocketClient.new.connect(endpoint, headers: Api::DEFAULT_HEADERS) do |ws|
ws.on(:open) do ws.on(:open) do
logger.debug(LOG_TAG) { "Requesting json protocol, v1..." } logger.debug(LOG_TAG) { "Requesting json protocol, v1..." }
ws.send({ protocol: "json", version: 1 }.to_json + "\x1e") ws.send({ protocol: "json", version: 1 }.to_json + "\x1e")
@@ -78,65 +88,71 @@ class W3DHub
end end
ws.on(:message) do |msg| ws.on(:message) do |msg|
msg = msg.data.split("\x1e").first msg = msg.to_str.split("\x1e").first
hash = JSON.parse(msg, symbolize_names: true) hash = JSON.parse(msg, symbolize_names: true)
# pp hash if hash[:target] != "ServerStatusChanged" && hash[:type] != 6 && hash[:type] != 3 # pp hash if hash[:target] != "ServerStatusChanged" && hash[:type] != 6 && hash[:type] != 3
# Send PING(?) # Send PING(?)
if hash.empty? || hash[:type] == 6 if hash.empty? || hash[:type] == TYPE_PING
ws.send({ type: 6 }.to_json + "\x1e") ws.send({ type: TYPE_PING }.to_json + "\x1e")
else next
case hash[:type] end
when 1
case hash[:target]
when "ServerRegistered"
data = hash[:arguments].first
this.invocation_id += 1 case hash[:type]
out = { "type": 1, "invocationId": "#{this.invocation_id}", "target": "SubscribeToServerStatusUpdates", "arguments": [data[:id], 1] } when 1
ws.send(out.to_json + "\x1e") case hash[:target]
when "ServerRegistered"
data = hash[:arguments].first
BackgroundWorker.foreground_job( this.invocation_id += 1
->(data) { [Api.server_details(data[:id], 2), data] }, out = {
->(array) do "type": 1,
server_data, data = array "invocationId": "#{this.invocation_id}",
"target": "SubscribeToServerStatusUpdates",
"arguments": [data[:id], 1]
}
ws.send(out.to_json + "\x1e")
next unless server_data BackgroundWorker.foreground_job(
->(data) { [Api.server_details(data[:id], 2), data] },
->(array) do
server_data, data = array
data[:status] = server_data next unless server_data
server = ServerListServer.new(data) data[:status] = server_data
Store.server_list.push(server)
States::Interface.instance&.update_server_browser(server, :update)
end,
nil,
data
)
when "ServerStatusChanged" server = ServerListServer.new(data)
id, data = hash[:arguments] Store.server_list.push(server)
server = Store.server_list.find { |s| s.id == id } States::Interface.instance&.update_server_browser(server, :update)
server_updated = server&.update(data) end,
nil,
data
)
BackgroundWorker.foreground_job(->(server) { server }, ->(server) { States::Interface.instance&.update_server_browser(server, :update) }, nil, server) if server_updated when "ServerStatusChanged"
id, data = hash[:arguments]
server = Store.server_list.find { |s| s.id == id }
server_updated = server&.update(data)
when "ServerUnregistered" BackgroundWorker.foreground_job(->(server) { server }, ->(server) { States::Interface.instance&.update_server_browser(server, :update) }, nil, server) if server_updated
id = hash[:arguments].first
server = Store.server_list.find { |s| s.id == id }
if server when "ServerUnregistered"
Store.server_list.delete(server) id = hash[:arguments].first
BackgroundWorker.foreground_job(->(server) { server }, ->(server) { States::Interface.instance&.update_server_browser(server, :remove) }, nil, server) server = Store.server_list.find { |s| s.id == id }
end
if server
Store.server_list.delete(server)
BackgroundWorker.foreground_job(->(server) { server }, ->(server) { States::Interface.instance&.update_server_browser(server, :remove) }, nil, server)
end end
end end
end end
end end
ws.on(:close) do |e| ws.on(:close) do
logger.error(LOG_TAG) { e } logger.error(LOG_TAG) { "Connection closed." }
this.auto_reconnect = true this.auto_reconnect = true
ws.close ws.close
end end
@@ -181,14 +197,24 @@ class W3DHub
# unsubscribe from removed servers # unsubscribe from removed servers
removed_servers.each do removed_servers.each do
@invocation_id += 1 @invocation_id += 1
out = { "type": 1, "invocationId": "#{@invocation_id}", "target": "SubscribeToServerStatusUpdates", "arguments": [server.id, 0] } out = {
"type": 1,
"invocationId": "#{@invocation_id}",
"target": "SubscribeToServerStatusUpdates",
"arguments": [server.id, 0]
}
ws.send(out.to_json + "\x1e") ws.send(out.to_json + "\x1e")
end end
# subscribe to new servers # subscribe to new servers
new_servers.each do new_servers.each do
@invocation_id += 1 @invocation_id += 1
out = { "type": 1, "invocationId": "#{@invocation_id}", "target": "SubscribeToServerStatusUpdates", "arguments": [server.id, 1] } out = {
"type": 1,
"invocationId": "#{@invocation_id}",
"target": "SubscribeToServerStatusUpdates",
"arguments": [server.id, 1]
}
ws.send(out.to_json + "\x1e") ws.send(out.to_json + "\x1e")
end end
end end

View File

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

View File

@@ -114,26 +114,33 @@ class W3DHub
def normalize_path(path, base_path) def normalize_path(path, base_path)
path = path.to_s.gsub("\\", "/") path = path.to_s.gsub("\\", "/")
return path if W3DHub.windows? # Windows is easy, or annoying, depending how you look at it... return "#{base_path}/#{path}" if W3DHub.windows? # Windows is easy, or annoying, depending how you look at it...
constructed_path = base_path constructed_path = base_path
lowercase_full_path = "#{base_path}/#{path}".downcase.strip.freeze
accepted_parts = 0
split_path = path.split("/") split_path = path.split("/")
split_path.each do |segment| split_path.each do |segment|
Dir.glob("#{constructed_path}/*").each do |part| Dir.glob("#{constructed_path}/*").each do |part|
next unless "#{constructed_path}/#{segment}".downcase == part.downcase next unless "#{constructed_path}/#{segment}".downcase == part.downcase
constructed_path = part # 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
break if File.file?(constructed_path) constructed_path = part
accepted_parts += 1
break
end end
end end
# Find file if it exists, otherwise downcase the `path` sans `base_path` # Find file if it exists else use provided path as cased
if "#{base_path}/#{path}".length == constructed_path.length if constructed_path.downcase.strip == lowercase_full_path
constructed_path constructed_path
elsif accepted_parts.positive?
"#{constructed_path}/#{split_path[accepted_parts..].join('/')}"
else else
"#{base_path}/#{path.downcase}" "#{base_path}/#{path}" # File doesn't exist, case doesn't matter.
end end
end end
@@ -176,7 +183,8 @@ class W3DHub
# Wine present? # Wine present?
if W3DHub.unix? if W3DHub.unix?
wine_present = W3DHub.command("which #{Store.settings[:wine_command]}") wine_present = W3DHub.command("which #{Store.settings[:wine_command]}")
fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed. Will be unable to create prefixes or launch games.") unless wine_present fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed.\n\n"\
"Will be unable to launch game.\n\nCheck wine options in launcher's settings.") unless wine_present
end end
end end
@@ -732,34 +740,51 @@ class W3DHub
temp_file_path = normalize_path(manifest_file.name, temp_path) temp_file_path = normalize_path(manifest_file.name, temp_path)
logger.info(LOG_TAG) { " Loading #{temp_file_path}.patch..." } logger.info(LOG_TAG) { " Loading #{temp_file_path}.patch..." }
patch_mix = W3DHub::Mixer::Reader.new(file_path: "#{temp_file_path}.patch", ignore_crc_mismatches: false) patch_mix = W3DHub::WWMix.new(path: "#{temp_file_path}.patch")
patch_info = JSON.parse(patch_mix.package.files.find { |f| f.name.casecmp?(".w3dhub.patch") || f.name.casecmp?(".bhppatch") }.data, symbolize_names: true) unless patch_mix.load
raise patch_mix.error_reason
end
patch_entry = patch_mix.entries.find { |e| e.name.casecmp?(".w3dhub.patch") || e.name.casecmp?(".bhppatch") }
patch_entry.read
patch_info = JSON.parse(patch_entry.blob, symbolize_names: true)
logger.info(LOG_TAG) { " Loading #{file_path}..." } logger.info(LOG_TAG) { " Loading #{file_path}..." }
target_mix = W3DHub::Mixer::Reader.new(file_path: "#{file_path}", ignore_crc_mismatches: false) target_mix = W3DHub::WWMix.new(path: "#{file_path}")
unless target_mix.load
raise target_mix.error_reason
end
logger.info(LOG_TAG) { " Removing files..." } if patch_info[:removedFiles].size.positive? logger.info(LOG_TAG) { " Removing files..." } if patch_info[:removedFiles].size.positive?
patch_info[:removedFiles].each do |file| patch_info[:removedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" } logger.debug(LOG_TAG) { " #{file}" }
target_mix.package.files.delete_if { |f| f.name.casecmp?(file) } target_mix.entries.delete_if { |e| e.name.casecmp?(file) }
end end
logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive? logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive?
patch_info[:updatedFiles].each do |file| patch_info[:updatedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" } logger.debug(LOG_TAG) { " #{file}" }
patch = patch_mix.package.files.find { |f| f.name.casecmp?(file) } patch = patch_mix.entries.find { |e| e.name.casecmp?(file) }
target = target_mix.package.files.find { |f| f.name.casecmp?(file) } target = target_mix.entries.find { |e| e.name.casecmp?(file) }
if target if target
target_mix.package.files[target_mix.package.files.index(target)] = patch target_mix.entries[target_mix.entries.index(target)] = patch
else else
target_mix.package.files << patch target_mix.entries << patch
end end
end end
logger.info(LOG_TAG) { " Writing updated #{file_path}..." } if patch_info[:updatedFiles].size.positive? logger.info(LOG_TAG) { " Writing updated #{file_path}..." } if patch_info[:updatedFiles].size.positive?
W3DHub::Mixer::Writer.new(file_path: "#{file_path}", package: target_mix.package, memory_buffer: true, encrypted: target_mix.encrypted?) 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) FileUtils.remove_dir(temp_path)

View File

@@ -53,4 +53,4 @@ class W3DHub
end end
end end
end end
end end

View File

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

View File

@@ -50,54 +50,16 @@ class W3DHub
end end
# Download a W3D Hub package # Download a W3D Hub package
# TODO: More work needed to make this work reliably def self.async_fetch_package(package, block)
def self._async_fetch_package(package, block)
path = package_path(package.category, package.subcategory, package.name, package.version)
headers = Api::FORM_ENCODED_HEADERS
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 = Api::FORM_ENCODED_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("/apis/launcher/1/get-package", headers, body)
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)
end
response.status == 200
ensure
file&.close
end
# Download a W3D Hub package
def self.fetch_package(package, block)
endpoint_download_url = package.download_url || "#{Api::W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package" endpoint_download_url = package.download_url || "#{Api::W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package"
if package.download_url if package.download_url
uri_path = package.download_url.split("/").last uri_path = package.download_url.split("/").last
endpoint_download_url = package.download_url.sub(uri_path, URI.encode_uri_component(uri_path)) endpoint_download_url = package.download_url.sub(uri_path, URI.encode_uri_component(uri_path))
end end
path = package_path(package.category, package.subcategory, package.name, package.version) path = package_path(package.category, package.subcategory, package.name, package.version)
headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT } headers = [["content-type", "application/x-www-form-urlencoded"], ["user-agent", Api::USER_AGENT]]
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account && !package.download_url headers << ["authorization", "Bearer #{Store.account.access_token}"] if Store.account && !package.download_url
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}" body = URI.encode_www_form("data": JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version }))
start_from_bytes = package.custom_partially_valid_at_bytes start_from_bytes = package.custom_partially_valid_at_bytes
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" } logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
@@ -107,52 +69,62 @@ class W3DHub
file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb") file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb")
if start_from_bytes.positive? if start_from_bytes.positive?
headers["Range"] = "bytes=#{start_from_bytes}-" headers << ["range", "bytes=#{start_from_bytes}-"]
file.pos = start_from_bytes file.pos = start_from_bytes
end end
streamer = lambda do |chunk, remaining_bytes, total_bytes| result = false
file.write(chunk) Sync do
uri = URI(endpoint_download_url)
block.call(chunk, remaining_bytes, total_bytes) response = W3DHub::Api.provision_http_client(uri.origin).send((package.download_url ? :get : :post), uri.request_uri, headers, body)
end if response.success?
total_bytes = package.size
# Create a new connection due to some weirdness somewhere in Excon response.each do |chunk|
response = Excon.send( file.write(chunk)
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 block.call(chunk, total_bytes - file.pos, total_bytes)
return true end
else
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) { " 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}" } logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false 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 end
rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" timed out after: #{W3DHub::Api::API_TIMEOUT} seconds" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false result
rescue Excon::Error => e end
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" errored:" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false # Download a W3D Hub package
ensure def self.fetch_package(package, block)
file&.close async_fetch_package(package, block)
end end
def self.acquire_net_lock(key) def self.acquire_net_lock(key)

View File

@@ -140,18 +140,16 @@ class W3DHub
end end
status.zero? status.zero?
else elsif block
if block IO.popen(command, "r") do |io|
IO.popen(command, "r") do |io| io.each_line do |line|
io.each_line do |line| block&.call(line)
block&.call(line)
end
end end
$CHILD_STATUS.success?
else
system(command)
end end
$CHILD_STATUS.success?
else
system(command)
end end
end end
@@ -159,23 +157,26 @@ class W3DHub
File.expand_path("~") File.expand_path("~")
end end
def self.ask_file(title: "Open File", filter: "*game*.exe") def self.ask_file(title: "Open File", filter: "*game*.exe", filters: [])
filters << filter if filters.empty?
if W3DHub.unix? if W3DHub.unix?
# search for command # search for command
cmds = %w{ zenity matedialog qarma kdialog } cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd| command = cmds.find do |cmd|
cmd if system("which #{cmd}") cmd if system("which #{cmd}")
end end
path = case File.basename(command) path = case File.basename(command)
when "zenity", "matedialog", "qarma" when "zenity", "matedialog", "qarma"
`#{command} --file-selection --title "#{title}" --file-filter "#{filter}"` options = filters.map { |s| format("--file-filter=\"%s\"", s) }.join(" ")
when "kdialog" `#{command} --file-selection --title \"#{title}\" #{options}`
`#{command} --title "#{title}" --getopenfilename . "#{filter}"` when "kdialog"
else `#{command} --title "#{title}" --getopenfilename . "#{filters.join(" ")}"`
raise "No known command found for system file selection dialog!" else
end raise "No known command found for system file selection dialog!"
end
path.strip path.strip
else else
@@ -189,20 +190,20 @@ class W3DHub
def self.ask_folder(title: "Open Folder") def self.ask_folder(title: "Open Folder")
if W3DHub.unix? if W3DHub.unix?
# search for command # search for command
cmds = %w{ zenity matedialog qarma kdialog } cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd| command = cmds.find do |cmd|
cmd if system("which #{cmd}") cmd if system("which #{cmd}")
end end
path = case File.basename(command) path = case File.basename(command)
when "zenity", "matedialog", "qarma" when "zenity", "matedialog", "qarma"
`#{command} --file-selection --directory --title "#{title}"` `#{command} --file-selection --directory --title "#{title}"`
when "kdialog" when "kdialog"
`#{command} --title "#{title}" --getexistingdirectory #{Dir.home}"` `#{command} --title "#{title}" --getexistingdirectory #{Dir.home}"`
else else
raise "No known command found for system file selection dialog!" raise "No known command found for system file selection dialog!"
end end
path.strip path.strip
else else

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,386 +0,0 @@
require "digest"
require "stringio"
class W3DHub
# https://github.com/TheUnstoppable/MixLibrary used for reference
class Mixer
DEFAULT_BUFFER_SIZE = 32_000_000
MIX1_HEADER = 0x3158494D
MIX2_HEADER = 0x3258494D
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
@encrypted = false
# Valid header
if (mime = read_i32) && (mime == MIX1_HEADER || mime == MIX2_HEADER)
@encrypted = mime == MIX2_HEADER
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: Expected \"#{MIX1_HEADER}\" or \"#{MIX2_HEADER}\", got \"0x#{mime.to_s(16).upcase}\"\n(#{file_path})"
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
def encrypted?
@encrypted
end
end
class Writer
attr_reader :package
def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE, encrypted: false)
@package = package
@buffer = MemoryBuffer.new(file_path: file_path, mode: :write, buffer_size: buffer_size)
@buffer.pos = 0
@encrypted = encrypted
@buffer.write(encrypted? ? "MIX2" : "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
def encrypted?
@encrypted
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

@@ -141,6 +141,8 @@ class W3DHub
stack(width: 1.0, fill: true, scroll: true, margin_top: 32) do stack(width: 1.0, fill: true, scroll: true, margin_top: 32) do
if Store.application_manager.installed?(game.id, channel.id) if Store.application_manager.installed?(game.id, channel.id)
para "v#{Store.application_manager.installed?(game.id, channel.id)[:installed_version]}"
Hash.new.tap { |hash| Hash.new.tap { |hash|
# hash[I18n.t(:"games.game_settings")] = { icon: "gear", block: proc { Store.application_manager.settings(game.id, channel.id) } } # hash[I18n.t(:"games.game_settings")] = { icon: "gear", block: proc { Store.application_manager.settings(game.id, channel.id) } }
# hash[I18n.t(:"games.wine_configuration")] = { icon: "gear", block: proc { Store.application_manager.wine_configuration(game.id, channel.id) } } if W3DHub.unix? # hash[I18n.t(:"games.wine_configuration")] = { icon: "gear", block: proc { Store.application_manager.wine_configuration(game.id, channel.id) } } if W3DHub.unix?
@@ -276,7 +278,7 @@ class W3DHub
end end
# Game Events # Game Events
@game_events_container = flow(width: 1.0, height: 128, padding: 8, visible: false) do @game_events_container = stack(width: 1.0, height: 128, padding: 8, scroll: true, visible: false) do
end end
# Game News # Game News
@@ -367,7 +369,7 @@ class W3DHub
flow(width: 1.0, height: 28, padding: 8) do flow(width: 1.0, height: 28, padding: 8) do
para "Favorite", fill: true para "Favorite", fill: true
toggle_button checked: Store.application_manager.favorite?(game.id), height: 18, padding_top: 3, padding_right: 3, padding_bottom: 3, padding_left: 3 do |btn| toggle_button checked: Store.application_manager.favorite?(game.id), height: 18, padding_top: 3, padding_right: 3, padding_bottom: 3, padding_left: 3 do |btn|
Store.application_manager.favorive(game.id, btn.value) Store.application_manager.favorite(game.id, btn.value)
Store.settings.save_settings Store.settings.save_settings
populate_games_list populate_games_list
@@ -511,15 +513,15 @@ class W3DHub
@game_events_container.show unless events.empty? @game_events_container.show unless events.empty?
@game_events_container.hide if events.empty? @game_events_container.hide if events.empty?
@game_events_container.clear do return unless (event = events.flatten.first)
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
background 0x44_000000
title event.title, width: 1.0, text_align: :center @game_events_container.clear do
title event.start_time.strftime("%A"), width: 1.0, text_align: :center stack(width: 1.0, fill: true, margin_left: 8, margin_right: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
caption event.start_time.strftime("%B %e, %Y %l:%M %p"), width: 1.0, text_align: :center background 0x44_000000
end
title event.title, width: 1.0, text_align: :center
title event.start_time.strftime("%A"), width: 1.0, text_align: :center
caption event.start_time.strftime("%B %e, %Y %l:%M %p"), width: 1.0, text_align: :center
end end
end end
end end
@@ -566,4 +568,4 @@ class W3DHub
end end
end end
end end
end end

View File

@@ -3,7 +3,7 @@ class W3DHub
class ServerBrowser < Page class ServerBrowser < Page
def setup def setup
@server_locked_icons = {} @server_locked_icons = {}
@refresh_server_list = false @refresh_server_list_at_ms = nil
refresh_server = false refresh_server = false
@selected_server ||= nil @selected_server ||= nil
@@ -138,8 +138,8 @@ class W3DHub
def update def update
super super
if @refresh_server_list && Gosu.milliseconds >= @refresh_server_list if @refresh_server_list_at_ms && Gosu.milliseconds >= @refresh_server_list_at_ms
@refresh_server_list = nil @refresh_server_list_at_ms = nil
# populate_server_list # populate_server_list
reorder_server_list reorder_server_list
@@ -213,25 +213,13 @@ class W3DHub
server.ping == W3DHub::Api::ServerListServer::NO_OR_BAD_PING ? "Ping failed" : "Ping #{server.ping}ms" server.ping == W3DHub::Api::ServerListServer::NO_OR_BAD_PING ? "Ping failed" : "Ping #{server.ping}ms"
end end
def find_element_by_tag(container, tag, list = [])
return unless container
container.children.each do |child|
list << child if child.style.tag == tag
find_element_by_tag(child, tag, list) if child.is_a?(CyberarmEngine::Element::Container)
end
return list.first
end
def refresh_server_list(server, mode = :update) # :remove, :refresh_all def refresh_server_list(server, mode = :update) # :remove, :refresh_all
if mode == :refresh_all if mode == :refresh_all
populate_server_list populate_server_list
return return
end end
@refresh_server_list = Gosu.milliseconds + 3_000 @refresh_server_list_at_ms = Gosu.milliseconds + 3_000
@refresh_server = server if @selected_server&.id == server.id @refresh_server = server if @selected_server&.id == server.id
server_container = find_element_by_tag(@server_list_container, server.id) server_container = find_element_by_tag(@server_list_container, server.id)
@@ -333,7 +321,8 @@ class W3DHub
para server&.status&.name, tag: :server_name, font: BOLD_FONT, text_wrap: :none para server&.status&.name, tag: :server_name, font: BOLD_FONT, text_wrap: :none
flow(width: 1.0, height: 1.0) do flow(width: 1.0, height: 1.0) do
para Store.application_manager.channel_name(server.game, server.channel).to_s, width: 172, margin_right: 8, tag: :server_channel para server.version, margin_right: 8, tag: :server_version
para Store.application_manager.channel_name(server.game, server.channel).to_s, width: 148, margin_right: 8, tag: :server_channel
para server.region, tag: :server_region para server.region, tag: :server_region
end end
end end
@@ -389,11 +378,12 @@ class W3DHub
flow(width: 1.0, height: 46, margin_top: 16, margin_bottom: 16) do flow(width: 1.0, height: 46, margin_top: 16, margin_bottom: 16) do
game_installed = Store.application_manager.installed?(server.game, server.channel) game_installed = Store.application_manager.installed?(server.game, server.channel)
game_updatable = Store.application_manager.updateable?(server.game, server.channel) game_updatable = Store.application_manager.updateable?(server.game, server.channel)
matching_version = (game_installed && game_installed[:installed_version] == server.version) || server.version == Api::ServerListServer::NO_OR_DEFAULT_VERSION
channel = Store.application_manager.channel(server.game, server.channel) channel = Store.application_manager.channel(server.game, server.channel)
style = ((channel && channel.user_level.downcase.strip == "public") || server.channel == "release") ? {} : TESTING_BUTTON style = ((channel && channel.user_level.downcase.strip == "public") || server.channel == "release") ? {} : TESTING_BUTTON
flow(fill: true) flow(fill: true)
button "<b>#{I18n.t(:"server_browser.join_server")}</b>", enabled: (game_installed && !game_updatable), **style do button "<b>#{I18n.t(:"server_browser.join_server")}</b>", enabled: (game_installed && !game_updatable && matching_version), **style do
# Check for nickname # Check for nickname
# prompt for nickname # prompt for nickname
# !abort unless nickname set # !abort unless nickname set

View File

@@ -7,54 +7,88 @@ class W3DHub
background 0xaa_252525 background 0xaa_252525
stack(width: 1.0, fill: true, max_width: 720, h_align: :center, scroll: true) do stack(width: 1.0, fill: true, max_width: 720, h_align: :center, scroll: true) do
stack(width: 1.0, height: 112) do tagline "Launcher Language"
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
@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
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 end
caption "Package Cache Directory", margin_left: 16, margin_top: 16
stack(width: 1.0, height: 200, margin_top: 16) do flow(width: 1.0, margin_left: 16) do
tagline "Launcher Directories" @package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
caption "Applications Install Directory", margin_left: 16 button "Browse...", width: 128, tip: "Browse for package cache directory" do
flow(width: 1.0, fill: true, margin_left: 16) do path = W3DHub.ask_folder
@app_install_dir_input = edit_line Store.settings[:app_install_dir], fill: true @package_cache_dir_input.value = path unless path.empty?
button "Browse...", width: 128, tip: "Browse for applications install directory" do
path = W3DHub.ask_folder
@app_install_dir_input.value = path unless path.empty?
end
end
caption "Package Cache Directory", margin_left: 16, margin_top: 16
flow(width: 1.0, fill: true, margin_left: 16) do
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
button "Browse...", width: 128, tip: "Browse for package cache directory" do
path = W3DHub.ask_folder
@package_cache_dir_input.value = path unless path.empty?
end
end end
end end
if W3DHub.unix? if W3DHub.unix?
stack(width: 1.0, height: 224, margin_top: 16) do tagline "Wine - Windows compatibility layer", margin_top: 16
tagline "Wine - Windows compatibility layer" caption "Wine Command", margin_left: 16
caption "Wine Command", margin_left: 16 flow(width: 1.0, margin_left: 16) do
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0, margin_left: 16 @wine_command_input = edit_line Store.settings[:wine_command], fill: true
para "Command to use to for Windows compatiblity layer.", margin_left: 16 button "Browse...", width: 128, tip: "Browse for wine executable" do
path = W3DHub.ask_file(filters: %w[wine proton])
caption "Wine Prefix", margin_left: 16, margin_top: 16 @wine_command_input.value = path unless path.empty?
flow(width: 1.0, height: 48, margin_left: 16) do
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix], enabled: false
para "Whether each game gets its own prefix. Uses global/default prefix by default."
end end
end end
para "Command to use to for Windows compatiblity layer.", margin_left: 16
caption "Wine Prefix", margin_left: 16, margin_top: 16
flow(width: 1.0, margin_left: 16) do
@wine_prefix_input = edit_line Store.settings[:wine_prefix], fill: true
button "Browse...", width: 128, tip: "Browse for wine prefix directory" do
path = W3DHub.ask_folder
@wine_prefix_input.value = path unless path.empty?
end
end
para "Leave empty to use default global prefix.", margin_left: 16
link "Wiki: Getting Started With Wine", tip: "https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine", margin_top: 16, margin_left: 16, border_color_bottom: 0xff_777777 do
W3DHub.url("https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine")
end
# TODO: support winetricks stuff
# tagline "Winetricks", margin_top: 16
# caption "Winetricks Command", margin_left: 16
# flow(width: 1.0, margin_left: 16) do
# @winetricks_command_input = edit_line Store.settings[:winetricks_command], fill: true, enabled: false
# button "Browse...", width: 128, tip: "Browse for winetricks executable", enabled: false do
# path = W3DHub.ask_file(filters: %w[winetricks protontricks])
# @winetricks_command_input.value = path unless path.empty?
# end
# end
# caption "Fixups", margin_left: 16, margin_top: 16
# button "Install d3dcompiler_47", margin_left: 16, enabled: false
# para "Fixes games instantly crashing at startup due to not being able to compile shaders.", margin_left: 16
# button "Install DXVK", margin_left: 16, margin_top: 16, enabled: false
# para "Use Vulkan-based DirectX translation layers.", margin_left: 16
# para "WARNING: Games will stop working if your hardware does not support Vulkan!", margin_left: 16
end end
end end
stack(width: 128, max_height: 256, h_align: :center, margin_top: 16, fill: true) do flow(width: 256, height: 64, h_align: :center, margin_top: 16) do
button "Save", width: 1.0 do button "Save", width: 1.0 do
save_settings! save_settings!
end end
flow(fill: true)
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 end
end end
@@ -98,7 +132,9 @@ class W3DHub
Store.settings[:package_cache_dir] = @package_cache_dir_input.value Store.settings[:package_cache_dir] = @package_cache_dir_input.value
Store.settings[:wine_command] = @wine_command_input.value Store.settings[:wine_command] = @wine_command_input.value
Store.settings[:wine_prefix] = @wine_prefix_toggle.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 Store.settings.save_settings

View File

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

View File

@@ -25,7 +25,8 @@ class W3DHub
@task_index = 0 @task_index = 0
stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR, background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR,
background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do
stack(width: 1.0, fill: true) do stack(width: 1.0, fill: true) do
end end
@@ -41,7 +42,8 @@ class W3DHub
end end
def draw def draw
Gosu.draw_circle(window.width / 2, window.height / 2, @w3dhub_logo.width * (0.6 + Math.cos(Gosu.milliseconds / 1000.0 * Math::PI).abs * 0.05), 128, 0xaa_353535, 32) Gosu.draw_circle(window.width / 2, window.height / 2,
@w3dhub_logo.width * (0.6 + Math.cos(Gosu.milliseconds / 1000.0 * Math::PI).abs * 0.05), 128, 0xaa_353535, 32)
@w3dhub_logo.draw_rot(window.width / 2, window.height / 2, 32) @w3dhub_logo.draw_rot(window.width / 2, window.height / 2, 32)
super super
@@ -147,28 +149,26 @@ class W3DHub
} }
@status_label.value = "Checking uplink..." @status_label.value = "Checking uplink..."
domains.each do |key, value| domains.each do |key, _value|
begin Resolv.getaddress(key.to_s)
Resolv.getaddress(key.to_s) rescue StandardError => e
rescue => e logger.error(LOG_TAG) { "Failed to resolve hostname: #{key}" }
logger.error(LOG_TAG) {"Failed to resolve hostname: #{key.to_s}"} logger.error(LOG_TAG) { e }
logger.error(LOG_TAG) {e}
push_state( push_state(
ConfirmDialog, ConfirmDialog,
title: "DNS Resolution Failure", title: "DNS Resolution Failure",
message: "Failed to resolve: #{key.to_s}\n\nTry disabling VPN or proxy if in use.\n\n\nContinue offline?", message: "Failed to resolve: #{key}\n\nTry disabling VPN or proxy if in use.\n\n\nContinue offline?",
cancel_callback: ->() { window.close }, cancel_callback: -> { window.close },
accept_callback: ->() { accept_callback: lambda {
@offline_mode = true @offline_mode = true
Store.offline_mode = true Store.offline_mode = true
@tasks[:connectivity_check][:complete] = true @tasks[:connectivity_check][:complete] = true
} }
) )
# Prevent task from being marked as completed # Prevent task from being marked as completed
return false return false
end
end end
@tasks[:connectivity_check][:complete] = true @tasks[:connectivity_check][:complete] = true
@@ -187,7 +187,9 @@ class W3DHub
@tasks[:service_status][:complete] = true @tasks[:service_status][:complete] = true
else else
BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = I18n.t(:"boot.w3dhub_service_is_down") }) BackgroundWorker.foreground_job(-> {}, lambda { |_|
@status_label.value = I18n.t(:"boot.w3dhub_service_is_down")
})
@tasks[:service_status][:complete] = true @tasks[:service_status][:complete] = true
@offline_mode = true @offline_mode = true
@@ -199,7 +201,7 @@ class W3DHub
def launcher_updater def launcher_updater
@status_label.value = "Checking for Launcher updates..." # I18n.t(:"boot.checking_for_updates") @status_label.value = "Checking for Launcher updates..." # I18n.t(:"boot.checking_for_updates")
Api.on_thread(:fetch, "https://api.github.com/repos/Inq8/CAmod/releases/latest") do |response| Api.on_thread(:fetch, "https://api.github.com/repos/cyberarm/w3d_hub_linux_launcher/releases/latest") do |response|
if response.status == 200 if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true) hash = JSON.parse(response.body, symbolize_names: true)
available_version = hash[:tag_name].downcase.sub("v", "") available_version = hash[:tag_name].downcase.sub("v", "")
@@ -232,7 +234,7 @@ class W3DHub
@tasks[:applications][:complete] = true @tasks[:applications][:complete] = true
else else
# FIXME: Failed to retreive! # FIXME: Failed to retreive!
BackgroundWorker.foreground_job(-> {}, ->(_){ @status_label.value = "FAILED TO RETREIVE APPS LIST" }) BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = "FAILED TO RETREIVE APPS LIST" })
@offline_mode = true @offline_mode = true
Store.offline_mode = true Store.offline_mode = true
@@ -246,6 +248,7 @@ class W3DHub
@status_label.value = "Retrieving application icons, this might take a moment..." # I18n.t(:"boot.checking_for_updates") @status_label.value = "Retrieving application icons, this might take a moment..." # I18n.t(:"boot.checking_for_updates")
packages = [] packages = []
failure = false
Store.applications.games.each do |app| Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" } packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" }
end end
@@ -261,21 +264,32 @@ class W3DHub
regenerate = false regenerate = false
broken_or_out_dated_icon = Digest::SHA256.new.hexdigest(File.binread(path)).upcase != package.checksum.upcase if File.exist?(path) if File.exist?(path)
broken_or_out_dated_icon = Digest::SHA256.new.hexdigest(File.binread(path)).upcase != package.checksum.upcase
end
if File.exist?(path) && !broken_or_out_dated_icon if File.exist?(path) && !broken_or_out_dated_icon
regenerate = !File.exist?(generated_icon_path) regenerate = !File.exist?(generated_icon_path)
else else
Cache.fetch_package(package, proc {}) begin
regenerate = true Cache.fetch_package(package, proc {})
regenerate = true
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 end
if regenerate next unless regenerate
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, ->(result) { result.save(result.images.max_by(&:width), generated_icon_path) })
end BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, lambda { |result|
result.save(result.images.max_by(&:width), generated_icon_path)
})
end end
@tasks[:app_icons][:complete] = true @tasks[:app_icons][:complete] = true unless failure
end end
end end
@@ -296,7 +310,8 @@ class W3DHub
package_details&.each do |package| package_details&.each do |package|
next if package.error? next if package.error?
package_cache_path = Cache.package_path(package.category, package.subcategory, package.name, package.version) 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 missing_or_broken_image = File.exist?(package_cache_path) ? Digest::SHA256.new.hexdigest(File.binread(package_cache_path)).upcase != package.checksum.upcase : true
@@ -356,7 +371,7 @@ class W3DHub
"web-links": [], "web-links": [],
"extended-data": [ "extended-data": [
{ name: "colour", value: game[:colour] }, { name: "colour", value: game[:colour] },
{ name: "usesEngineCfg", value: game[:uses_engine_cfg] }, { name: "usesEngineCfg", value: game[:uses_engine_cfg] }
] ]
} }

View File

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

View File

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

75
lib/websocket_client.rb Normal file
View File

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

View File

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

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.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -14,7 +14,10 @@ require "logger"
require "time" require "time"
require "base64" require "base64"
require "zip" require "zip"
require "excon" require "async"
require "async/http/endpoint"
require "async/websocket/client"
require "async/http/internet/instance"
class W3DHub class W3DHub
W3DHUB_DEBUG = ARGV.join.include?("--debug") W3DHUB_DEBUG = ARGV.join.include?("--debug")
@@ -32,7 +35,7 @@ class W3DHub
FileUtils.mkdir_p(CACHE_PATH) unless Dir.exist?(CACHE_PATH) FileUtils.mkdir_p(CACHE_PATH) unless Dir.exist?(CACHE_PATH)
FileUtils.mkdir_p(LOGS_PATH) unless Dir.exist?(LOGS_PATH) FileUtils.mkdir_p(LOGS_PATH) unless Dir.exist?(LOGS_PATH)
LOGGER = Logger.new("#{LOGS_PATH}/w3d_hub_linux_launcher.log", "daily") LOGGER = W3DHUB_DEBUG ? Logger.new(STDOUT) : Logger.new("#{LOGS_PATH}/w3d_hub_linux_launcher.log", "daily")
LOGGER.level = Logger::Severity::DEBUG # W3DHUB_DEBUG ? Logger::Severity::DEBUG : Logger::Severity::WARN LOGGER.level = Logger::Severity::DEBUG # W3DHUB_DEBUG ? Logger::Severity::DEBUG : Logger::Severity::WARN
LOG_TAG = "W3DHubLinuxLauncher" LOG_TAG = "W3DHubLinuxLauncher"
@@ -84,11 +87,10 @@ class W3DHub
BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff") BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff")
end end
require "i18n"
require "websocket-client-simple"
require "English" require "English"
require "sdl2" require "sdl2"
require_relative "lib/i18n"
I18n.load_path << Dir["#{W3DHub::GAME_ROOT_PATH}/locales/*.yml"] I18n.load_path << Dir["#{W3DHub::GAME_ROOT_PATH}/locales/*.yml"]
I18n.default_locale = :en I18n.default_locale = :en
@@ -102,11 +104,12 @@ require_relative "lib/store"
require_relative "lib/window" require_relative "lib/window"
require_relative "lib/cache" require_relative "lib/cache"
require_relative "lib/settings" require_relative "lib/settings"
require_relative "lib/mixer" require_relative "lib/ww_mix"
require_relative "lib/ico" require_relative "lib/ico"
require_relative "lib/multicast_server" require_relative "lib/broadcast_server"
require_relative "lib/hardware_survey" require_relative "lib/hardware_survey"
require_relative "lib/game_settings" require_relative "lib/game_settings"
require_relative "lib/websocket_client"
require_relative "lib/background_worker" require_relative "lib/background_worker"
require_relative "lib/application_manager" require_relative "lib/application_manager"
require_relative "lib/application_manager/manifest" require_relative "lib/application_manager/manifest"