35 Commits

Author SHA1 Message Date
d630e5044e Make clear package cache button functional 2026-01-14 08:12:29 -06:00
632fc2c05c Bump version 2026-01-09 09:59:51 -06:00
bf8f440ec7 Update gems 2026-01-09 09:37:24 -06:00
633aa10d4a Fixed ServerBrowser differed recalcuate after server update not implemented correctly :D 2026-01-09 09:26:45 -06:00
51d6d981f1 Removed implementation of find_element_by_tag from ServerBrowser, is implemented in CyberarmEngine nowadays. 2026-01-09 09:10:36 -06:00
820da31fe2 Remove Excon ca bundle setting since we removed Excon 2026-01-09 09:02:41 -06:00
6281a44961 Fixed crash 2026-01-09 09:01:55 -06:00
90a1c47389 Fix typo avorive -> avorite, improve Play Now server selection to check server version if provided 2026-01-08 21:18:43 -06:00
782d0f1cb3 Replace websocket-client-simple gem with async-websocket 2026-01-08 21:17:00 -06:00
f2cd26dda3 Update readme 2026-01-07 18:17:23 -06:00
11e5b578a1 Added screenshot for README 2026-01-07 18:12:21 -06:00
7f7e0fab6a Replace usage of Mixer with WWMix reimplementation, fixes excessive memory usage by NOT eager loading MIX file data blobs 2026-01-07 18:05:02 -06:00
a0ff6ec812 Reimplemented MIX file reader/writer 2026-01-07 17:05:40 -06:00
840bc849d3 Migrated away from Excon and to async-http, fixes issues with ipv6 dns resolving but not reachable- and is the start towards more migration to async libs, websocket based server list updater is temporarily broken 2026-01-07 14:18:48 -06:00
752bd2b026 Show server version in server browser 2026-01-07 00:16:14 -06:00
8086ab59b9 Renamed multicast server to broadcast server 2026-01-07 00:15:54 -06:00
948fcfda9a Use correct repo url for self-updater (doesn't work yet, but its a step) 2026-01-06 23:26:56 -06:00
daceb5d56d Use application installed version 2026-01-06 23:26:56 -06:00
e6eae4117f Use server version 2026-01-06 23:26:56 -06:00
a8c74095fe Show launcher version in title bar 2026-01-06 23:26:56 -06:00
f608f45f02 Formatted boot, rescue from write error at start up in #app_icons 2025-12-30 17:27:45 -06:00
603328a51f Update gems, drop i18n gem and implement basic replacement 2025-12-11 16:23:39 -06:00
48297ad9cd Update README 2025-11-30 21:31:57 -06:00
39fbb9df38 Add FUNDING.yml 2025-11-29 11:40:04 -06:00
bc9a524a55 Handle edge case where Gosu.user_languages is empty (on alpine linux for example) 2025-11-29 11:31:36 -06:00
d92a8753d8 Bump version 2025-10-31 11:54:28 -05:00
b299593076 Fixed a couple edge cases with Task#normalize_path causing failures 2025-10-31 11:53:53 -05:00
ce10cdc658 Fix not properly saving access_token_expiry value 2025-10-25 21:23:55 -05:00
5a3f350015 Fixed edge case where Task#normalize_path wouldn't handle partial matches of correctly 2025-10-24 23:05:04 -05:00
d53299e904 Windows is too easy, or annoying. Fix Task#normalize_path on windows ignoring base_path" 2025-10-15 16:27:28 -05:00
d12d3ff6b8 Don't downcase file path unless we need too, update gems. 2025-10-10 13:30:37 -05:00
d67ffa14a3 Show error message at start up if we cannot resolve critical domains. Fixes #15 2025-10-08 15:12:22 -05:00
71047ce9e8 Ugg. 2025-10-08 14:16:33 -05:00
7da716dde4 Possibly fix failing to rescue from networking errors when fetching packages 2025-10-08 14:09:11 -05:00
3a72a2e094 Possibly fix failing to rescue from timeouts 2025-10-08 13:51:32 -05:00
28 changed files with 893 additions and 705 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:
push:
branches: [ master, test ]
branches: [master]
workflow_dispatch:
jobs:

17
Gemfile
View File

@@ -1,16 +1,15 @@
source "https://rubygems.org"
gem "base64"
gem "excon"
gem "async-http"
gem "async-websocket"
gem "cyberarm_engine"
gem "sdl2-bindings"
gem "libui", platforms: [:windows]
gem "digest-crc"
gem "i18n"
gem "ircparser"
gem "rexml"
gem "rubyzip"
gem "websocket-client-simple"
gem "win32-process", platforms: [:windows]
gem "win32-security", platforms: [:windows]
@@ -19,9 +18,9 @@ gem "win32-security", platforms: [:windows]
# 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: 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
# gem "bundler", "~>2.4.3"
# gem "rake"
# gem "ocran"
# gem "releasy"#, path: "../releasy"
# end
group :windows_packaging do
gem "bundler", "~>2.4.3"
gem "rake"
gem "ocran"
gem "releasy"#, path: "../releasy"
end

View File

@@ -1,37 +1,85 @@
GEM
remote: https://rubygems.org/
specs:
async (2.35.1)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-http (0.93.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)
concurrent-ruby (1.3.5)
cyberarm_engine (0.24.5)
console (1.34.2)
fiber-annotation
fiber-local (~> 1.1)
json
cri (2.15.12)
cyberarm_engine (0.25.0)
gosu (~> 1.1)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
event_emitter (0.2.6)
excon (1.3.0)
logger
ffi (1.17.2-x64-mingw-ucrt)
ffi (1.17.2-x86_64-linux-gnu)
ffi-win32-extensions (1.0.4)
ffi
ffi (1.17.0)
ffi-win32-extensions (1.1.0)
ffi (>= 1.15.5, <= 1.17.0)
fiber-annotation (0.2.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.1)
fiddle (1.1.8)
gosu (1.4.6)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-endpoint (0.16.0)
io-event (1.14.2)
io-stream (0.11.1)
ircparser (1.0.0)
libui (0.1.2-x64-mingw)
logger (1.7.0)
mutex_m (0.3.0)
rake (13.3.0)
rexml (3.4.2)
rubyzip (3.0.2)
json (2.18.0)
libui (0.2.0-x64-mingw-ucrt)
fiddle
metrics (0.15.0)
ocran (1.3.17)
fiddle (~> 1.0)
protocol-hpack (1.5.1)
protocol-http (0.58.0)
protocol-http1 (0.36.0)
protocol-http (~> 0.58)
protocol-http2 (0.23.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)
releasy (0.2.4)
bundler (>= 1.2.1)
cri (~> 2.15.0)
ocran (~> 1.3.0)
rake (>= 0.9.2.2)
rexml (3.4.4)
rubyzip (3.2.2)
sdl2-bindings (0.2.3)
ffi (~> 1.15)
websocket (1.2.11)
websocket-client-simple (0.9.0)
base64
event_emitter
mutex_m
websocket
traces (0.18.2)
win32-process (0.10.0)
ffi (>= 1.0.0)
win32-security (0.5.0)
@@ -40,22 +88,24 @@ GEM
PLATFORMS
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES
async-http
async-websocket
base64
bundler (~> 2.4.3)
cyberarm_engine
digest-crc
excon
i18n
ircparser
libui
ocran
rake
releasy
rexml
rubyzip
sdl2-bindings
websocket-client-simple
win32-process
win32-security
BUNDLED WITH
2.6.8
2.4.22

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ class W3DHub
class Api
class ServerListServer
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)
@data = hash
@@ -14,6 +15,7 @@ class W3DHub
@port = @data[:port]
@region = @data[:region]
@channel = @data[:channel] || "release"
@version = @data[:version] || NO_OR_DEFAULT_VERSION
@ping = NO_OR_BAD_PING
@status = Status.new(@data[:status])

