15 Commits

Author SHA1 Message Date
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
17 changed files with 258 additions and 51 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:

13
Gemfile
View File

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

View File

@@ -3,27 +3,36 @@ GEM
specs: specs:
base64 (0.3.0) base64 (0.3.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
cri (2.15.12)
cyberarm_engine (0.24.5) cyberarm_engine (0.24.5)
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) event_emitter (0.2.6)
excon (1.3.0) excon (1.3.2)
logger logger
ffi (1.17.2-x64-mingw-ucrt) ffi (1.17.0)
ffi (1.17.2-x86_64-linux-gnu) ffi-win32-extensions (1.1.0)
ffi-win32-extensions (1.0.4) ffi (>= 1.15.5, <= 1.17.0)
ffi fiddle (1.1.8)
gosu (1.4.6) gosu (1.4.6)
i18n (1.14.7) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
ircparser (1.0.0) ircparser (1.0.0)
libui (0.1.2-x64-mingw) libui (0.2.0-x64-mingw-ucrt)
fiddle
logger (1.7.0) logger (1.7.0)
mutex_m (0.3.0) mutex_m (0.3.0)
rake (13.3.0) ocran (1.3.17)
rexml (3.4.2) fiddle (~> 1.0)
rubyzip (3.0.2) rake (13.3.1)
releasy (0.2.4)
bundler (>= 1.2.1)
cri (~> 2.15.0)
ocran (~> 1.3.0)
rake (>= 0.9.2.2)
rexml (3.4.4)
rubyzip (3.2.2)
sdl2-bindings (0.2.3) sdl2-bindings (0.2.3)
ffi (~> 1.15) ffi (~> 1.15)
websocket (1.2.11) websocket (1.2.11)
@@ -40,16 +49,19 @@ GEM
PLATFORMS PLATFORMS
x64-mingw-ucrt x64-mingw-ucrt
x86_64-linux
DEPENDENCIES DEPENDENCIES
base64 base64
bundler (~> 2.4.3)
cyberarm_engine cyberarm_engine
digest-crc digest-crc
excon excon
i18n i18n
ircparser ircparser
libui libui
ocran
rake
releasy
rexml rexml
rubyzip rubyzip
sdl2-bindings sdl2-bindings
@@ -58,4 +70,4 @@ DEPENDENCIES
win32-security win32-security
BUNDLED WITH BUNDLED WITH
2.6.8 2.4.22

View File

@@ -3,7 +3,7 @@ It runs natively on Linux! No mucking about trying to get .NET 4.6.1 or somethin
Only requires OpenGL, Ruby, and a few gems. Only requires OpenGL, Ruby, and a few gems.
## Installing ## Installing
* Install Ruby 3.0+, from your package manager. * Install Ruby 3.4+, from your package manager.
* Install Gosu's [dependencies](https://github.com/gosu/gosu/wiki/Getting-Started-on-Linux). * Install Gosu's [dependencies](https://github.com/gosu/gosu/wiki/Getting-Started-on-Linux).
* Install required gems: `bundle install` * Install required gems: `bundle install`

View File

@@ -91,7 +91,7 @@ class W3DHub
retry_interval: 1, retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
) )
rescue Excon::Errors::Timeout => e rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" } logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e) DummyResponse.new(e)
@@ -135,7 +135,7 @@ class W3DHub
retry_interval: 1, retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
) )
rescue Excon::Errors::Timeout => e rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" } logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e) DummyResponse.new(e)

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

@@ -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
# Handle edge case where a file with the same name is in a higher directory
next if File.file?(part) && part.downcase.strip != lowercase_full_path
constructed_path = part constructed_path = part
accepted_parts += 1
break if File.file?(constructed_path) 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

View File

@@ -133,10 +133,24 @@ class W3DHub
return true return true
else else
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" } logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response.status}" } logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
false return false
end end
rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" timed out after: #{W3DHub::Api::API_TIMEOUT} seconds" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false
rescue Excon::Error => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" errored:" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false
ensure ensure
file&.close file&.close
end end

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

@@ -51,10 +51,16 @@ class W3DHub
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)}", **DANGEROUS_BUTTON) do
# TODO.
end end
end end
end end

View File

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

View File

@@ -11,7 +11,7 @@ class W3DHub
@fraction = 0.0 @fraction = 0.0
@w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png") @w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png")
@tasks = { @tasks = {
# connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com? connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com?
# launcher_updater: { started: false, complete: false }, # launcher_updater: { started: false, complete: false },
server_list: { started: false, complete: false }, server_list: { started: false, complete: false },
refresh_user_token: { started: false, complete: false }, refresh_user_token: { started: false, complete: false },
@@ -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
@@ -139,6 +141,39 @@ class W3DHub
@tasks[:refresh_user_token][:complete] = true @tasks[:refresh_user_token][:complete] = true
end end
def connectivity_check
domains = {
"w3dhub-api.w3d.cyberarm.dev": false,
"s3.w3d.cyberarm.dev": false,
"secure.w3dhub.com": false
}
@status_label.value = "Checking uplink..."
domains.each do |key, _value|
Resolv.getaddress(key.to_s)
rescue StandardError => e
logger.error(LOG_TAG) { "Failed to resolve hostname: #{key}" }
logger.error(LOG_TAG) { e }
push_state(
ConfirmDialog,
title: "DNS Resolution Failure",
message: "Failed to resolve: #{key}\n\nTry disabling VPN or proxy if in use.\n\n\nContinue offline?",
cancel_callback: -> { window.close },
accept_callback: lambda {
@offline_mode = true
Store.offline_mode = true
@tasks[:connectivity_check][:complete] = true
}
)
# Prevent task from being marked as completed
return false
end
@tasks[:connectivity_check][:complete] = true
end
def service_status def service_status
Api.on_thread(:service_status) do |service_status| Api.on_thread(:service_status) do |service_status|
@service_status = service_status @service_status = service_status
@@ -152,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
@@ -197,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
@@ -211,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
@@ -226,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
begin
Cache.fetch_package(package, proc {}) Cache.fetch_package(package, proc {})
regenerate = true regenerate = true
end rescue Errno::EACCES => e
failure = true
if regenerate push_state(MessageDialog, title: "Fatal Error",
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, ->(result) { result.save(result.images.max_by(&:width), generated_icon_path) }) message: "Directory Permission Error (#{e.class}):\n#{e}.\n\nIs the required drive mounted?",
accept_callback: -> { window.close })
end end
end end
@tasks[:app_icons][:complete] = true next unless regenerate
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, lambda { |result|
result.save(result.images.max_by(&:width), generated_icon_path)
})
end
@tasks[:app_icons][:complete] = true unless failure
end end
end end
@@ -261,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
@@ -321,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

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

View File

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

View File

@@ -84,11 +84,11 @@ class W3DHub
BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff") BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff")
end end
require "i18n"
require "websocket-client-simple" require "websocket-client-simple"
require "English" require "English"
require "sdl2" require "sdl2"
require_relative "lib/i18n"
I18n.load_path << Dir["#{W3DHub::GAME_ROOT_PATH}/locales/*.yml"] I18n.load_path << Dir["#{W3DHub::GAME_ROOT_PATH}/locales/*.yml"]
I18n.default_locale = :en I18n.default_locale = :en