View File

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

View File

@@ -249,9 +249,17 @@ class W3DHub
return nil unless app_data
found_server = Store.server_list.select do |server|
server.game == app_id && server.channel == channel && !server.status.password && server.status.player_count < server.status.max_players
end&.first
server_options = Store.server_list.select do |server|
server.game == app_id &&
server.channel == channel &&
!server.status.password &&
server.status.player_count < server.status.max_players
end
# try to find server with lowest ping and matching version
found_server = server_options.find { |server| server.version == app_data[:installed_version] }
# try to find server with lowest ping and undefined version
found_server ||= server_options.find { |server| server.version == Api::ServerListServer::NO_OR_DEFAULT_VERSION }
found_server ? found_server : nil
end
@@ -283,7 +291,7 @@ class W3DHub
end
end
def favorive(app_id, bool)
def favorite(app_id, bool)
Store.settings[:favorites] ||= {}
if bool

View File

@@ -114,26 +114,33 @@ class W3DHub
def normalize_path(path, base_path)
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
lowercase_full_path = "#{base_path}/#{path}".downcase.strip.freeze
accepted_parts = 0
split_path = path.split("/")
split_path.each do |segment|
Dir.glob("#{constructed_path}/*").each do |part|
next unless "#{constructed_path}/#{segment}".downcase == part.downcase
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
# Find file if it exists, otherwise downcase the `path` sans `base_path`
if "#{base_path}/#{path}".length == constructed_path.length
# Find file if it exists else use provided path as cased
if constructed_path.downcase.strip == lowercase_full_path
constructed_path
elsif accepted_parts.positive?
"#{constructed_path}/#{split_path[accepted_parts..].join('/')}"
else
"#{base_path}/#{path.downcase}"
"#{base_path}/#{path}" # File doesn't exist, case doesn't matter.
end
end
@@ -732,34 +739,51 @@ class W3DHub
temp_file_path = normalize_path(manifest_file.name, temp_path)
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_info = JSON.parse(patch_mix.package.files.find { |f| f.name.casecmp?(".w3dhub.patch") || f.name.casecmp?(".bhppatch") }.data, symbolize_names: true)
patch_mix = W3DHub::WWMix.new(path: "#{temp_file_path}.patch")
unless patch_mix.load
raise patch_mix.error_reason
end
patch_entry = patch_mix.entries.find { |e| e.name.casecmp?(".w3dhub.patch") || e.name.casecmp?(".bhppatch") }
patch_entry.read
patch_info = JSON.parse(patch_entry.blob, symbolize_names: true)
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?
patch_info[:removedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" }
target_mix.package.files.delete_if { |f| f.name.casecmp?(file) }
target_mix.entries.delete_if { |e| e.name.casecmp?(file) }
end
logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive?
patch_info[:updatedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" }
patch = patch_mix.package.files.find { |f| f.name.casecmp?(file) }
target = target_mix.package.files.find { |f| f.name.casecmp?(file) }
patch = patch_mix.entries.find { |e| e.name.casecmp?(file) }
target = target_mix.entries.find { |e| e.name.casecmp?(file) }
if target
target_mix.package.files[target_mix.package.files.index(target)] = patch
target_mix.entries[target_mix.entries.index(target)] = patch
else
target_mix.package.files << patch
target_mix.entries << patch
end
end
logger.info(LOG_TAG) { " Writing updated #{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)

View File

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

View File

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

View File

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

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
if Store.application_manager.installed?(game.id, channel.id)
para "v#{Store.application_manager.installed?(game.id, channel.id)[:installed_version]}"
Hash.new.tap { |hash|
# hash[I18n.t(:"games.game_settings")] = { icon: "gear", block: proc { Store.application_manager.settings(game.id, channel.id) } }
# hash[I18n.t(:"games.wine_configuration")] = { icon: "gear", block: proc { Store.application_manager.wine_configuration(game.id, channel.id) } } if W3DHub.unix?
@@ -367,7 +369,7 @@ class W3DHub
flow(width: 1.0, height: 28, padding: 8) do
para "Favorite", fill: true
toggle_button checked: Store.application_manager.favorite?(game.id), height: 18, padding_top: 3, padding_right: 3, padding_bottom: 3, padding_left: 3 do |btn|
Store.application_manager.favorive(game.id, btn.value)
Store.application_manager.favorite(game.id, btn.value)
Store.settings.save_settings
populate_games_list
@@ -566,4 +568,4 @@ class W3DHub
end
end
end
end
end

View File

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

View File

@@ -51,10 +51,18 @@ class W3DHub
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
save_settings!
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

View File

@@ -2,7 +2,7 @@ class W3DHub
class Settings
def self.defaults
{
language: Gosu.user_languages.first.split("_").first,
language: Gosu.user_languages.first&.split("_")&.first || "en",
app_install_dir: default_app_install_dir,
package_cache_dir: default_package_cache_dir,
parallel_downloads: 4,

View File

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

View File

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

View File

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

68
lib/websocket_client.rb Normal file
View File

@@ -0,0 +1,68 @@
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|
endpoint = Async::HTTP::Endpoint.parse(endpoint, alpn_protocols: Async::HTTP::Protocol::HTTP11.names)
Async::WebSocket::Client.connect(endpoint, headers: headers) do |connection|
@connection = connection
@events[:open]&.call
while message = connection.read
@events[:message].call(message)
end
# FIXME: Don't rescue for all ta errors?
rescue => error
@errored = true
@events[:error]&.call(error)
ensure
@events[:close]&.call unless @errored
@connection = nil
@errored = false
end
end
self
end
def on(event, &block)
raise "Event must be a symbol" unless event.is_a?(Symbol)
raise "Unknown event: #{event.inspect}" unless @events.keys.include?(event)
raise "No block given for #{event.inspect}" unless block_given?
@events[event] = block
end
def send(data, type: :text)
@connection&.write(data)
@connection&.flush
end
def close
@connection&.close
end
def open?
!closed?
end
def closed?
@connection&.closed?
end
end
end

View File

@@ -2,7 +2,7 @@ class W3DHub
class Window < CyberarmEngine::Window
def setup
self.show_stats_plotter = false
self.caption = I18n.t(:app_name)
self.caption = "#{I18n.t(:app_name)} v#{VERSION}"
Store[:server_list] = []
Store[:settings] = Settings.new

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