152 Commits

Author SHA1 Message Date
3e4b25f0d4 Progress towards background work replacement, work getting new NetworkManager and HttpClient up and running 2026-02-01 15:12:24 -06:00
ebc045019a More work on network handling refactor 2026-01-31 22:33:14 -06:00
79858a02ce Merge branch 'master' into development 2026-01-31 19:08:51 -06:00
68df923bea Bump version 2026-01-31 18:25:33 -06:00
ddbec8d72c Fixed patching not preserving encryption flag of target mix 2026-01-31 18:24:47 -06:00
70d4e0c40f WWMix: added support for replacing entries that are duplicates 2026-01-31 18:23:00 -06:00
f651143937 WIP API refactor 2026-01-31 14:07:08 -06:00
1425225eef Pivot away from Ractor (stil overly complicated to work with, even in Ruby 4) and towards 1 thread for http requests and 1 thread for application tasks, WIP refactor if Api to be non-blocking async- everything is broken atm 😁 2026-01-31 11:18:41 -06:00
f98d8c3394 Break API calls in work towards moving all blocking io to ractors, dropped BackgroundWorker class 2026-01-30 19:36:11 -06:00
6e79c4639d WIP Ractor Task stuff 2026-01-29 21:44:54 -06:00
4a8457e233 Initial work on reimplementing Task to be Ractor/sub-Process safe 2026-01-28 18:27:58 -06:00
46dece0479 Update gems 2026-01-28 14:02:47 -06:00
68af00bd2f Remove binding.irb call from Cache that was accidently left in... 2026-01-28 10:39:23 -06:00
208f2b8a39 Disable PingManager debug printing 2026-01-28 10:38:50 -06:00
b5d975761c Don't assume application executable is 'game.exe' 2026-01-28 10:38:22 -06:00
44483117d8 Initial work on process monitoring, minor refactoring 2026-01-21 10:45:07 -06:00
4146debc4c Initial work on PingManager 2026-01-20 17:53:25 -06:00
1f4185ada2 Tweak ping ratings for ServerBrowser 2026-01-20 15:38:18 -06:00
e2496d0a09 Fix up Play Now server selection logic, again 😁 2026-01-20 15:36:57 -06:00
d4e4697983 Initial work on UDP mode ICMP ping (https://keith.github.io/xcode-man-pages/icmp.4.html#Non-privileged_ICMP) 2026-01-20 09:19:16 -06:00
3e949cf863 Add some docs 2026-01-17 22:45:08 -06:00
027ba27b37 Consider player count in 'play now' server auto selection :D 2026-01-17 13:51:34 -06:00
adff1f9159 Fix play now auto-selected servers not sorted by ping 2026-01-17 13:32:51 -06:00
355a4503ea Fix Api.async_http logging empty urls 2026-01-15 08:38:35 -06:00
f30658ffc2 Corrected indention of WebsocketClient, use Async::HTTP::Client instead of Async::HTTP::Internet in order to provide ssl_context with a custom ca_file path in order to support tebako builds again 2026-01-14 19:06:58 -06:00
9e8f4e1c71 Update gems, disable windows packaging gems 2026-01-14 14:54:31 -06:00
b7e2e69af9 Improve missing wine error message, add link to wiki on settings page, fix crash when saving settings due to disabling winetricks options 2026-01-14 14:45:16 -06:00
3dbfd23b10 Limit game events to 1, hide currently unused winetricks bit of settings 2026-01-14 14:04:58 -06:00
d1d667056b Refresh and enable Welcome dialog if no settings.json is present 2026-01-14 12:12:27 -06:00
c881296ac8 Improve wine settings, fixup wineprefix usage 2026-01-14 10:58:55 -06:00
d630e5044e Make clear package cache button functional 2026-01-14 08:12:29 -06:00
632fc2c05c Bump version 2026-01-09 09:59:51 -06:00
bf8f440ec7 Update gems 2026-01-09 09:37:24 -06:00
633aa10d4a Fixed ServerBrowser differed recalcuate after server update not implemented correctly :D 2026-01-09 09:26:45 -06:00
51d6d981f1 Removed implementation of find_element_by_tag from ServerBrowser, is implemented in CyberarmEngine nowadays. 2026-01-09 09:10:36 -06:00
820da31fe2 Remove Excon ca bundle setting since we removed Excon 2026-01-09 09:02:41 -06:00
6281a44961 Fixed crash 2026-01-09 09:01:55 -06:00
90a1c47389 Fix typo avorive -> avorite, improve Play Now server selection to check server version if provided 2026-01-08 21:18:43 -06:00
782d0f1cb3 Replace websocket-client-simple gem with async-websocket 2026-01-08 21:17:00 -06:00
f2cd26dda3 Update readme 2026-01-07 18:17:23 -06:00
11e5b578a1 Added screenshot for README 2026-01-07 18:12:21 -06:00
7f7e0fab6a Replace usage of Mixer with WWMix reimplementation, fixes excessive memory usage by NOT eager loading MIX file data blobs 2026-01-07 18:05:02 -06:00
a0ff6ec812 Reimplemented MIX file reader/writer 2026-01-07 17:05:40 -06:00
840bc849d3 Migrated away from Excon and to async-http, fixes issues with ipv6 dns resolving but not reachable- and is the start towards more migration to async libs, websocket based server list updater is temporarily broken 2026-01-07 14:18:48 -06:00
752bd2b026 Show server version in server browser 2026-01-07 00:16:14 -06:00
8086ab59b9 Renamed multicast server to broadcast server 2026-01-07 00:15:54 -06:00
948fcfda9a Use correct repo url for self-updater (doesn't work yet, but its a step) 2026-01-06 23:26:56 -06:00
daceb5d56d Use application installed version 2026-01-06 23:26:56 -06:00
e6eae4117f Use server version 2026-01-06 23:26:56 -06:00
a8c74095fe Show launcher version in title bar 2026-01-06 23:26:56 -06:00
f608f45f02 Formatted boot, rescue from write error at start up in #app_icons 2025-12-30 17:27:45 -06:00
603328a51f Update gems, drop i18n gem and implement basic replacement 2025-12-11 16:23:39 -06:00
48297ad9cd Update README 2025-11-30 21:31:57 -06:00
39fbb9df38 Add FUNDING.yml 2025-11-29 11:40:04 -06:00
bc9a524a55 Handle edge case where Gosu.user_languages is empty (on alpine linux for example) 2025-11-29 11:31:36 -06:00
d92a8753d8 Bump version 2025-10-31 11:54:28 -05:00
b299593076 Fixed a couple edge cases with Task#normalize_path causing failures 2025-10-31 11:53:53 -05:00
ce10cdc658 Fix not properly saving access_token_expiry value 2025-10-25 21:23:55 -05:00
5a3f350015 Fixed edge case where Task#normalize_path wouldn't handle partial matches of correctly 2025-10-24 23:05:04 -05:00
d53299e904 Windows is too easy, or annoying. Fix Task#normalize_path on windows ignoring base_path" 2025-10-15 16:27:28 -05:00
d12d3ff6b8 Don't downcase file path unless we need too, update gems. 2025-10-10 13:30:37 -05:00
d67ffa14a3 Show error message at start up if we cannot resolve critical domains. Fixes #15 2025-10-08 15:12:22 -05:00
71047ce9e8 Ugg. 2025-10-08 14:16:33 -05:00
7da716dde4 Possibly fix failing to rescue from networking errors when fetching packages 2025-10-08 14:09:11 -05:00
3a72a2e094 Possibly fix failing to rescue from timeouts 2025-10-08 13:51:32 -05:00
3c565e6fee Bump version 2025-10-08 11:33:05 -05:00
2dc750a686 Task#normalize_path now takes the base path as an argument and attempts to find the case-insensitive file path to target path 2025-10-08 11:32:35 -05:00
ed119a4925 Fixed All Games section was failing to load app icons 2025-09-19 13:55:06 -05:00
e4d99aac00 Added functional support for developer multi join 2025-09-12 23:41:03 -05:00
e9b8638c27 Fixed (soft) crash when downloading package with a space in its name 2025-09-05 20:02:58 -05:00
4997cfabb0 Fixed not handling filename case for patches 2025-08-26 09:23:54 -05:00
The Unnamed Engineer
0c906464f0 case desensitize unzip 2025-08-26 08:55:27 -05:00
The Unnamed Engineer
5bafc77d97 Update task.rb
Modify all potentially case sensitive file operations to operate in a case-insensitive manner.
2025-08-26 08:55:27 -05:00
30aa44312d Fixed failing to download application manifests unless logged in by checking which source the application/channel orginated from, updated gems. 2025-08-26 08:51:08 -05:00
2031f589b7 Moved processed app icons to cache directory, removed app logos (banners) from media since we now download them and app backgrounds from the api 2025-08-04 22:09:00 -05:00
b909952790 Use fresh logos and backgrounds 2025-08-04 12:25:27 -05:00
6d651c7ad6 Download game logos and backgrounds from backend 2025-08-04 12:25:13 -05:00
60909b0963 Fixed W3DHub.ask_folder crashing on windows 2025-08-04 12:18:38 -05:00
48617b26da Minor post-merge refactor, mainly moved duplicated method ca_bundle_path into common.rb 2025-08-04 10:50:07 -05:00
ad2544a56b Merge branch 'The-Unnamed-Engineer-feature-buildBinaryPackage' 2025-08-03 17:10:06 -05:00
80c104772f Merge branch 'feature-buildBinaryPackage' of github.com:The-Unnamed-Engineer/w3d_hub_linux_launcher into The-Unnamed-Engineer-feature-buildBinaryPackage 2025-08-03 17:09:36 -05:00
09082c0c5d Whitespace 2025-08-03 17:09:10 -05:00
The Unnamed Engineer
27e5da9fd2 Merge branch 'cyberarm:master' into feature-buildBinaryPackage 2025-08-03 17:55:46 -04:00
0bb8ef5f19 Initial work launcher (self) updater 2025-06-25 19:45:23 -05:00
cc0910e68e Updated cyberarm_engine gem to fix edit_line's with prefilled text not visible 2025-06-24 13:56:50 -05:00
fd728fa945 Redid Settings page 2025-06-24 13:47:02 -05:00
ec6dfe8371 Bump version 2025-06-24 10:41:07 -05:00
49d501a8b0 Refactored API to support both backends and to re-enable logging in (on the primary backend) 2025-06-24 10:38:41 -05:00
The Unnamed Engineer
e239f9cd4d Patch IRC config to detect RHEL cert bundle 2025-06-11 06:21:11 -04:00
The Unnamed Engineer
b68d24deda Simply warn for unknown languages. 2025-06-10 15:02:23 -04:00
The Unnamed Engineer
1081832df0 Handle cases where image has not yet downloaded 2025-06-10 13:45:01 -04:00
The Unnamed Engineer
c3cee78265 Fix language error crash 2025-06-10 13:22:24 -04:00
The Unnamed Engineer
4d3163740a Update API to support RHEL cert bundle 2025-06-10 13:21:34 -04:00
The Unnamed Engineer
f1953c45e7 Initial support for binary packaging 2025-06-10 11:20:55 -04:00
685a1aa82c Bump version 2025-05-16 09:48:13 -05:00
9dfee9d1d3 Updated server browser to order servers by player count, then by ping. 2025-05-16 09:47:49 -05:00
1e0adc398c Add support for patching encrypted mixes 2025-05-16 09:39:05 -05:00
3485d5b61a Bump version 2025-04-26 19:46:17 -05:00
cb81a51bfe Fixed failing to fetch manifests properly 2025-04-26 19:45:58 -05:00
314201f238 Switch server list to alternate 2025-04-26 09:55:06 -05:00
pure_bliss
12721cbfbc Fixed newline on end of file 2025-04-26 09:53:50 -05:00
pure_bliss
5ef11fbee8 Not showing image in news if it failed to fetch 2025-04-26 09:53:50 -05:00
e73abce65e Bump version 2025-04-26 07:52:53 -05:00
9b1cb1bb95 Make Task check status of package download instead of assuming it succeeded 2025-04-26 07:52:25 -05:00
c9185e9859 Added download_url support to Package 2025-04-26 07:51:37 -05:00
e4a0d2a848 Fixed news parser a little to not fail if it gets an empty json hash 2025-04-26 07:51:13 -05:00
1401b80057 Fixed status of package downloader not checked, package downloader now supports download_url field from the new backend 2025-04-26 07:49:14 -05:00
cfae4ec3a5 Update to use new backend, package downloader will follow redirects 2025-04-26 07:18:36 -05:00
c344e6a522 Fixed crash at startup when there is no data cached for applications. Fixes #10 2025-04-26 07:16:22 -05:00
696c30aa63 Don't attempt to generate app icon if the package's details has an error 2025-04-23 23:34:39 -05:00
1818d8bec9 Fixed error preventing GSH client from working, moved server list starting task ahead of other tasks to ensure server list _can_ populate if other parts of the backend are dead 2025-04-23 22:03:16 -05:00
4af10a998e Update gems 2025-01-10 12:42:25 -06:00
6736abc277 Write data/paths.ini file 2024-09-11 16:54:21 -05:00
c9c5e18d70 Update theme: testing and dangerous buttons now have slight gradient 2024-04-04 15:07:40 -05:00
67c52c84a1 Removed hardcoded GSH endpoint from signalr server list updater, moved direct connect button to the right of the server list, added a slight gradient to buttons so their not totally flat. 2024-03-17 11:29:39 -05:00
80d1fa865c Fixed auto selected server not checking if full, fixed game icon on bar only showing update available icon for 'first' channel 2024-03-14 11:34:53 -05:00
a1810e3f2c Cache application data for offline use 2024-03-12 11:24:44 -05:00
75b9e3e14a Bump version 2024-03-12 10:43:17 -05:00
7fdb406588 Fix handling of extended-data in offline mode 2024-03-12 10:42:10 -05:00
0ab616f48b Bump version 2024-03-12 09:31:16 -05:00
e035b1ed58 Hide nonfunctional game modifications tab 2024-03-12 09:30:51 -05:00
3f7ec2fb5c Partially revert ask_file/folder on linux to explicitly use zenity/kdialog commands directly instead of using libui hack which leaves the file browser window open after returning 2024-03-12 09:21:52 -05:00
6d209c8942 Server and applications lists are now updated every 5 and 10 minutes respectively 2024-03-11 22:35:30 -05:00
f55924596d Fix some weird scoping issues with ServerListUpdater lambdas 2024-03-11 19:17:38 -05:00
d84c8321c5 Speed up API requests be using a persistent connection, increase news fetch buffer time to 30 seconds from 10 seconds. 2024-03-11 15:09:31 -05:00
38e0de76df Update community news every hour, added 10 second delay between fetch attempts to prevent making a bunch of unneeded requests 2024-03-11 14:16:33 -05:00
9bdca9eba1 Make game news expire after an hour and get refetched 2024-03-11 14:04:03 -05:00
02307f1789 Ducktape on libui for native file open dialogs 2024-03-11 13:45:10 -05:00
c1ca3ec80e Added libui to gems list to use in future for its open_folder dialogs, improvements to server list updater and server browser to correctly handle unregistering and registering servers 2024-03-11 13:09:05 -05:00
29c8667602 Improve error messaging for fetching packages 2024-03-11 13:06:50 -05:00
b594cdae96 Bump version 2024-03-05 13:23:21 -06:00
d350e51d0b Improvements to server browser 2024-03-05 12:50:51 -06:00
0cbe013a11 Use new menu and menu_item elements instead of custom extension, improved styling of list_box menus in game settings 2024-03-04 20:57:13 -06:00
5c806852a5 Store game colour and uses_engine_cfg to settings config, use wwconfig when game doesn't use engine.cfg 2024-03-04 18:17:32 -06:00
655fc14557 Handle user not having an avatar image 2024-03-04 17:34:55 -06:00
6772d4757f Bump version 2024-03-04 16:36:50 -06:00
3383cbd019 Make import button gooder, explicitly require excon http client 2024-03-04 16:35:52 -06:00
aceed86cb4 Fixed checkbox on favorites tab clipping buttom border 2024-03-02 12:14:53 -06:00
58daeffb14 Fixed renegade not importing from registry 2024-03-02 12:13:34 -06:00
2a4bb87e68 Bump version 2024-03-02 11:19:20 -06:00
fc643235b5 Fixed Task#verify_files, patching will now actually delete removed files now, some minor refactoring. 2024-03-02 10:56:58 -06:00
cd0db4e0fc Refactored Task#build_package_list to fix 'overdownloading' packages that then are replaced/no longer needed; FIXME: broke Repair task in the process. 2024-03-02 01:41:36 -06:00
84051103fc Improved avatar handling, fixed import/install button text sizes, fixed typo ERRNO-> Errno, ocra[n] packaging adjustments 2024-03-01 22:39:18 -06:00
f2dd844181 Tweak download manager 2024-03-01 20:25:49 -06:00
a512669a2d UX improvements to Server Browser 2024-02-29 16:36:06 -06:00
85d408fad7 More style updates 2024-02-29 13:54:30 -06:00
d4e81dd441 Server profiles can now be deleted from Asterisk and game_title is now correctly saved to server profile when first created. 2024-02-28 21:47:41 -06:00
0d1333ee4f Update gems 2024-02-28 20:44:32 -06:00
458a9e8832 Bump version 2024-02-28 19:28:29 -06:00
924f4c2b75 More UI tweaks, prettied up Boot state, Updated Welcome state (may be I'll use it someday? 😁) 2024-02-28 19:23:01 -06:00
0b9b519848 More styling changes 2024-02-28 18:14:41 -06:00
f9d401e713 Styling improvements 2024-02-28 10:52:29 -06:00
77 changed files with 4254 additions and 2053 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: cyberarm
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

47
.github/workflows/build-tebako.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Build Launcher Binary
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build-tebako:
runs-on: ubuntu-latest
strategy:
matrix:
architecture: [x64]
container:
image: ghcr.io/tamatebako/tebako-ubuntu-20.04:latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Install Gosu and native dependencies
run: |
apt-get update
apt-get install -y libsdl2-dev libgl1-mesa-dev libopenal-dev libgmp-dev libfontconfig1-dev libsndfile1-dev libmpg123-dev libpango1.0-dev libtool libssl-dev libffi-dev
- name: Update Bundler and lockfile
run: |
gem install bundler -v 2.4.22
bundle _2.4.22_ lock --update --bundler
- name: Build Tebako binary
run: |
tebako press -P -R 3.4.1 -m bundle -o w3d_hub_linux_launcher -r $PWD -e w3d_hub_linux_launcher.rb
- name: Prepare artifact directory
run: |
mkdir w3d-hub-launcher-x86_64
cp w3d_hub_linux_launcher w3d-hub-launcher-x86_64/
cp -r media w3d-hub-launcher-x86_64/
cp -r locales w3d-hub-launcher-x86_64/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: w3d-hub-launcher-x86_64
path: w3d-hub-launcher-x86_64

24
Gemfile
View File

@@ -1,18 +1,26 @@
source "https://rubygems.org"
gem "base64"
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: [:x64_mingw, :mingw]
gem "win32-security", platforms: [:x64_mingw, :mingw]
gem "win32-process", platforms: [:windows]
gem "win32-security", platforms: [:windows]
# group :windows_packaging do
# gem "rake"
# gem "releasy"
# end
# PACKAGING NOTES
# bundler 2.5.x doesn't seem to play nice with ocra[n]
# 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

View File

@@ -1,33 +1,77 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.2.0)
concurrent-ruby (1.2.3)
cyberarm_engine (0.24.1)
excon (~> 0.88)
async (2.36.0)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-http (0.94.2)
async (>= 2.10.2)
async-pool (~> 0.11)
io-endpoint (~> 0.14)
io-stream (~> 0.6)
metrics (~> 0.12)
protocol-http (~> 0.58)
protocol-http1 (~> 0.36)
protocol-http2 (~> 0.22)
protocol-url (~> 0.2)
traces (~> 0.10)
async-pool (0.11.1)
async (>= 2.0)
async-websocket (0.30.0)
async-http (~> 0.76)
protocol-http (~> 0.34)
protocol-rack (~> 0.7)
protocol-websocket (~> 0.17)
base64 (0.3.0)
console (1.34.2)
fiber-annotation
fiber-local (~> 1.1)
json
cyberarm_engine (0.25.1)
gosu (~> 1.1)
gosu_more_drawables (~> 0.3)
digest-crc (0.6.5)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
event_emitter (0.2.6)
excon (0.109.0)
ffi (1.16.3)
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)
gosu_more_drawables (0.3.1)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
io-endpoint (0.17.2)
io-event (1.14.2)
io-stream (0.11.1)
ircparser (1.0.0)
rake (13.1.0)
rexml (3.2.6)
rubyzip (2.3.2)
json (2.18.0)
libui (0.2.0-x64-mingw-ucrt)
fiddle
metrics (0.15.0)
protocol-hpack (1.5.1)
protocol-http (0.58.1)
protocol-http1 (0.37.0)
protocol-http (~> 0.58)
protocol-http2 (0.24.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.47)
protocol-rack (0.21.0)
io-stream (>= 0.10)
protocol-http (~> 0.58)
rack (>= 1.0)
protocol-url (0.4.0)
protocol-websocket (0.20.2)
protocol-http (~> 0.2)
rack (3.2.4)
rake (13.3.1)
rexml (3.4.4)
rubyzip (3.2.2)
sdl2-bindings (0.2.3)
ffi (~> 1.15)
websocket (1.2.10)
websocket-client-simple (0.8.0)
event_emitter
websocket
traces (0.18.2)
win32-process (0.10.0)
ffi (>= 1.0.0)
win32-security (0.5.0)
@@ -36,21 +80,21 @@ GEM
PLATFORMS
x64-mingw-ucrt
x64-mingw32
x86_64-linux
DEPENDENCIES
async-http
async-websocket
base64
cyberarm_engine
digest-crc
i18n
ircparser
libui
rexml
rubyzip
sdl2-bindings
websocket-client-simple
win32-process
win32-security
BUNDLED WITH
2.5.3
4.0.3

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

@@ -11,13 +11,13 @@ Releasy::Project.new do
version W3DHub::VERSION
executable "w3d_hub_linux_launcher.rb"
files ["lib/**/*.*", "locales/*", "media/**/**", "data/.gitkeep", "data/cache/.gitkeep"]
exclude_encoding # Applications that don't use advanced encoding (e.g. Japanese characters) can save build size with this.
files ["lib/**/*.*", "locales/*", "media/**/**", "data/.gitkeep", "data/cache/.gitkeep", "data/logs/.gitkeep"]
# exclude_encoding # Applications that don't use advanced encoding (e.g. Japanese characters) can save build size with this.
verbose
add_build :windows_folder do
icon "media/icons/app.ico"
executable_type :console # Assuming you don't want it to run with a console window.
executable_type :windows # :console # Assuming you don't want it to run with a console window.
add_package :exe # Windows self-extracting archive.
end
end

35
docs/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Documentation
## File Formats
Documentation and examples of file formats.
### [Legacy Manifest](file_formats/LEGACY_MANIFEST.md)
Blue Hell Productions `manifest.xml`. Still in use by W3D Hub.
### [Megafest](file_formats/MEGAFEST.md)
Prototype new thing `megafest.json`
### [MIX](file_formats/MIX.md)
Westwood archive format `<name>.mix`, `<name>.dat` and `<name>.pkg`
### [W3D Hub Patch](file_formats/W3D_HUB_PATCH.md)
Describes how to update a MIX archive from a package patch
## Protocols
### [LAN Discovery](protocols/LAN_DISCOVERY.md)
How launchers and other applications discovery each other.
### [Launcher Remote](protocols/LAUNCHER_REMOTE.md)
Remotely control and get progress reports from the launcher
### [LAN Package Share](protocols/LAN_PACKAGE_SHARE.md)
Enable launchers on the same network to discover each other and share packages over LAN.

View File

@@ -0,0 +1,73 @@
# Legacy Manifest
In use since Blue Hell Productions
> [!IMPORTANT]
> References to packages exclude `.zip`
Example `Full` manifest:
```xml
<BHP_Game_Manifest game="apb" version="0.9935.1.0" type="Full">
<Dependency name="msvc-2015.zip"/>
<Dependency name="directx-43.zip"/>
<File name="game.exe" checksum="C7AE972A2B9CF6EFCADE7919323F048F5CFAF2D40BD5A67D5A91F8D8A3759CA4" package="binaries"/>
<File name="MemoryManager.dll" checksum="B60A2B3F6A07D729EE5C2902CB9A6BEDAF55EEDB13F8514BAC0AD6F5D51DD0C2" package="binaries"/>
<File name="scripts.dll" checksum="76CAAEBF21E4D6A34C3ABDD474D59580DD1EAB097C9585A1FFC0E28FF0FCAF56" package="binaries"/>
<File name="Scripts2.dll" checksum="A0439B3BFE6CB497F402291548A312A9F43C0D38BDAF26C83E21DBD3F4D55702" package="binaries"/>
<File name="shared.dll" checksum="17A8797EC5F4C34E241681F4AF76EEA93856D6A1854EC9838B263AEC89CDF036" package="binaries"/>
<File name="ttle.dll" checksum="C005EB50D77675CE712B30A4402AB0D152D3993EB509E35AEDCB834CED54FADF" package="binaries"/>
<File name="ttversion.txt" checksum="17A9E627A03E20AC33849330468BF8A80B48449C9D473DF45F1052A160083167" package="misc"/>
<File name="Data/always.dat" checksum="D4D09149C7B5368AB3947AEC7B6E7781208F592FCA4390261F04A8E9C1887EB3" package="always"/>
<File name="Data/RA_Volcano.mix" checksum="4F3CB80BCF200659B3B4B167CCB64ECB36A3BA917681F4AB8027568EC187B88D" package="RA_Volcano"/>
<File name="Data/Movies/ea_ww.bik" checksum="065B8B0894CF9C1C09A940D26069C54A771EBB3092143BA37E97F8CA85B8CCAD" package="movies"/>
<File name="Data/Movies/R_Intro.BIK" checksum="0D9A2B2ABFD9680DF1C8D0F876DA4B60F015CA5C56EC6C0593528A3F88E2A58F" package="movies"/>
</BHP_Game_Manifest>
```
Example `Patch` manifest:
```xml
<BHP_Game_Manifest game="apb" version="3.6.4.1" type="Patch" baseVersion="3.6.4.0">
<Dependency name="msvc-2022.zip"/>
<Dependency name="msvc-2015.zip" removedsince="3.6.4.1"/>
<File name="data/Always.dat" checksum="7B27D547E5A50401C9CA2851ACD4AC3F3CDA104F34650E1C32EB63A4120E3C90">
<Patch from="3.6.4.0" package="Always.patch.3.6.4.0"/>
</File>
<File name="data/Always_Emitters.dat" checksum="5C3EA8E5C4D7278F5EF1AF3CE1FC4F698C5A7D1FE7116DA0376A709E7CD3B2AE">
<Patch from="3.6.3.0" package="Always_Emitters.patch.3.6.3.0"/>
</File>
<File name="data/Always_Vehicles.dat" checksum="C6D4F6B3412C26065DE9B9F739BEC041B49FD613DB2686D8F231C2F692B263C5">
<Patch from="3.6.4.0" package="Always_Vehicles.patch.3.6.4.0"/>
</File>
<File name="data/RA_TestUnits.mix" removedsince="3.6.4.1"/>
<File name="data/RA_RidgeWar.mix" checksum="C3438C532C27F535CD958BEA9702160E952BC6CE58F9B43778F91DA23C6AE9FE" package="RA_RidgeWar"/>
<File name="data/RA_SwampOfIllusions.mix" checksum="A0787979664E5977A1949587382A3A84B9F1526F81934EBB7A3AC9334F9D4059">
<Patch from="3.6.4.0" package="RA_SwampOfIllusions.patch.3.6.4.0"/>
</File>
</BHP_Game_Manifest>
```
## SPECIFICATION
### BHP_GAME_MANIFEST (Root Element)
* **GAME** - Unique Application ID
* **VERSION** - Application Unique Version for Build
* **TYPE** - Type of Build
* **Full** - Full Build
* **Patch** - Patch Build
* **BASEVERSION** - *OPTIONAL* Version this build was based upon
### DEPENDENCY
* **NAME** - Package name of dependency sans file extension
* **REMOVEDSINCE** - _OPTIONAL_ No data. Not used anymore?
### FILE
* **NAME** - Name of file
* **CHECKSUM** - _OPTIONAL_ SHA256 hash of file
* **PACKAGE** - _OPTIONAL_ Package that contains this file. Not present on removed files or files that have been patched.
* **REMOVEDSINCE** - _OPTIONAL_ Version this package was removed in. If present, only _NAME_ and _REMOVEDSINCE_ will be present.
* **PATCH**
* **FROM** - Version that the file last changed
* **PACKAGE** - Name of package that contains this file.

View File

@@ -0,0 +1,319 @@
> [!NOTE]
> DRAFT
# Megafest
The fat manifest for W3D Hub Packager and Launcher(s), represented as a JSON hash.
This **MEGAFEST** is intended to simplify patch generation and distribution by ensuring that
all the data the Packager and Launcher(s) need can be found in a **single** manifest file.
Speed up application packaging by only needing to scan and checksum the new builds files instead of needing
to maintain a pristine copy of the previous version to be diffed against.
Reduce the number of requests that the launcher(s) need to make to sort out how to download and
patch the application.
## Glossary
### File
An individual file on disk.
An individual file as part of a MIX archive.
### Package
A collection of one or more files in a compressed archive for efficiently distributing the application.
> [!NOTE]
> Packages _MAY_ be compressed using ZSTANDARD (.zst) in the future. Do not assume all packages are zip files.
>
> Package names in the megafest include the file extension for this reason.
## Example `megafest.json` File
```json
{
"spec": 0,
"application": {
"id": "apb",
"version": "3.7.0.1",
"user_level": "public",
"previous_versions": [
"3.7.0.0"
]
},
"dependencies": [
"msvc-2022"
],
"packages": [
{
"name": "binaries.zip",
"version": "3.7.0.0",
"checksum": "SHA256-HASH",
"size": 4096,
"chunk_size": 4194304,
"chunk_checksums": [
"SHA256-HASH-A",
"SHA256-HASH-B"
],
"files": [
{
"name": "game2.exe",
"checksum": "SHA256-HASH",
"size": 8691743
}
]
},
{
"name": "data/Always.dat.zip",
"version": "3.7.0.0",
"checksum": "SHA256-HASH",
"size": 1355917483,
"chunk_size": 4194304,
"chunk_checksums": [
"SHA256-HASH-A",
"SHA256-HASH-B"
],
"files": [
{
"name": "mp_wep_gdi.w3d",
"checksum": "CRC32-HASH",
"offset": 0,
"size": 8691743
}
]
},
{
"name": "data/Always.patch.3.7.0.0.zst",
"version": "3.7.0.1",
"checksum": "SHA256-HASH",
"size": 1355919483,
"chunk_size": 4194304,
"chunk_checksums": [
"SHA256-HASH-A",
"SHA256-HASH-B"
],
"from_version": "3.7.0.0",
"files": [
{
"name": "data/Always.patch",
"checksum": "SHA256-HASH",
"size": 8691743
}
]
},
{
"name": "binaries.zip",
"version": "3.7.0.1",
"checksum": "SHA256-HASH",
"size": 4096,
"chunk_size": 4194304,
"chunk_checksums": [
"SHA256-HASH-A",
"SHA256-HASH-B"
],
"files": [
{
"name": "game.exe",
"checksum": "SHA256-HASH",
"size": 8691743
}
]
}
],
"changes": [
{
"name": "game.exe",
"type": "added",
"package": "binaries"
},
{
"name": "game2.exe",
"type": "removed"
},
{
"name": "data/Always.dat",
"type": "updated",
"package": "Always.dat"
}
],
"index": [
{
"name": "game.exe",
"checksum": "SHA256-HASH",
"size": 8691743
},
{
"name": "data/Always.dat",
"checksum": "SHA256-HASH",
"size": 1355917483
}
]
}
```
## SPECIFICATION
### Application
List of application details:
* **ID** - Unique Application ID
* **VERSION** - Application Unique Version
* **USER_LEVEL** - User access level for this version
* **PREVIOUS_VERSIONS** - Complete list of previous versions. Full builds only include _the_ previous version
Example:
```json
{
"id": "apb",
"version": "3.7.0.1",
"user_level": "public",
"previous_versions": [
"3.7.0.0"
]
}
```
### Dependencies
List of application's dependencies
Example:
```json
[
"msvc-2022"
]
```
### Packages
Complete list of application's version tree of packages.
That is, every package from the last full build to present for this version series.
* **NAME** - Name of package
* **VERSION** - Application version this package belongs to
* **CHECKSUM** - SHA256 checksum of package
* **SIZE** - File size of package in bytes
* CHUNK **SIZE** - Size _CHUNK_CHECKSUMS_ represent in bytes
* **CHUNK_CHECKSUMS** - Array of file chunk checksums
* **FROM_VERSION** - *OPTIONAL* Version this file was last changed. Only present for MIX patches.
* **FILES** - Array of files
* **NAME**
* **CHECKSUM** - SHA256 checksum of file.
* **SIZE** - File size in bytes
Example:
```json
[
{
"name": "binaries",
"version": "3.7.0.0",
"checksum": "SHA256-HASH",
"size": 4096,
"chunk_size": 4194304,
"chunk_checksums": [
"SHA256-HASH-A",
"SHA256-HASH-B"
],
"files": [
{
"name": "game.exe",
"checksum": "SHA256-HASH",
"size": 8691743
}
]
},
{
"name": "data/Always.patch.3.7.0.0",
"version": "3.7.0.1",
"checksum": "SHA256-HASH",
"size": 2436543,
"chunk_size": 4194304,
"chunk_checksums": [
"SHA256-HASH-A",
"SHA256-HASH-B"
],
"from_version": "3.7.0.0",
"files": [
{
"name": "data/Always.patch",
"checksum": "SHA256-HASH",
"size": 3636749
}
]
}
]
```
### Changes
List of file changes in this build.
* **NAME** - Name of file
* **TYPE** - Type of change
* **ADDED** - New file has been added
* **UPDATED** - File has been changed
* **REMOVED** - File has been deleted
* **PACKAGE** - _OPTIONAL_ Name of package, for this build version, the file can be found in
```json
[
{
"name": "game.exe",
"type": "updated",
"package": "binaries.zip"
},
{
"name": "game2.exe",
"type": "removed"
},
{
"name": "data/Always.dat",
"type": "added",
"package": "Always.dat.zip"
}
]
```
### Index
Complete list of files for this build with: filename, checksum, file size, and list of files for MIX archives.
* **NAME** - File name
* **CHECKSUM** - SHA256 hash of file
* **SIZE** - File size
* **FILES** - _OPTIONAL_ List of files inside of MIX archives
* **NAME** - File name
* **CHECKSUM** - CRC32 hash of file data
* **OFFSET** - Offset of file in MIX archive
* **SIZE** - Size of file data
```json
[
{
"name": "game.exe",
"checksum": "SHA256-HASH",
"size": 8691743
},
{
"name": "data/Always.dat",
"checksum": "SHA256-HASH",
"size": 570961432,
"files": [
{
"name": "mp_wep_gdi.w3d",
"checksum": "CRC32_HASH",
"offset": 0,
"size": 3524
}
]
}
]
```

132
docs/file_formats/MIX.md Normal file
View File

@@ -0,0 +1,132 @@
# Westwood Renegade MIX Archive
with W3D Hub / Tiberian Technologies alterations
The .MIX archive file format is used by Renegade as _yes_.
> [!IMPORTANT]
> The MIX format has no notion about directories.
>
> Each file name **MUST** be unique.
>
> e.g. `data/map.dds` and `data/RA_Under/MAP.dds` have the same base file name
> and therefore **MUST** raise an error when writing.
> [!NOTE]
> `io_pos` is the current position on the seek head of the open file
## Reading
* Read [Header](#header)
* validate MIME type (`MIX1` or `MIX2`)
* Jump to io_pos `file_data_offset`
* Read `file_count`
* Read array of [`file_data`](#file-data-array)
* Jump to io_pos `file_names_offset`
* Read `file_count`
* Read array of [`file_names`](#file-names-array)
* Read file blobs
* For each file's [`file_data`](#file-data-array)
* Jump io_pos to `file_content_offset`
* Read `file_content_length`
* Done.
## Writing
* Write MIME type (`MIX1` or `MIX2`)
* Jump to io_pos `16`
* Write file blobs
* **IMPORTANT:** Add `-io_pos & 7` padding to io_pos **AFTER** each file blob is written
* Save io_pos as `file_data_offset`
* Write `file_count`
* Write array of [`file_data`](#file-data-array)
* Save io_pos as `file_names_offset`
* Write `file_count`
* Write array of [`file_names`](#file-names-array)
* **IMPORTANT:** Ensure file names are null terminated and DO NOT exceed 254 (+ null byte) characters in length.
* Jump to io_pos `4`
* Write [`file_data_offset`](#header)
* Write [`file_names_offset`](#header)
* Write [`reserved`](#header)
* Done.
## Pseudo Example:
```yml
header:
mime: MIX1 or MIX2
file_data_offset: 0x1024EAEA
file_names_offset: 0x8192EAEA
reserved: 0
files:
array:
- file_blob
file_data:
file_count: 0x000021
array:
file_name_crc32: 0xEFEFEFEF
file_content_offset: 0x3217DEAD
file_content_length: 0x0018BEEF
file_names:
file_count: 0x000021
array:
file_name_length: 0xff
file_name: "mp_wep_gdi.w3d"
```
## MIME Types
### MIX1 `0x3158494D`
Westwood Farm Fresh Organic.
### MIX2 `0x3258494D`
Same as `MIX1`. `MIX2` hints to the engine's file reader to decrypt files before use.
## Data Structures
### Header
| Name | Offset | Type | Description |
|-------------------|--------|---------|---------------------------------------------------------------------|
| MIME | 0 | int32_t | 4 bytes representing `MIX1` (`0x3158494D)` or `MIX2` (`0x3258494D`) |
| File Data Offset | 4 | int32_t | Offset in MIX archive that [`file_data`](#file-data) data starts |
| File Names Offset | 8 | int32_t | Offset in MIX archive that [`file_name`](#file-names) data starts |
| RESERVED | 12 | int32_t | Unused reserved int. Write as `0` |
### File
| Name | Offset | Type | Description |
|-----------|---------|-----------------------------|---------------|
| File Blob | complex | char[`file_content_length`] | File contents |
### File Data
| Name | Offset | Type | Description |
|------------|--------------------|---------|--------------------------------|
| File Count | `file_data_offset` | int32_t | Number of files in MIX archive |
### File Data Array
| Name | Offset | Type | Description |
|---------------------|--------------------------------------------|----------|--------------------------------------------------------|
| File Name CRC32 | `file_data_offset` + 4 * (index * sizeof) | uint32_t | CRC32 of **UPPERCASE** file name in network byte order |
| File Content Offset | `file_data_offset` + 8 * (index * sizeof) | uint32_t | File content offset |
| File Content Length | `file_data_offset` + 12 * (index * sizeof) | uint32_t | File content length |
### File Names
| Name | Offset | Type | Description |
|------------|---------------------|---------|--------------------------------|
| File Count | `file_names_offset` | int32_t | Number of files in MIX archive |
### File Names Array
| Name | Offset | Type | Description |
|------------------|---------|--------------------------|-------------------------------------------------|
| File Name Length | complex | uint8_t | Length of string in bytes, including null byte. |
| File Name | complex | char[`file_name_length`] | null terminated string |

View File

@@ -0,0 +1,19 @@
# W3D Hub Patch
A W3D Hub Patch
A `.w3dhub.patch` is included in MIX Patch files.
It contains instructions for updating the target MIX.
JSON file with a static CRC32 of `E6FE46B8`
## Example:
```json
{
"removedFiles": [
"sov_v_mad.gif"
],
"updatedFiles": [
"sov_v_mad.w3d"
]
}
```

View File

@@ -0,0 +1,36 @@
> [!NOTE]
> DRAFT
# LAN Discovery
Doing things
Broadcast port: 4898
| Name | Type | Description |
|-------------------|----------|--------------------------------------------------------------------------|
| Version | int32_t | Version of protocol this application supports |
| Owner | string | Nickname of application's active user |
| Hostname | string | Name of device |
| UUID | string | Unique identifier for this application |
| Application | string | Name of application |
| Features | array | Array of strings naming features the application supports |
| Service Port | uint16_t | Dynamically assigned TCP port that interested parties should connect too |
> [!NOTE]
> Max packet size for UDP broadcast may need to be limited to 512 bytes
```json
{
"version": 0,
"owner": "cyberarm",
"hostname": "PC-1692",
"uuid": "019bcf1e-a22e-7fe0-a3db-3a9d37bfc6fa",
"application": "W3D Hub Linux Launcher",
"features": [
"launcher_remote:3",
"package_share:1"
],
"service_port": 56802
}
```

View File

@@ -0,0 +1,9 @@
> [!NOTE]
> DRAFT
# LAN Package Share
Authentication is not required, only user opt-in to enable package sharing from their application.
* List available packages
* upload or download verified packages between other applications on LAN network

View File

@@ -0,0 +1,9 @@
> [!NOTE]
> DRAFT
# Launcher Remote
* Get list of available, installed, and in progress applications
* Trigger download or update of application
* Delete application
* Join server

View File

@@ -1,89 +1,75 @@
class W3DHub
class Api
LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 10 # 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)
@error = error
end
def success?
false
end
def status
-1
end
def body
""
end
def error
@error
end
Api.send(method, *args, &callback)
end
#! === W3D Hub API === !#
W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
ALT_W3DHUB_API_ENDPOINT = "https://w3dhub-api.w3d.cyberarm.dev".freeze # "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
ENDPOINT = "https://secure.w3dhub.com".freeze
HTTP_CLIENTS = {}
def self.async_http(method:, path:, headers:, body:, backend:, async:, &callback)
raise "NO CALLBACK DEFINED!" unless callback
case backend
when :w3dhub
endpoint = W3DHUB_API_ENDPOINT
when :alt_w3dhub
endpoint = ALT_W3DHUB_API_ENDPOINT
when :gsh
endpoint = SERVER_LIST_ENDPOINT
end
# Handle arbitrary urls that may come through
if path.start_with?("http")
uri = URI(path)
endpoint = uri.origin
path = uri.request_uri
end
url = "#{endpoint}#{path}"
def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil)
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
Excon.send(
method,
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
Store.network_manager.request(method, url, headers, body, async, &callback)
end
def self.post(url, headers = DEFAULT_HEADERS, body = nil)
excon(:post, url, headers, body)
def self.post(path:, headers: DEFAULT_HEADERS, body: nil, backend: :w3dhub, async: true, &callback)
async_http(method: :post, path: path, headers: headers, body: body, backend: backend, async: async, &callback)
end
def self.get(path:, headers: DEFAULT_HEADERS, body: nil, backend: :w3dhub, async: true, &callback)
async_http(method: :get, path: path, headers: headers, body: body, backend: backend, async: async, &callback)
end
# Api.get but handles any URL instead of known hosts
def self.fetch(path:, headers: DEFAULT_HEADERS, body: nil, backend: :w3dhub, async: true, &callback)
async_http(method: :get, path: path, headers: headers, body: body, backend: backend, async: async, &callback)
end
# Method: POST
@@ -101,166 +87,277 @@ class W3DHub
#
# On a failed login the service responds with:
# {"error":"login-failed"}
def self.refresh_user_login(refresh_token)
body = "data=#{JSON.dump({refreshToken: refresh_token})}"
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
def self.refresh_user_login(refresh_token, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({ refreshToken: refresh_token }))
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
handler = lambda do |result|
if result.okay?
user_data = JSON.parse(result.data, symbolize_names: true)
return false if user_data[:error]
if user_data[:error]
callback.call(CyberarmEngine::Result.new(data: false))
next
end
body = "data=#{JSON.dump({ id: user_data[:userid] })}"
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
user_details_data = user_details(user_data[:userid]) || {}
if user_details.status == 200
user_details_data = JSON.parse(user_details.body, symbolize_names: true)
callback.call(CyberarmEngine::Result.new(data: Account.new(user_data, user_details_data)))
else
logger.error(LOG_TAG) { "Failed to fetch refresh user details:" }
logger.error(LOG_TAG) { user_details }
end
logger.error(LOG_TAG) { "Failed to fetch refresh user login:" }
logger.error(LOG_TAG) { result.error }
Account.new(user_data, user_details_data)
else
logger.error(LOG_TAG) { "Failed to fetch refresh user login:" }
logger.error(LOG_TAG) { response }
false
callback.call(result)
end
end
post(path: "/apis/launcher/1/user-login", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end
# See #user_refresh_token
def self.user_login(username, password)
body = "data=#{JSON.dump({username: username, password: password})}"
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
def self.user_login(username, password, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({ username: username, password: password }))
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
handler = lambda do |result|
if result.okay?
user_data = JSON.parse(result.data, symbolize_names: true)
return false if user_data[:error]
if user_data[:error]
callback.call(CyberarmEngine::Result.new(data: false))
next
end
body = "data=#{JSON.dump({ id: user_data[:userid] })}"
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
user_details_data = user_details(user_data[:userid]) || {}
if user_details.status == 200
user_details_data = JSON.parse(user_details.body, symbolize_names: true)
callback.call(CyberarmEngine::Result.new(data: Account.new(user_data, user_details_data)))
else
logger.error(LOG_TAG) { "Failed to fetch user details:" }
logger.error(LOG_TAG) { user_details }
end
logger.error(LOG_TAG) { "Failed to fetch user login:" }
logger.error(LOG_TAG) { result.error }
Account.new(user_data, user_details_data)
else
logger.error(LOG_TAG) { "Failed to fetch user login:" }
logger.error(LOG_TAG) { response }
false
callback.call(result)
end
end
post(path: "/apis/launcher/1/user-login", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end
# /apis/launcher/1/user-login
# Client sends an Authorization header bearer token which is received from logging in (Required?)
# /apis/w3dhub/1/get-user-details
#
# Response: avatar-uri (Image download uri), id, username
def self.user_details(id)
def self.user_details(id, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({ id: id }))
handler = lambda do |result|
if result.okay?
callback.call(CyberarmEngine::Result.new(data: JSON.parse(result.data, symbolize_names: true)))
else
logger.error(LOG_TAG) { "Failed to fetch user details:" }
logger.error(LOG_TAG) { result.error }
callback.call(result)
end
end
post(path: "/apis/w3dhub/1/get-user-details", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end
# /apis/w3dhub/1/get-service-status
# Service response:
# {"services":{"authentication":true,"packageDownload":true}}
def self.service_status
response = post("#{ENDPOINT}/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS)
def self.service_status(backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
callback.call(CyberarmEngine::Result.new(data: ServiceStatus.new(result.data)))
else
logger.error(LOG_TAG) { "Failed to fetch service status:" }
logger.error(LOG_TAG) { result.error }
if response.status == 200
ServiceStatus.new(response.body)
else
logger.error(LOG_TAG) { "Failed to fetch service status:" }
logger.error(LOG_TAG) { response }
false
callback.call(result)
end
end
post(path: "/apis/w3dhub/1/get-service-status", backend: backend, &handler)
end
# /apis/launcher/1/get-applications
# Client sends an Authorization header bearer token which is received from logging in (Optional)
# Launcher sends an empty data request: data={}
# Response is a list of applications/games
def self.applications
response = post("#{ENDPOINT}/apis/launcher/1/get-applications")
def self.applications(backend = :w3dhub, &callback)
async = !callback.nil?
if response.status == 200
Applications.new(response.body)
else
logger.error(LOG_TAG) { "Failed to fetch applications list:" }
logger.error(LOG_TAG) { response }
false
# Complicated why to "return" direct value
callback = ->(result) { result }
handler = lambda do |result|
if result.okay?
callback.call(CyberarmEngine::Result.new(data: Applications.new(result.data, backend)))
else
logger.error(LOG_TAG) { "Failed to fetch applications list:" }
logger.error(LOG_TAG) { result.error }
callback.call(result)
end
end
post(path: "/apis/launcher/1/get-applications", async: async, backend: backend, &handler)
end
# Populate applications list from primary and alternate backends
# (alternate only has latest public builds of _most_ games)
def self._applications(&callback)
handler = lambda do |result|
# nothing special on offer if we're not logged in
applications_primary = Store.account ? Api.applications(:w3dhub).data : false
applications_alternate = Api.applications(:alt_w3dhub).data
# Fail if we fail to fetch applications list from either backend
unless applications_primary || applications_alternate
callback.call(CyberarmEngine::Result.new)
next
end
unless applications_primary
callback.call(CyberarmEngine::Result.new(data: applications_alternate))
next
end
# Merge the two app lists together
apps = applications_alternate
if applications_primary
applications_primary.games.each do |game|
# Check if game exists in alternate list
_game = apps.games.find { |g| g.id == game.id }
unless _game
apps.games << game
# App didn't exist in alternates list
# comparing channels isn't useful
next
end
# If it does, check that all of its channels also exist in alternate list
# and that the primary versions are the same as the alternates list
game.channels.each do |channel|
_channel = _game.channels.find { |c| c.id == channel.id }
unless _channel
_game.channels << channel
# App didn't have channel in alternates list
# comparing channel isn't useful
next
end
# If channel versions and access levels match then all's well
if channel.current_version == _channel.current_version &&
channel.user_level == _channel.user_level
# All's Well!
next
end
# If the access levels don't match then overwrite alternate's channel with primary's channel
if channel.user_level != _channel.user_level
# Replace alternate's channel with primary's channel
_game.channels[_game.channels.index(_channel)] = channel
# Replaced, continue.
next
end
# If versions don't match then pick whichever one is higher
if Gem::Version.new(channel.current_version) > Gem::Version.new(_channel.current_version)
# Replace alternate's channel with primary's channel
_game.channels[_game.channels.index(_channel)] = channel
else
# Do nothing, alternate backend version is greater.
end
end
end
end
callback.call(CyberarmEngine::Result.new(data: apps))
end
# Bit hacky but we just need to run this handler from the networking thread and async reactor
get(path: "", backend: nil, &handler)
end
# /apis/w3dhub/1/get-news
# Client sends an Authorization header bearer token which is received from logging in (Optional)
# Client requests news for a specific application/game e.g.: data={"category":"ia"} ("launcher-home" retrieves the weekly hub updates)
# Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one
def self.news(category)
body = "data=#{JSON.dump({category: category})}"
response = post("#{ENDPOINT}/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body)
def self.news(category, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
callback.call(CyberarmEngine::Result.new(data: News.new(result.data)))
else
logger.error(LOG_TAG) { "Failed to fetch news for:" }
logger.error(LOG_TAG) { category }
logger.error(LOG_TAG) { result.error }
if response.status == 200
News.new(response.body)
else
logger.error(LOG_TAG) { "Failed to fetch news for:" }
logger.error(LOG_TAG) { category }
logger.error(LOG_TAG) { response }
false
callback.call(result)
end
end
body = URI.encode_www_form("data": JSON.dump({ category: category }))
post(path: "/apis/w3dhub/1/get-news", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end
# Downloading games
# /apis/launcher/1/get-package-details
# client requests package details: data={"packages":[{"category":"games","name":"apb.ico","subcategory":"apb","version":""}]}
def self.package_details(packages)
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
response = post("#{ENDPOINT}/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body)
def self.package_details(packages, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
hash = JSON.parse(result.data, symbolize_names: true)
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
hash[:packages].map { |pkg| Package.new(pkg) }
else
logger.error(LOG_TAG) { "Failed to fetch package details for:" }
logger.error(LOG_TAG) { packages }
logger.error(LOG_TAG) { response }
false
callback.call(CyberarmEngine::Result.new(data: hash[:packages].map { |pkg| Package.new(pkg) }))
else
logger.error(LOG_TAG) { "Failed to fetch package details for:" }
logger.error(LOG_TAG) { packages }
logger.error(LOG_TAG) { result.error }
callback.call(result)
end
end
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
post(path: "/apis/launcher/1/get-package-details", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end
# /apis/launcher/1/get-package
# client requests package: data={"category":"games","name":"ECW_Asteroids.zip","subcategory":"ecw","version":"1.0.0.0"}
#
# server responds with download bytes, probably supports chunked download and resume
def self.package(package, &block)
Cache.fetch_package(package, block)
# FIXME: REFACTOR Cache.fetch_package to use HttpClient
def self.package(package, &callback)
Cache.fetch_package(package, callback)
end
# /apis/w3dhub/1/get-events
#
# clients requests events: data={"serverPath":"apb"}
def self.events(app_id)
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
response = post("#{ENDPOINT}/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body)
if response.status == 200
array = JSON.parse(response.body, symbolize_names: true)
array.map { |e| Event.new(e) }
else
false
def self.events(app_id, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
array = JSON.parse(result.data, symbolize_names: true)
callback.call(CyberarmEngine::Result.new(data: array.map { |e| Event.new(e) }))
else
callback.call(result)
end
end
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
post(path: "/apis/w3dhub/1/get-server-events", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end
#! === Server List API === !#
SERVER_LIST_ENDPOINT = "https://gsh.w3dhub.com".freeze
def self.get(url, headers = DEFAULT_HEADERS, body = nil)
excon(:get, url, headers, body)
end
# 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
# Method: GET
# FORMAT: JSON
@@ -279,15 +376,17 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# nick, team (index of teams array), score, kills, deaths
def self.server_list(level = 1)
response = get("#{SERVER_LIST_ENDPOINT}/listings/getAll/v2?statusLevel=#{level}")
if response.status == 200
data = JSON.parse(response.body, symbolize_names: true)
return data.map { |hash| ServerListServer.new(hash) }
def self.server_list(level = 1, backend = :gsh, &callback)
handler = lambda do |result|
if result.okay?
data = JSON.parse(result.data, symbolize_names: true)
callback.call(CyberarmEngine::Result.new(data: data.map { |hash| ServerListServer.new(hash) }))
else
callback.call(result)
end
end
false
get(path: "/listings/getAll/v2?statusLevel=#{level}", backend: backend, &handler)
end
# /listings/getStatus/v2/:id?statusLevel=#{0-2}
@@ -301,21 +400,24 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# nick, team (index of teams array), score, kills, deaths
def self.server_details(id, level)
response = get("#{SERVER_LIST_ENDPOINT}/listings/getStatus/v2/#{id}?statusLevel=#{level}")
def self.server_details(id, level, backend = :gsh, &callback)
return false unless id && level
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
return hash
handler = lambda do |result|
if result.okay?
callback.call(CyberarmEngine::Result.new(data: JSON.parse(result.data, symbolize_names: true)))
else
callback.call(result)
end
end
false
get(path: "/listings/getStatus/v2/#{id}?statusLevel=#{level}", backend: backend, &handler)
end
# /listings/push/v2/negotiate?negotiateVersion=1
##? /listings/push/v2/?id=#{websocket token?}
## Websocket server list listener
def self.server_list_push(id)
def self.server_list_push(id, &callback)
end
end
end

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

@@ -1,14 +1,16 @@
class W3DHub
class Api
class Applications
def initialize(response)
attr_reader :data
def initialize(response, source = nil)
@data = JSON.parse(response, symbolize_names: true)
games = @data[:applications].select { |a| a[:category] == "games" }
@games = []
games.each { |hash| @games << Game.new(hash) }
games.each { |hash| @games << Game.new(hash, source) }
@games.sort_by!(&:name).reverse
end
@@ -18,37 +20,71 @@ class W3DHub
class Game
attr_reader :id, :name, :type, :category, :studio_id, :channels, :web_links, :color
attr_reader :___source
def initialize(hash)
def initialize(hash, source = nil)
@data = hash
@data[:___source] = source if source
@id = @data[:id]
@id = @data[:id].to_s
@name = @data[:name]
@type = @data[:type]
@category = @data[:category]
@studio_id = @data[:"studio-id"]
# TODO: Do processing
@channels = @data[:channels].map { |channel| Channel.new(channel) }
@channels = @data[:channels].map { |channel| Channel.new(channel, source) }
@web_links = @data[:"web-links"]&.map { |link| WebLink.new(link) } || []
@extended_data = @data[:"extended-data"]
color = @data[:"extended-data"].find { |h| h[:name] == "colour" }[:value].sub("#", "")
color = color.sub("ff", "") if color.length == 8
@color = "ff#{color}".to_i(16)
cfg = @data[:"extended-data"].find { |h| h[:name] == "usesEngineCfg" }
@uses_engine_cfg = (cfg && cfg[:value].to_s.downcase.strip == "true") == true # explicit truthy compare to prevent return `nil`
cfg = @data[:"extended-data"].find { |h| h[:name] == "usesRenFolder" }
@uses_ren_folder = (cfg && cfg[:value].to_s.downcase.strip == "true") == true # explicit truthy compare to prevent return `nil`
end
def uses_engine_cfg?
@uses_engine_cfg
end
def uses_ren_folder?
@uses_ren_folder
end
def source
@data[:___source]&.to_sym || :w3dhub
end
def source=(sym)
@data[:___source] = sym
end
class Channel
attr_reader :id, :name, :user_level, :current_version
def initialize(hash)
def initialize(hash, source = nil)
@data = hash
@data[:___source] = source
@id = @data[:id]
@id = @data[:id].to_s
@name = @data[:name]
@user_level = @data[:"user-level"]
@current_version = @data[:"current-version"]
end
def source
@data[:___source]&.to_sym || :w3dhub
end
def source=(sym)
@data[:___source] = sym
end
end
class WebLink

View File

@@ -6,7 +6,7 @@ class W3DHub
def initialize(response)
@data = JSON.parse(response, symbolize_names: true)
@items = @data[:news].map { |item| Item.new(item) }
@items = (@data[:news] && @data[:news].is_a?(Array)) ? @data[:news].map { |item| Item.new(item) } : []
end
class Item

View File

@@ -1,7 +1,7 @@
class W3DHub
class Api
class Package
attr_reader :category, :subcategory, :name, :version, :size, :checksum, :checksum_chunk_size, :checksum_chunks,
attr_reader :category, :subcategory, :name, :version, :size, :checksum, :checksum_chunk_size, :checksum_chunks, :download_url, :error,
:custom_partially_valid_at_bytes, :custom_is_patch
def initialize(hash)
@@ -16,6 +16,9 @@ class W3DHub
@checksum = @data[:checksum]
@checksum_chunk_size = @data[:"checksum-chunk-size"]
@checksum_chunks = @data[:"checksum-chunks"]
@error = @data[:error] || nil
@download_url = @data[:download_url] || nil
@custom_partially_valid_at_bytes = 0
@custom_is_patch = false
@@ -25,6 +28,10 @@ class W3DHub
@checksum_chunks[:"#{key}"]
end
def error?
@error
end
def partially_valid_at_bytes=(i)
@custom_partially_valid_at_bytes = i
end

View File

@@ -1,7 +1,10 @@
class W3DHub
class Api
class ServerListServer
attr_reader :id, :game, :address, :port, :region, :channel, :ping, :status
NO_OR_BAD_PING = 1_000_000
NO_OR_DEFAULT_VERSION = "838"
attr_reader :id, :game, :address, :port, :region, :channel, :version, :ping, :status
def initialize(hash)
@data = hash
@@ -12,43 +15,56 @@ class W3DHub
@port = @data[:port]
@region = @data[:region]
@channel = @data[:channel] || "release"
@ping = -1
@version = @data[:version] || NO_OR_DEFAULT_VERSION
@ping = NO_OR_BAD_PING
@status = @data[:status] ? Status.new(@data[:status]) : nil
@status = Status.new(@data[:status])
@ping_interval = 30_000
@last_pinged = Gosu.milliseconds + @ping_interval + 1
# if we're on unix and using the PingManager then check every second since
# we're not _actually_ pinging the server.
@ping_interval = W3DHub.unix? ? 1_000 : 60_000
@last_pinged = Gosu.milliseconds + @ping_interval + 1_000
Store.ping_manager.add_address(@address)
end
def update(hash)
if @status
@status.instance_variable_set(:@name, hash[:name])
@status.instance_variable_set(:@password, hash[:password] || false)
@status.instance_variable_set(:@map, hash[:map])
@status.instance_variable_set(:@max_players, hash[:maxplayers])
@status.instance_variable_set(:@player_count, hash[:numplayers] || 0)
@status.instance_variable_set(:@started, hash[:started])
@status.instance_variable_set(:@remaining, hash[:remaining])
@status.name = hash[:name]
@status.password = hash[:password] || false
@status.map = hash[:map]
@status.max_players = hash[:maxplayers]
@status.player_count = hash[:numplayers] || 0
@status.started = hash[:started]
@status.remaining = hash[:remaining]
@status.instance_variable_set(:@teams, hash[:teams]&.map { |t| Team.new(t) }) if hash[:teams]
@status.instance_variable_set(:@players, hash[:players]&.select { |t| t[:nick] != "Nod" && t[:nick] != "GDI" }&.map { |t| Player.new(t) }) if hash[:players]
@status.teams = hash[:teams]&.map { |t| Team.new(t) } if hash[:teams]
@status.players = hash[:players]&.select { |t| t[:nick] != "Nod" && t[:nick] != "GDI" }&.map { |t| Player.new(t) } if hash[:players]
send_ping
return true
else
@status = Status.new(hash)
end
false
true
end
def send_ping(force_ping = false)
if force_ping || Gosu.milliseconds - @last_pinged >= @ping_interval
@last_pinged = Gosu.milliseconds
if W3DHub.unix?
average_ping = Store.ping_manager.ping_for(@address)
@ping = average_ping.negative? ? NO_OR_BAD_PING : average_ping
States::Interface.instance&.update_server_ping(self)
end
return #unless W3DHub.windows?
W3DHub::BackgroundWorker.foreground_parallel_job(
lambda do
@ping = -1
W3DHub.command("ping #{@address} #{W3DHub.windows? ? '-n 3' : '-c 3'}") do |line|
if W3DHub.windows? && line =~ /Minimum|Maximum|Maximum/i
@ping = line.strip.split(",").last.split("=").last.sub("ms", "").to_i
@@ -57,6 +73,8 @@ class W3DHub
end
end
@ping = NO_OR_BAD_PING if @ping.zero?
@ping
end,
lambda do |_|
@@ -67,26 +85,26 @@ class W3DHub
end
class Status
attr_reader :name, :password, :map, :max_players, :player_count, :started, :remaining, :teams, :players
attr_accessor :name, :password, :map, :max_players, :player_count, :started, :remaining, :teams, :players
def initialize(hash)
@data = hash
@data = hash || {}
@teams = @data[:teams]&.map { |t| Team.new(t) }
@players = @data[:players]&.select { |t| t[:nick] != "Nod" && t[:nick] != "GDI" }&.map { |t| Player.new(t) }
@teams = @data[:teams]&.map { |t| Team.new(t) } || []
@players = @data[:players]&.select { |t| t[:nick] != "Nod" && t[:nick] != "GDI" }&.map { |t| Player.new(t) } || []
@name = @data[:name]
@name = @data[:name] || ""
@password = @data[:password] || false
@map = @data[:map]
@max_players = @data[:maxplayers]
@map = @data[:map] || ""
@max_players = @data[:maxplayers] || 0
@player_count = @players.size || @data[:numplayers].to_i
@started = @data[:started]
@remaining = @data[:remaining]
@started = @data[:started] || Time.now
@remaining = @data[:remaining] || "00.00.00"
end
end
class Team
attr_reader :id, :name, :score, :kills, :deaths
attr_accessor :id, :name, :score, :kills, :deaths
def initialize(hash)
@data = hash
@@ -100,7 +118,7 @@ class W3DHub
end
class Player
attr_reader :nick, :team, :score, :kills, :deaths
attr_accessor :nick, :team, :score, :kills, :deaths
def initialize(hash)
@data = hash

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
@@ -11,30 +14,36 @@ class W3DHub
@@instance = ServerListUpdater.new
end
attr_accessor :auto_reconnect
attr_accessor :auto_reconnect, :invocation_id
def initialize
@auto_reconnect = false
@reconnection_delay = 1
@invocation_id = 0
logger.info(LOG_TAG) { "Starting emulated SignalR Server List Updater..." }
run
# run
end
def run
Thread.new do
begin
connect
Sync do |task|
begin
@auto_reconnect = true
while W3DHub::BackgroundWorker.alive?
connect if @auto_reconnect
sleep 1
# FIXME
while true #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 10
retry
end
end
@@ -46,62 +55,185 @@ class W3DHub
@auto_reconnect = false
logger.debug(LOG_TAG) { "Requesting connection token..." }
response = Excon.post("https://gsh.w3dhub.com/listings/push/v2/negotiate?negotiateVersion=1", headers: Api::DEFAULT_HEADERS, body: "")
data = JSON.parse(response.body, symbolize_names: true)
result = nil
Api.post("/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh) do |callback_result|
result = callback_result
end
# FIXME: we've introduced ourselves to callback hell, yay!
while result.nil?
sleep 0.1
end
if result.error?
@auto_reconnect = true
@reconnection_delay *= 2
@reconnection_delay = 60 if @reconnection_delay > 60
return
end
@reconnection_delay = 1
connect_websocket(JSON.parse(result.data, symbolize_names: true))
end
def connect_websocket(data)
@invocation_id = 0 if @invocation_id > 9095
id = data[:connectionToken]
endpoint = "https://gsh.w3dhub.com/listings/push/v2?id=#{id}"
endpoint = "#{Api::SERVER_LIST_ENDPOINT}/listings/push/v2?id=#{id}"
logger.debug(LOG_TAG) { "Connecting to websocket..." }
this = self
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")
logger.debug(LOG_TAG) { "Subscribing to server changes..." }
Store.server_list.each_with_index do |server, i|
i += 1
Store.server_list.each do |server|
this.invocation_id += 1
mode = 1 # 2 full details, 1 basic details
out = { "type": 1, "invocationId": "#{i}", "target": "SubscribeToServerStatusUpdates", "arguments": [server.id, mode] }
out = { "type": 1, "invocationId": "#{this.invocation_id}", "target": "SubscribeToServerStatusUpdates", "arguments": [server.id, mode] }
ws.send(out.to_json + "\x1e")
end
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)
# Send PING(?)
if hash.empty? || hash[:type] == 6
ws.send({ type: 6 }.to_json + "\x1e")
else
case hash[:type]
when 1
if hash[:target] == "ServerStatusChanged"
id, data = hash[:arguments]
server = Store.server_list.find { |s| s.id == id }
server_updated = server&.update(data)
# pp hash if hash[:target] != "ServerStatusChanged" && hash[:type] != 6 && hash[:type] != 3
BackgroundWorker.foreground_job(-> {}, ->(result){ States::Interface.instance&.update_server_browser(server) }) if server_updated
# Send PING(?)
if hash.empty? || hash[:type] == TYPE_PING
ws.send({ type: TYPE_PING }.to_json + "\x1e")
next
end
case hash[:type]
when 1
case hash[:target]
when "ServerRegistered"
data = hash[:arguments].first
this.invocation_id += 1
out = {
"type": 1,
"invocationId": "#{this.invocation_id}",
"target": "SubscribeToServerStatusUpdates",
"arguments": [data[:id], 1]
}
ws.send(out.to_json + "\x1e")
BackgroundWorker.foreground_job(
->(data) { [Api.server_details(data[:id], 2), data] },
->(array) do
server_data, data = array
next unless server_data
data[:status] = server_data
server = ServerListServer.new(data)
Store.server_list.push(server)
States::Interface.instance&.update_server_browser(server, :update)
end,
nil,
data
)
when "ServerStatusChanged"
id, data = hash[:arguments]
server = Store.server_list.find { |s| s.id == id }
server_updated = server&.update(data)
BackgroundWorker.foreground_job(->(server) { server }, ->(server) { States::Interface.instance&.update_server_browser(server, :update) }, nil, server) if server_updated
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|
p e
ws.on(:close) do
logger.error(LOG_TAG) { "Connection closed." }
this.auto_reconnect = true
ws.close
end
ws.on(:error) do |e|
p e
logger.error(LOG_TAG) { e }
this.auto_reconnect = true
ws.close
end
end
@ws = nil
end
def refresh_server_list(list)
new_servers = []
removed_servers = []
# find new servers
list.each do |server|
found_server = Store.server_list.find { |s| s.id == server.id }
new_servers << server unless found_server
end
# find removed servers
Store.server_list.each do |server|
found_server = list.find { |s| s.id == server.id }
removed_servers << server unless found_server
end
# purge removed servers from list
Store.server_list.reject! do |server|
removed_servers.find { |s| server.id == s.id }
end
# add new servers to list
Store.server_list = Store.server_list + new_servers
if @ws
# unsubscribe from removed servers
removed_servers.each do
@invocation_id += 1
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]
}
ws.send(out.to_json + "\x1e")
end
end
# sort list
Store.server_list.sort_by! { |s| [s.status.player_count, s.id] }.reverse!
end
end
end

View File

@@ -5,6 +5,7 @@ class W3DHub
def initialize
@tasks = [] # :installer, :importer, :repairer, :uninstaller
@running_applications = {}
end
def install(app_id, channel)
@@ -22,9 +23,7 @@ class W3DHub
# unpack packages
# install dependencies (e.g. visual C runtime)
installer = Installer.new(app_id, channel)
@tasks.push(installer)
@tasks.push(Installer.new(context: task_context(app_id, channel, "version")))
end
def update(app_id, channel)
@@ -32,7 +31,7 @@ class W3DHub
return false unless installed?(app_id, channel)
updater = Updater.new(app_id, channel)
updater = Updater.new(Installer.new(context: task_context(app_id, channel, "version")))
@tasks.push(updater)
end
@@ -44,7 +43,7 @@ class W3DHub
# if auto-import fails ask user for path to game exe
# mark app as imported/installed
@tasks.push(Importer.new(app_id, channel))
push_state(W3DHub::States::ImportGameDialog, app_id: app_id, channel: channel)
end
def settings(app_id, channel)
@@ -86,15 +85,27 @@ class W3DHub
# open wwconfig.exe or config.exe for ecw
if (app_data = installed?(app_id, channel) && W3DHub.unix?)
exe = if Store.settings[:wine_prefix]
"WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" winecfg"
else
"winecfg"
end
return unless (app_data = installed?(app_id, channel) && W3DHub.unix?)
Process.spawn("#{exe}")
end
exe = if !Store.settings[:wine_prefix].to_s.empty?
"WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" winecfg"
else
"winecfg"
end
Process.spawn(exe)
end
def task_context(app_id, channel, version)
Task::Context.new(
SecureRandom.hex,
"games",
app_id,
channel,
version,
"",
""
)
end
def repair(app_id, channel)
@@ -109,7 +120,7 @@ class W3DHub
# unpack packages
# install dependencies (e.g. visual C runtime) if appropriate
@tasks.push(Repairer.new(app_id, channel))
@tasks.push(Repairer.new(context: task_context(app_id, channel, "version")))
end
def uninstall(app_id, channel)
@@ -124,7 +135,7 @@ class W3DHub
title: "Uninstall #{game.name}?",
message: "Are you sure you want to uninstall #{game.name} (#{channel})?",
accept_callback: proc {
@tasks.push(Uninstaller.new(app_id, channel))
@tasks.push(Uninstaller.new(context: task_context(app_id, channel, "version")))
}
)
end
@@ -169,11 +180,17 @@ class W3DHub
def wine_command(app_id, channel)
return "" if W3DHub.windows?
if Store.settings[:wine_prefix]
"WINEPREFIX=\"#{Store.settings[:wine_prefix]}\" \"#{Store.settings[:wine_command]}\" "
else
"#{Store.settings[:wine_command]} "
end
"\"#{Store.settings[:wine_command]}\" "
end
def wine_enviroment_variables(app_id, channel)
vars = {}
return vars if W3DHub.windows?
vars["WINEPREFIX"] = Store.settings[:wine_prefix] unless Store.settings[:wine_prefix].to_s.empty?
# vars["WINEDEBUG"] = "-all" if true # TODO make this an option. wine debug interferences with pid returned from Process.spawn
vars
end
def mangohud_command(app_id, channel)
@@ -188,6 +205,13 @@ class W3DHub
end
end
def mangohud_enviroment_variables(app_id, channel)
vars = {}
return vars if W3DHub.windows?
vars
end
def dxvk_command(app_id, channel)
return "" if W3DHub.windows?
@@ -201,32 +225,134 @@ class W3DHub
end
end
def run(app_id, channel, *args)
if (app_data = installed?(app_id, channel))
pid = Process.spawn("#{dxvk_command(app_id, channel)}#{mangohud_command(app_id, channel)}#{wine_command(app_id, channel)}\"#{app_data[:install_path]}\" -launcher #{args.join(' ')}")
Process.detach(pid)
def dxvk_enviroment_variables(app_id, channel)
vars = {}
return vars if W3DHub.windows?
vars
end
def start_command(path, exe)
if W3DHub.windows?
"start /D \"#{path}\" /B #{exe}"
else
"#{path}/#{exe}"
end
end
def join_server(app_id, channel, server, password = nil)
if installed?(app_id, channel) && Store.settings[:server_list_username].to_s.length.positive?
run(
app_id, channel,
"+connect #{server.address}:#{server.port} +netplayername #{Store.settings[:server_list_username]}#{password ? " +password \"#{password}\"" : ""}"
)
def run(app_id, channel, *args)
if (app_data = installed?(app_id, channel))
install_directory = app_data[:install_directory]
exe_path = app_id == "ecw" ? "#{install_directory}/game500.exe" : app_data[:install_path]
exe_path.gsub!("/", "\\") if W3DHub.windows?
exe_path.gsub!("\\", "/") if W3DHub.unix?
exe = File.basename(exe_path)
path = File.dirname(exe_path)
env = {}
if W3DHub.unix?
env.merge!(
dxvk_enviroment_variables(app_id, channel),
mangohud_enviroment_variables(app_id, channel),
wine_enviroment_variables(app_id, channel)
)
end
attempted = false
begin
pid = Process.spawn(
env,
"#{dxvk_command(app_id, channel)}"\
"#{mangohud_command(app_id, channel)}"\
"#{wine_command(app_id, channel)}"\
"#{attempted ? start_command(path, exe) : "\"#{exe_path}\""} "\
"-launcher #{args.join(' ')}"
)
Process.detach(pid)
BackgroundWorker.foreground_parallel_job(-> { monitor_process(app_id, channel, pid) }, ->(result) { handle_process_result(app_id, channel, result) })
rescue Errno::EINVAL => e
retryable = !attempted
attempted = true
# Assume that we're on windoze and that the game requires admin
retry if retryable
# TODO: Show an error message if we reach here...
end
end
end
def monitor_process(app_id, channel, pid)
key = "#{app_id}-#{channel}"
@running_applications[key] = pid
status = Process::Status.wait(pid)
pp [pid, status]
@running_applications.delete(key)
status
end
def handle_process_result(app_id, channel, status)
pp [app_id, channel, status]
# Everything's fine
return if status.pid >= 0 && status.success?
# Everything's not fine
reason = status.pid.positive? ? "Crashed" : "Failed to Launch"
game = Store.applications.games.find { |g| g.id == app_id }
title = "#{reason}: #{game.name}" if game
title = "Application #{reason}" unless game
message = if status.pid.negative?
"Command Not Found."
else
"Application crashed."
end
push_state(
States::MessageDialog,
title: title,
message: message,
accept_callback: proc {
}
)
end
def join_server(app_id, channel, server, username = Store.settings[:server_list_username], password = nil, multi = false)
return unless installed?(app_id, channel) && username.to_s.length.positive?
run(
app_id, channel,
"+connect #{server.address}:#{server.port} +netplayername #{username}#{password ? " +password \"#{password}\"" : ""}#{multi ? " +multi" : ""}"
)
end
def play_now_server(app_id, channel)
app_data = installed?(app_id, channel)
return nil unless app_data
found_server = Store.server_list.select do |server|
server.game == app_id && server.channel == channel && !server.status.password
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
# sort by player count HIGH to LOW
# and by ping LOW to HIGH
server_options.sort! do |a, b|
[b.status.player_count, a.ping] <=> [a.status.player_count, b.ping]
end
found_server ? found_server : nil
# 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
end
def play_now(app_id, channel)
@@ -256,7 +382,7 @@ class W3DHub
end
end
def favorive(app_id, bool)
def favorite(app_id, bool)
Store.settings[:favorites] ||= {}
if bool
@@ -314,10 +440,11 @@ class W3DHub
begin
reg_constant.open(registry_path, reg_type) do |reg|
if (install_path = reg["InstallDir"])
if (install_path = reg["InstallPath"])
install_path = File.dirname(install_path)
install_path.gsub!("\\", "/")
exe_path = app_id == "ecw" ? "#{install_path}/game750.exe" : "#{install_path}/game.exe"
exe_path = app_id == "ecw" ? "#{install_path}/game500.exe" : "#{install_path}/game.exe"
if File.exist?(exe_path)
installed_version = app_id == "ren" ? "1.0.0.0" : reg["InstalledVersion"]
@@ -383,17 +510,19 @@ class W3DHub
false
end
def imported!(task, exe_path)
def imported!(application, channel, exe_path)
exe_path.gsub!("\\", "/")
application_data = {
name: task.application.name,
name: application.name,
install_directory: File.dirname(exe_path),
installed_version: task.channel.current_version,
installed_version: channel.current_version,
install_path: exe_path,
wine_prefix: task.wine_prefix
wine_prefix: nil
}
Store.settings[:games] ||= {}
Store.settings[:games][:"#{task.app_id}_#{task.release_channel}"] = application_data
Store.settings[:games][:"#{application.id}_#{channel.id}"] = application_data
Store.settings.save_settings
end
@@ -404,6 +533,8 @@ class W3DHub
# wine_prefix # optional
install_directory = Cache.install_path(task.application, task.channel)
install_directory.gsub!("\\", "/")
application_data = {
name: task.application.name,
install_directory: install_directory,
@@ -424,7 +555,7 @@ class W3DHub
end
def installing?(app_id, channel)
@tasks.find { |t| t.is_a?(Installer) && t.app_id == app_id && t.release_channel == channel }
@tasks.find { |t| t.is_a?(Installer) && t.context.app_id == app_id && t.context.channel_id == channel }
end
def updateable?(app_id, channel)
@@ -459,6 +590,72 @@ class W3DHub
Store.applications.games.detect { |g| g.id == app_id }&.name
end
def channel_name(app_id, channel_id)
app = Store.applications.games.detect { |g| g.id.to_s == app_id.to_s }
return unless app
app.channels.detect { |g| g.id.to_s == channel_id.to_s }&.name
end
def application(app_id)
Store.applications.games.detect { |g| g.id.to_s == app_id.to_s }
end
def channel(app_id, channel_id)
app = Store.applications.games.detect { |g| g.id.to_s == app_id.to_s }
return unless app
app.channels.detect { |g| g.id.to_s == channel_id.to_s }
end
def handle_task_event(event)
# ONLY CALL on MAIN Ractor
raise "Something has gone horribly wrong!" unless Ractor.main?
pp event
task = @tasks.find { |t| t.context.task_id == event.task_id }
return unless task # FIXME: This is probably a fatal error
case event.type
when Task::EVENT_FAILURE
Store.main_thread_queue << proc do
window.push_state(
W3DHub::States::MessageDialog,
type: event.data[:type],
title: event.data[:title],
message: event.data[:message]
)
end
# FIXME: Send event to Games page to trigger refresh
States::Interface.instance&.hide_application_taskbar
@tasks.delete(task)
when Task::EVENT_START
States::Interface.instance&.show_application_taskbar
when Task::EVENT_SUCCESS
States::Interface.instance&.hide_application_taskbar
@tasks.delete(task)
# FIXME: Send event to Games page to trigger refresh
when Task::EVENT_STATUS
task.status = event.data
States::Interface.instance&.update_interface_task_status(task)
when Task::EVENT_STATUS_OPERATION
hash = event.data
operation = task.status.operations[operation[:id]]
if operation
operation.label = hash[:label]
operation.value = hash[:value]
operation.progress = hash[:progress]
States::Interface.instance&.update_interface_task_status(task)
end
end
end
# No application tasks are being done
def idle?
!busy?
@@ -479,15 +676,37 @@ class W3DHub
@tasks.delete_if { |t| t.state == :complete || t.state == :halted || t.state == :failed }
task = @tasks.find { |t| t.state == :not_started }
task&.start
return unless task
# mark MAIN ractor's task as started before handing off to background ractor
# so that we don't start up multiple tasks at once.
task.start
on_ractor(task)
end
def on_ractor(task)
raise "Something has gone horribly wrong!!!" unless Ractor.main?
ractor = Ractor.new(task) do |t|
t.start
end
Thread.new do
while (message_event = ractor.take)
break unless message_event.is_a?(Task::MessageEvent)
Store.application_manager.handle_task_event(message_event)
end
end
end
def task?(type, app_id, channel)
@tasks.find do |t|
t.type == type &&
t.app_id == app_id &&
t.release_channel == channel &&
[ :not_started, :running, :paused ].include?(t.state)
t.context.app_id == app_id &&
t.context.channel_id == channel &&
[ :not_started, :running, :paused ].include?(t.state)
end
end
end

View File

@@ -30,7 +30,7 @@ class W3DHub
def parse_files
@document.root.elements.each("//File") do |element|
@files.push(ManifestFile.new(element))
@files.push(ManifestFile.new(element, @version))
end
end
@@ -42,9 +42,9 @@ class W3DHub
# TODO: Support patches
class ManifestFile
attr_reader :name, :checksum, :package, :removed_since, :from
attr_reader :name, :checksum, :package, :removed_since, :from, :version
def initialize(xml)
def initialize(xml, version)
@data = xml
@name = @data["name"]
@@ -58,6 +58,8 @@ class W3DHub
@from = patch["from"]
@package = patch["package"]
end
@version = version
end
def removed?

View File

@@ -1,10 +1,10 @@
class W3DHub
class ApplicationManager
class Status
attr_reader :application, :channel, :step, :operations, :data
attr_accessor :label, :value, :progress
attr_reader :application, :channel, :operations, :data
attr_accessor :label, :value, :progress, :step
def initialize(application:, channel:, label: "", value: "", progress: 0.0, step: :pending, operations: {}, &callback)
def initialize(application:, channel:, label: "", value: "", progress: 0.0, step: :pending, operations: {})
@application = application
@channel = channel
@@ -15,17 +15,10 @@ class W3DHub
@step = step
@operations = operations
@callback = callback
@data = {}
end
def step=(sym)
@step = sym
@callback&.call(self)
@step
end
class Operation
attr_accessor :label, :value, :progress

View File

@@ -27,8 +27,9 @@ class W3DHub
@bytes_downloaded = -1
@manifests = []
@files = {}
@files = []
@packages = []
@deleted_files = [] # TODO: remove removed files
@wine_prefix = nil
@@ -61,7 +62,7 @@ class W3DHub
status = execute_task
rescue FailFast
# no-op
rescue StandardError, ERRNO::EACCES => e
rescue StandardError, Errno::EACCES => e
status = false
@task_failure_reason = e.message[0..512]
@@ -77,7 +78,7 @@ class W3DHub
@task_state = :complete unless @task_state == :failed
hide_application_taskbar if @task_state == :failed
send_message_dialog(:failure, "Task #{type.inspect} failed for #{@application.name}", @task_failure_reason) if @task_state == :failed && !@fail_silently
send_message_dialog(:failure, "#{type.to_s.capitalize} Task failed for #{@application.name}", @task_failure_reason) if @task_state == :failed && !@fail_silently
# end
end
end
@@ -111,6 +112,38 @@ class W3DHub
@task_state == :failed
end
def normalize_path(path, base_path)
path = path.to_s.gsub("\\", "/")
return "#{base_path}/#{path}" if W3DHub.windows? # Windows is easy, or annoying, depending how you look at it...
constructed_path = base_path
lowercase_full_path = "#{base_path}/#{path}".downcase.strip.freeze
accepted_parts = 0
split_path = path.split("/")
split_path.each do |segment|
Dir.glob("#{constructed_path}/*").each do |part|
next unless "#{constructed_path}/#{segment}".downcase == part.downcase
# Handle edge case where a file with the same name is in a higher directory
next if File.file?(part) && part.downcase.strip != lowercase_full_path
constructed_path = part
accepted_parts += 1
break
end
end
# Find file if it exists else use provided path as cased
if constructed_path.downcase.strip == lowercase_full_path
constructed_path
elsif accepted_parts.positive?
"#{constructed_path}/#{split_path[accepted_parts..].join('/')}"
else
"#{base_path}/#{path}" # File doesn't exist, case doesn't matter.
end
end
def failure_reason
@task_failure_reason || ""
end
@@ -150,7 +183,8 @@ class W3DHub
# Wine present?
if W3DHub.unix?
wine_present = W3DHub.command("which #{Store.settings[:wine_command]}")
fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed. Will be unable to create prefixes or launch games.") unless wine_present
fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed.\n\n"\
"Will be unable to launch game.\n\nCheck wine options in launcher's settings.") unless wine_present
end
end
@@ -207,16 +241,23 @@ class W3DHub
@manifests << manifest
until(manifest.full?)
fetch_manifest("games", app_id, "manifest.xml", manifest.base_version)
manifest = load_manifest("games", app_id, "manifest.xml", manifest.base_version)
manifests << manifest
if fetch_manifest("games", app_id, "manifest.xml", manifest.base_version)
manifest = load_manifest("games", app_id, "manifest.xml", manifest.base_version)
manifests << manifest
else
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{manifest.base_version}")
return []
end
end
else
fail!("Failed to retrieve manifest: games:#{app_id}:manifest.xml-#{@target_version}")
return []
end
@manifests
end
def build_package_list(manifests)
def build_package_list
@status.operations.clear
@status.label = "Downloading #{@application.name}..."
@status.value = "Building package list..."
@@ -224,44 +265,48 @@ class W3DHub
@status.step = :build_package_list
packages = []
manifests.reverse.each do |manifest|
# Process manifest game files in OLDEST to NEWEST order so we can simply remove preceeding files from the array that aren't needed
@manifests.reverse.each do |manifest|
logger.info(LOG_TAG) { "#{manifest.game}-#{manifest.type}: #{manifest.version} (#{manifest.base_version})" }
manifest.files.each do |file|
@files["#{file.name}:#{manifest.version}"] = file
next if file.removed? # No package data
# if file.patch?
# fail!("#{@application.name} requires patches. Patching is not yet supported.")
# break
# end
next if packages.detect do |pkg|
pkg.category == "games" &&
pkg.subcategory == @app_id &&
pkg.name == file.package &&
pkg.version == manifest.version
if file.removed? # No package data
@files.delete_if { |f| f.name.casecmp?(file.name) }
@deleted_files.push(file)
next
end
packages.push(
Api::Package.new(
{ category: "games", subcategory: @app_id, name: file.package, version: manifest.version }
)
)
@files.delete_if { |f| f.name.casecmp?(file.name) } unless file.patch?
packages.last.is_patch = file if file.patch?
# If file has been recreated in a newer patch, don't delete it;
# A full file package will exist for it so it will get completely replaced.
@deleted_files.delete_if { |f| f.name.casecmp?(file.name) }
@files.push(file)
end
# TODO: Dependencies
end
packages
@files.each do |file|
next if packages.detect do |pkg|
pkg.category == "games" &&
pkg.subcategory == @app_id &&
pkg.name.to_s.casecmp?(file.package.to_s) &&
pkg.version == file.version
end
package = Api::Package.new({ category: "games", subcategory: @app_id, name: file.package, version: file.version })
package.is_patch = file if file.patch?
packages.push(package)
end
@packages = packages
end
def verify_files(manifests, packages)
def verify_files
@status.operations.clear
@status.label = "Downloading #{@application.name}..."
@status.value = "Verifying installed files..."
@@ -278,46 +323,42 @@ class W3DHub
folder_exists = File.directory?(path)
manifests.each do |manifest|
# Process manifest game files in NEWEST to OLDEST order so that we don't erroneously flag
# valid files as invalid due to an OLDER version of the file being checked FIRST.
@files.reverse.each do |file|
break unless folder_exists
manifest.files.each do |file|
safe_file_name = file.name.gsub("\\", "/")
# Fix borked Data -> data 'cause Windows don't care about capitalization
safe_file_name.sub!("Data/", "data/")
file_path = normalize_path(file.name, path)
file_path = "#{path}/#{safe_file_name}"
processed_files += 1
@status.progress = processed_files.to_f / file_count
processed_files += 1
@status.progress = processed_files.to_f / file_count
next if file.removed_since
next if accepted_files.key?(file_path)
next if file.removed_since
next if accepted_files.key?(safe_file_name)
unless File.exist?(file_path)
rejected_files << { file: file, manifest_version: file.version }
logger.info(LOG_TAG) { "[#{file.version}] File missing: #{file_path}" }
next
end
unless File.exist?(file_path)
rejected_files << { file: file, manifest_version: manifest.version }
logger.info(LOG_TAG) { "[#{manifest.version}] File missing: #{file_path}" }
next
end
digest = Digest::SHA256.new
f = File.open(file_path)
digest = Digest::SHA256.new
f = File.open(file_path)
while (chunk = f.read(32_000_000))
digest.update(chunk)
end
while (chunk = f.read(32_000_000))
digest.update(chunk)
end
f.close
f.close
logger.info(LOG_TAG) { file.inspect } if file.checksum.nil?
logger.info(LOG_TAG) { file.inspect } if file.checksum.nil?
if digest.hexdigest.upcase == file.checksum.upcase
accepted_files[safe_file_name] = manifest.version
logger.info(LOG_TAG) { "[#{manifest.version}] Verified file: #{file_path}" }
else
rejected_files << { file: file, manifest_version: manifest.version }
logger.info(LOG_TAG) { "[#{manifest.version}] File failed Verification: #{file_path}" }
end
if digest.hexdigest.upcase == file.checksum.upcase
accepted_files[file_path] = file.version
logger.info(LOG_TAG) { "[#{file.version}] Verified file: #{file_path}" }
else
rejected_files << { file: file, manifest_version: file.version }
logger.info(LOG_TAG) { "[#{file.version}] File failed Verification: #{file_path}" }
end
end
@@ -329,7 +370,7 @@ class W3DHub
rejected_files.each do |hash|
next if selected_packages_hash["#{hash[:file].package}_#{hash[:manifest_version]}"]
package = packages.find { |pkg| pkg.name == hash[:file].package && pkg.version == hash[:manifest_version] }
package = @packages.find { |pkg| pkg.name.casecmp?(hash[:file].package) && pkg.version == hash[:manifest_version] }
if package
selected_packages_hash["#{hash[:file].package}_#{hash[:manifest_version]}"] = true
@@ -340,13 +381,15 @@ class W3DHub
end
# Removed packages that don't need to be fetched or processed
packages.delete_if { |package| !selected_packages.find { |pkg| pkg == package } } if folder_exists
@packages.delete_if { |package| !selected_packages.find { |pkg| pkg == package } } if folder_exists
packages
@packages
end
def fetch_packages(packages)
hashes = packages.map do |pkg|
def fetch_packages
return if @packages.empty?
hashes = @packages.map do |pkg|
{
category: pkg.category,
subcategory: pkg.subcategory,
@@ -355,98 +398,103 @@ class W3DHub
}
end
package_details = Api.package_details(hashes)
package_details = Api.package_details(hashes, @channel.source || :w3dhub)
if package_details
@packages = [package_details].flatten
@packages.each do |rich|
package = packages.find do |pkg|
pkg.category == rich.category &&
pkg.subcategory == rich.subcategory &&
"#{pkg.name}.zip" == rich.name &&
pkg.version == rich.version
end
package.instance_variable_set(:"@name", rich.name)
package.instance_variable_set(:"@size", rich.size)
package.instance_variable_set(:"@checksum", rich.checksum)
package.instance_variable_set(:"@checksum_chunk_size", rich.checksum_chunk_size)
package.instance_variable_set(:"@checksum_chunks", rich.checksum_chunks)
end
@packages_to_download = []
@status.label = "Downloading #{@application.name}..."
@status.value = "Verifying local packages..."
@status.progress = 0.0
package_details.each do |pkg|
@status.operations[:"#{pkg.checksum}"] = Status::Operation.new(
label: pkg.name,
value: "Pending...",
progress: 0.0
)
end
@status.step = :prefetch_verifying_packages
package_details.each_with_index.each do |pkg, i|
operation = @status.operations[:"#{pkg.checksum}"]
if verify_package(pkg)
operation.value = "Verified"
operation.progress = 1.0
else
@packages_to_download << pkg
operation.value = "#{W3DHub.format_size(pkg.custom_partially_valid_at_bytes)} / #{W3DHub.format_size(pkg.size)}"
operation.progress = pkg.custom_partially_valid_at_bytes.to_f / pkg.size
end
@status.progress = i.to_f / package_details.count
update_interface_task_status
end
@status.operations.delete_if { |key, o| o.progress >= 1.0 }
@status.step = :fetch_packages
@total_bytes_to_download = @packages_to_download.sum { |pkg| pkg.size - pkg.custom_partially_valid_at_bytes }
@bytes_downloaded = 0
pool = Pool.new(workers: Store.settings[:parallel_downloads])
@packages_to_download.each do |pkg|
pool.add_job Pool::Job.new( proc {
package_bytes_downloaded = pkg.custom_partially_valid_at_bytes
package_fetch(pkg) do |chunk, remaining_bytes, total_bytes|
@bytes_downloaded += chunk.to_s.length
package_bytes_downloaded += chunk.to_s.length
@status.value = "#{W3DHub.format_size(@bytes_downloaded)} / #{W3DHub.format_size(@total_bytes_to_download)}"
@status.progress = @bytes_downloaded.to_f / @total_bytes_to_download
operation = @status.operations[:"#{pkg.checksum}"]
operation.value = "#{W3DHub.format_size(package_bytes_downloaded)} / #{W3DHub.format_size(pkg.size)}"
operation.progress = package_bytes_downloaded.to_f / pkg.size # total_bytes
update_interface_task_status
end
})
end
pool.manage_pool
else
unless package_details
fail!("Failed to fetch package details")
return
end
[package_details].flatten.each do |rich|
if rich.error?
fail!("Failed to retrieve package details! (#{rich.category}:#{rich.subcategory}:#{rich.name}:#{rich.version})\nError: #{rich.error.gsub("-", " ").capitalize}")
return
end
package = @packages.find do |pkg|
pkg.category.to_s.casecmp?(rich.category.to_s) &&
pkg.subcategory.to_s.casecmp?(rich.subcategory.to_s) &&
"#{pkg.name}.zip".casecmp?(rich.name) &&
pkg.version == rich.version
end
package.instance_variable_set(:"@name", rich.name)
package.instance_variable_set(:"@size", rich.size)
package.instance_variable_set(:"@checksum", rich.checksum)
package.instance_variable_set(:"@checksum_chunk_size", rich.checksum_chunk_size)
package.instance_variable_set(:"@checksum_chunks", rich.checksum_chunks)
end
@packages_to_download = []
@status.label = "Downloading #{@application.name}..."
@status.value = "Verifying local packages..."
@status.progress = 0.0
package_details.each do |pkg|
@status.operations[:"#{pkg.checksum}"] = Status::Operation.new(
label: pkg.name,
value: "Pending...",
progress: 0.0
)
end
@status.step = :prefetch_verifying_packages
package_details.each_with_index.each do |pkg, i|
operation = @status.operations[:"#{pkg.checksum}"]
if verify_package(pkg)
operation.value = "Verified"
operation.progress = 1.0
else
@packages_to_download << pkg
operation.value = "#{W3DHub.format_size(pkg.custom_partially_valid_at_bytes)} / #{W3DHub.format_size(pkg.size)}"
operation.progress = pkg.custom_partially_valid_at_bytes.to_f / pkg.size
end
@status.progress = i.to_f / package_details.count
update_interface_task_status
end
@status.operations.delete_if { |key, o| o.progress >= 1.0 }
@status.step = :fetch_packages
@total_bytes_to_download = @packages_to_download.sum { |pkg| pkg.size - pkg.custom_partially_valid_at_bytes }
@bytes_downloaded = 0
pool = Pool.new(workers: Store.settings[:parallel_downloads])
@packages_to_download.each do |pkg|
pool.add_job Pool::Job.new( proc {
package_bytes_downloaded = pkg.custom_partially_valid_at_bytes
package_fetch(pkg) do |chunk, remaining_bytes, total_bytes|
@bytes_downloaded += chunk.to_s.length
package_bytes_downloaded += chunk.to_s.length
@status.value = "#{W3DHub.format_size(@bytes_downloaded)} / #{W3DHub.format_size(@total_bytes_to_download)}"
@status.progress = @bytes_downloaded.to_f / @total_bytes_to_download
operation = @status.operations[:"#{pkg.checksum}"]
operation.value = "#{W3DHub.format_size(package_bytes_downloaded)} / #{W3DHub.format_size(pkg.size)}"
operation.progress = package_bytes_downloaded.to_f / pkg.size # total_bytes
update_interface_task_status
end
})
end
pool.manage_pool
end
def verify_packages(packages)
def verify_packages
end
def unpack_packages(packages)
def unpack_packages
path = Cache.install_path(@application, @channel)
logger.info(LOG_TAG) { "Unpacking packages in '#{path}'..." }
Cache.create_directories(path, true)
@@ -456,7 +504,7 @@ class W3DHub
@status.value = "Unpacking..."
@status.progress = 0.0
packages.each do |pkg|
@packages.each do |pkg|
# FIXME: can't add a new key into hash during iteration (RuntimeError)
@status.operations[:"#{pkg.checksum}"] = Status::Operation.new(
label: pkg.name,
@@ -468,7 +516,7 @@ class W3DHub
@status.step = :unpacking
i = -1
packages.each do |package|
@packages.each do |package|
i += 1
status = if package.custom_is_patch
@@ -501,6 +549,23 @@ class W3DHub
end
end
def remove_deleted_files
return unless @deleted_files.size.positive?
logger.info(LOG_TAG) { "Removing dead files..." }
@deleted_files.each do |file|
logger.info(LOG_TAG) { " #{file.name}" }
path = Cache.install_path(@application, @channel)
file_path = normalize_path(file.name, path)
File.delete(file_path) if File.exist?(file_path)
logger.info(LOG_TAG) { " removed." }
end
end
def create_wine_prefix
if W3DHub.unix? && @wine_prefix
# TODO: create a wine prefix if configured
@@ -513,7 +578,7 @@ class W3DHub
end
end
def install_dependencies(packages)
def install_dependencies
# TODO: install dependencies
@status.operations.clear
@status.label = "Installing #{@application.name}..."
@@ -523,6 +588,22 @@ class W3DHub
@status.step = :install_dependencies
end
def write_paths_ini
path = Cache.install_path(@application, @channel)
File.open(normalize_path("data/paths.ini", path), "w") do |file|
file.puts("[paths]")
file.puts("RegBase=W3D Hub")
file.puts("RegClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
file.puts("RegFDS=#{@application.category}\\#{@application.id}-#{@channel.id}-server")
file.puts("FileBase=W3D Hub");
file.puts("FileClient=#{@application.category}\\#{@application.id}-#{@channel.id}")
file.puts("FileFDS=#{@application.category}\\#{@application.id}-#{@channel.id}-server")
file.puts("UseRenFolder=#{@application.uses_ren_folder?}")
end
end
def mark_application_installed
Store.application_manager.installed!(self)
@@ -544,12 +625,17 @@ class W3DHub
# Check for and integrity of local manifest
package = nil
array = Api.package_details([{ category: category, subcategory: subcategory, name: name, version: version }])
array = Api.package_details([{ category: category, subcategory: subcategory, name: name, version: version }], @channel.source || :w3dhub)
if array.is_a?(Array)
package = array.first
else
fail!("Failed to fetch manifest package details!")
return
fail!("Failed to fetch manifest package details! (#{category}:#{subcategory}:#{name}:#{version})")
return false
end
if package.error?
fail!("Failed to retrieve manifest package details! (#{category}:#{subcategory}:#{name}:#{version})\nError: #{package.error.gsub("-", " ").capitalize}")
return false
end
if File.exist?(Cache.package_path(category, subcategory, name, version))
@@ -567,9 +653,13 @@ class W3DHub
def package_fetch(package, &block)
logger.info(LOG_TAG) { "Downloading: #{package.category}:#{package.subcategory}:#{package.name}-#{package.version}" }
Api.package(package) do |chunk, remaining_bytes, total_bytes|
status_okay = Api.package(package) do |chunk, remaining_bytes, total_bytes|
block&.call(chunk, remaining_bytes, total_bytes)
end
fail!("Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})") unless status_okay
status_okay
end
def verify_package(package, &block)
@@ -581,7 +671,7 @@ class W3DHub
return false unless File.exist?(path)
operation = @status.operations[:"#{package.checksum}"]
operation&.value = "Verifying..."
operation&.value = "Verifying..."
file_size = File.size(path)
logger.info(LOG_TAG) { " File size: #{file_size}" }
@@ -646,38 +736,52 @@ class W3DHub
logger.info(LOG_TAG) { " Unpacking patch \"#{package_path}\" in \"#{temp_path}\"" }
unzip(package_path, temp_path)
# Fix borked Data -> data 'cause Windows don't care about capitalization
safe_file_name = "#{manifest_file.name.sub('Data/', 'data/')}"
file_path = normalize_path(manifest_file.name, path)
temp_file_path = normalize_path(manifest_file.name, temp_path)
logger.info(LOG_TAG) { " Loading #{temp_path}/#{safe_file_name}.patch..." }
patch_mix = W3DHub::Mixer::Reader.new(file_path: "#{temp_path}/#{safe_file_name}.patch", ignore_crc_mismatches: false)
patch_info = JSON.parse(patch_mix.package.files.find { |f| f.name == ".w3dhub.patch" || f.name == ".bhppatch" }.data, symbolize_names: true)
logger.info(LOG_TAG) { " Loading #{temp_file_path}.patch..." }
patch_mix = W3DHub::WWMix.new(path: "#{temp_file_path}.patch")
unless patch_mix.load
raise patch_mix.error_reason
end
patch_entry = patch_mix.entries.find { |e| e.name.casecmp?(".w3dhub.patch") || e.name.casecmp?(".bhppatch") }
patch_entry.read
# "remove" patch meta file from patch before copying patch data
patch_mix.entries.delete(patch_entry)
logger.info(LOG_TAG) { " Loading #{path}/#{safe_file_name}..." }
target_mix = W3DHub::Mixer::Reader.new(file_path: "#{path}/#{safe_file_name}", ignore_crc_mismatches: false)
patch_info = JSON.parse(patch_entry.blob, symbolize_names: true)
logger.info(LOG_TAG) { " Loading #{file_path}..." }
target_mix = W3DHub::WWMix.new(path: "#{file_path}")
unless target_mix.load
raise target_mix.error_reason
end
logger.info(LOG_TAG) { " Removing files..." } if patch_info[:removedFiles].size.positive?
patch_info[:removedFiles].each do |file|
logger.debug(LOG_TAG) { " #{file}" }
target_mix.package.files.delete_if { |f| f.name == 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 == file }
target = target_mix.package.files.find { |f| f.name == file }
if target
target_mix.package.files[target_mix.package.files.index(target)] = patch
else
target_mix.package.files << patch
patch_mix.entries.each do |entry|
target_mix.add_entry(entry: entry, replace: true)
end
end
logger.info(LOG_TAG) { " Writing updated #{path}/#{safe_file_name}..." } if patch_info[:updatedFiles].size.positive?
W3DHub::Mixer::Writer.new(file_path: "#{path}/#{safe_file_name}", package: target_mix.package, memory_buffer: true)
logger.info(LOG_TAG) { " Writing updated #{file_path}..." } if patch_info[:updatedFiles].size.positive?
temp_mix_path = "#{temp_path}/#{File.basename(file_path)}"
temp_mix = W3DHub::WWMix.new(path: temp_mix_path, encrypted: target_mix.encrypted?)
target_mix.entries.each { |e| temp_mix.add_entry(entry: e, replace: true) }
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)
@@ -688,19 +792,17 @@ class W3DHub
stream = Zip::InputStream.new(File.open(package_path))
while (entry = stream.get_next_entry)
# Normalize the path to handle case-insensitivity consistently
file_path = normalize_path(entry.name, path)
safe_file_name = entry.name.gsub("\\", "/")
# Fix borked Data -> data 'cause Windows don't care about capitalization
safe_file_name.sub!("Data/", "data/")
dir_path = "#{path}/#{File.dirname(safe_file_name)}"
dir_path = File.dirname(file_path)
unless dir_path.end_with?("/.") || Dir.exist?(dir_path)
FileUtils.mkdir_p(dir_path)
end
File.open("#{path}/#{safe_file_name}", "wb") do |f|
File.open(file_path, "wb") do |f|
i = entry.get_input_stream
while (chunk = i.read(32_000_000)) # Read up to ~32 MB per chunk
f.write chunk
end

View File

@@ -1,26 +0,0 @@
class W3DHub
class ApplicationManager
class Importer < Task
LOG_TAG = "W3DHub::ApplicationManager::Importer".freeze
def type
:importer
end
def execute_task
path = W3DHub.ask_file
unless File.exist?(path) && !File.directory?(path)
fail!("File #{path.inspect} does not exist or is a directory")
fail_silently! if path.nil? || path&.length&.zero? # User likely canceled the file selection
end
return false if failed?
Store.application_manager.imported!(self, path)
true
end
end
end
end

View File

@@ -8,43 +8,44 @@ class W3DHub
end
def execute_task
show_application_taskbar
fail_fast
fail_fast!
return false if failed?
manifests = fetch_manifests
fetch_manifests
return false if failed?
packages = build_package_list(manifests)
build_package_list
return false if failed?
verify_files(manifests, packages)
remove_deleted_files
return false if failed?
fetch_packages(packages)
verify_files
return false if failed?
verify_packages(packages)
fetch_packages
return false if failed?
unpack_packages(packages)
verify_packages
return false if failed?
unpack_packages
return false if failed?
create_wine_prefix
return false if failed?
install_dependencies(packages)
install_dependencies
return false if failed?
write_paths_ini
return false if failed?
mark_application_installed
return false if failed?
sleep 1
hide_application_taskbar
true
end
end
end
end
end

View File

@@ -6,24 +6,6 @@ class W3DHub
def type
:repairer
end
# def execute_task
# fail_fast
# return false if failed?
# manifests = fetch_manifests
# return false if failed?
# packages = build_package_list(manifests)
# return false if failed?
# verify_files(manifests, packages)
# return false if failed?
# # pp packages.select { |pkg| pkg.name == "misc" }
# true
# end
end
end
end

View File

@@ -23,7 +23,12 @@ class W3DHub
def self.verify_peer
no_verify.tap do |context|
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
context.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
context.cert_store = OpenSSL::X509::Store.new
if (ca_file = W3DHub.ca_bundle_path)
context.cert_store.add_file(ca_file)
else
context.cert_store.set_default_paths
end
end
end

View File

@@ -7,31 +7,31 @@ class W3DHub
theme W3DHub::THEME
background 0xaa_444444
background 0x88_525252
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 256, v_align: :center, h_align: :center, background: 0xff_222222) do
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 268, v_align: :center, h_align: :center, background: 0xee_222222) do
# Title bar
flow(width: 1.0, height: 32, padding: 8) do
flow(width: 1.0, height: 36, padding: 8) do
background 0x88_000000
# image "#{GAME_ROOT_PATH}/media/ui_icons/export.png", width: 32, align: :center, color: 0xaa_ffffff
# tagline "<b>#{I18n.t(:"server_browser.direct_connect")}</b>", fill: true, text_align: :center
tagline @game ? "Update Game" : "Add Game", width: 1.0, text_align: :center
title @game ? "Update Game" : "Add Game", width: 1.0, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding_left: 8, padding_right: 8) do
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "Game or Mod Title:"
@game_title = edit_line "#{@game&.title}", width: 1.0, fill: true
end
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "Path to Executable:"
flow(width: 1.0, fill: true) do
@game_path = edit_line "#{@game&.path}", fill: true, height: 1.0
button "Browse...", width: 128, height: 1.0, enabled: W3DHub.unix?, tip: W3DHub.unix? ? "Browse for game executable" : "Not available on Windows" do
button "Browse...", width: 128, height: 1.0, tip: "Browse for game executable" do
path = W3DHub.ask_file
@game_path.value = path if !path.empty? && File.exist?(path)
end
@@ -40,7 +40,7 @@ class W3DHub
flow(fill: true)
flow(width: 1.0, margin_top: 8, height: 40, padding_bottom: 8) do
flow(width: 1.0, margin_top: 8, height: 46, padding_bottom: 8) do
button "Cancel", fill: true, margin_right: 4 do
pop_state
end

View File

@@ -7,24 +7,24 @@ class W3DHub
theme W3DHub::THEME
background 0xaa_444444
background 0x88_525252
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 560, v_align: :center, h_align: :center, background: 0xff_222222) do
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 610, v_align: :center, h_align: :center, background: 0xee_222222) do
# Title bar
flow(width: 1.0, height: 32, padding: 8) do
flow(width: 1.0, height: 36, padding: 8) do
background 0x88_000000
# tagline "<b>#{I18n.t(:"server_browser.direct_connect")}</b>", fill: true, text_align: :center
tagline @profile ? "Update IRC Profile" : "Add IRC Profile", width: 1.0, fill: true, text_align: :center
title @profile ? "Update IRC Profile" : "Add IRC Profile", width: 1.0, fill: true, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding_left: 8, padding_right: 8) do
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "IRC Nickname:"
@nickname = edit_line "#{@profile&.nickname}", width: 1.0, fill: true
end
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
flow(width: 1.0, height: 1.0) do
stack(width: 0.5, height: 1.0) do
para "IRC Username (Optional):"
@@ -38,7 +38,7 @@ class W3DHub
end
end
stack(width: 1.0, height: 66, margin_top: 32) do
stack(width: 1.0, height: 72, margin_top: 32) do
flow(width: 1.0, height: 1.0) do
stack(width: 0.75, height: 1.0) do
para "IRC Server IP or Hostname:"
@@ -52,23 +52,30 @@ class W3DHub
end
end
flow(width: 1.0, height: 66, margin_top: 8) do
@server_ssl = check_box "IRC Server Use SSL", checked: @profile&.server_ssl, text_size: 18, width: 0.5, height: 66
@server_verify_ssl = check_box "IRC Verify Server SSL Certificate", checked: @profile ? @profile.server_verify_ssl : true, text_size: 18, width: 0.5, height: 66
flow(width: 1.0, height: 72, margin_top: 8) do
flow(width: 0.5, height: 1.0) do
@server_ssl = toggle_button checked: @profile&.server_ssl, text_size: 18, height: 18
para "IRC Server Use SSL", fill: true, text_wrap: :none, margin_left: 8
end
flow(width: 0.5, height: 1.0) do
@server_verify_ssl = toggle_button checked: @profile ? @profile.server_verify_ssl : true, text_size: 18, height: 18
para "IRC Verify Server SSL Certificate", fill: true, text_wrap: :none, margin_left: 8
end
end
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "Brenbot Bot Name:"
@bot_username = edit_line "#{@profile&.bot_username}", width: 1.0, fill: true
end
flow(width: 1.0, height: 66) do
stack(width: 0.5, height: 66) do
flow(width: 1.0, height: 72) do
stack(width: 0.5, height: 72) do
para "Brenbot Auth Username:"
@bot_auth_username = edit_line "#{@profile&.bot_auth_username}", width: 1.0, fill: true
end
stack(width: 0.5, height: 66) do
stack(width: 0.5, height: 72) do
para "Brenbot Auth Password:"
@bot_auth_password = edit_line @profile && @profile.bot_auth_password ? Base64.strict_decode64(@profile.bot_auth_password) : "", width: 1.0, fill: true, type: :password
end
@@ -76,7 +83,7 @@ class W3DHub
flow(fill: true)
flow(width: 1.0, margin_top: 8, height: 40, padding_bottom: 8) do
flow(width: 1.0, margin_top: 8, height: 46, padding_bottom: 8) do
button "Cancel", fill: true, margin_right: 4 do
pop_state
end

View File

@@ -7,21 +7,21 @@ class W3DHub
theme W3DHub::THEME
background 0xaa_444444
background 0x88_525252
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 256, v_align: :center, h_align: :center, background: 0xff_222222) do
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 272, v_align: :center, h_align: :center, background: 0xee_222222) do
# Title bar
flow(width: 1.0, height: 32, padding: 8) do
flow(width: 1.0, height: 36, padding: 8) do
background 0x88_000000
# image "#{GAME_ROOT_PATH}/media/ui_icons/export.png", width: 32, align: :center, color: 0xaa_ffffff
# tagline "<b>#{I18n.t(:"server_browser.direct_connect")}</b>", fill: true, text_align: :center
tagline @server_profile ? "Update Server Profile" : "Add Server Profile", width: 1.0, text_align: :center
title @server_profile ? "Update Server Profile" : "Add Server Profile", width: 1.0, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding_left: 8, padding_right: 8) do
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "Server Profile Name:"
@server_name = edit_line "#{@server_profile&.name}", width: 1.0, fill: true
@server_name.subscribe(:changed) do |label|
@@ -31,7 +31,7 @@ class W3DHub
flow(fill: true)
flow(width: 1.0, height: 40, padding_bottom: 8) do
flow(width: 1.0, height: 46, padding_bottom: 8) do
button "Cancel", fill: true, margin_right: 4 do
pop_state
end

View File

@@ -1,166 +0,0 @@
class W3DHub
class BackgroundWorker
LOG_TAG = "W3DHub::BackgroundWorker"
@@instance = nil
@@alive = false
def self.create
raise "BackgroundWorker instance already exists!" if @@instance
logger.info(LOG_TAG) { "Starting background job worker..." }
@@thread = Thread.current
@@alive = true
@@run = true
@@instance = self.new
@@instance.handle_jobs
end
def self.instance
@@instance
end
def self.run?
@@run
end
def self.alive?
@@alive
end
def self.busy?
instance&.busy?
end
def self.shutdown!
@@run = false
end
def self.kill!
@@thread.kill
@@instance.kill!
end
def self.job(job, callback, error_handler = nil)
@@instance.add_job(Job.new(job: job, callback: callback, error_handler: error_handler))
end
def self.parallel_job(job, callback, error_handler = nil)
@@instance.add_parallel_job(Job.new(job: job, callback: callback, error_handler: error_handler))
end
def self.foreground_job(job, callback, error_handler = nil)
@@instance.add_job(Job.new(job: job, callback: callback, error_handler: error_handler, deliver_to_queue: true))
end
def self.foreground_parallel_job(job, callback, error_handler = nil)
@@instance.add_parallel_job(Job.new(job: job, callback: callback, error_handler: error_handler, deliver_to_queue: true))
end
def initialize
@busy = false
@jobs = []
# Jobs which are order independent
@parallel_busy = false
@thread_pool = []
@parallel_jobs = []
end
def kill!
@thread_pool.each(&:kill)
logger.info(LOG_TAG) { "Forcefully killed background job worker." }
@@alive = false
end
def handle_jobs
8.times do |i|
Thread.new do
@thread_pool << Thread.current
while BackgroundWorker.run?
job = @parallel_jobs.shift
@parallel_busy = true
begin
job&.do
rescue => e
job&.raise_error(e)
end
@parallel_busy = !@parallel_jobs.empty?
sleep 0.1
end
end
end
Thread.new do
@thread_pool << Thread.current
while BackgroundWorker.run?
job = @jobs.shift
@busy = true
begin
job&.do
rescue => e
job&.raise_error(e)
end
@busy = !@jobs.empty?
sleep 0.1
end
logger.info(LOG_TAG) { "Stopped background job worker." }
@@alive = false
end
end
def add_job(job)
@jobs << job
end
def add_parallel_job(job)
@parallel_jobs << job
end
def busy?
@busy || @parallel_busy
end
class Job
def initialize(job:, callback:, error_handler: nil, deliver_to_queue: false)
@job = job
@callback = callback
@error_handler = error_handler
@deliver_to_queue = deliver_to_queue
end
def do
result = @job.call
deliver(result)
end
def deliver(result)
if @deliver_to_queue
Store.main_thread_queue << -> { @callback.call(result) }
else
@callback.call(result)
end
end
def raise_error(error)
logger.error error
@error_handler&.call(error)
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

@@ -9,19 +9,17 @@ class W3DHub
end
# Fetch a generic uri
def self.fetch(uri:, force_fetch: false, async: true)
def self.fetch(uri:, force_fetch: false, backend: :w3dhub)
path = path(uri)
if !force_fetch && File.exist?(path)
path
elsif async
BackgroundWorker.job(
-> { Api.get(uri, W3DHub::Api::DEFAULT_HEADERS) },
->(response) { File.open(path, "wb") { |f| f.write response.body } if response.status == 200 }
)
else
response = Api.get(uri, W3DHub::Api::DEFAULT_HEADERS)
File.open(path, "wb") { |f| f.write response.body } if response.status == 200
Api.fetch(path: uri, backend: backend) do |result|
if result.okay?
File.open(path, "wb") { |f| f.write result.data }
end
end
end
end
@@ -50,10 +48,16 @@ class W3DHub
end
# Download a W3D Hub package
# TODO: More work needed to make this work reliably
def self._async_fetch_package(package, block)
def self.async_fetch_package(package, block)
endpoint_download_url = package.download_url || "#{Api::W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package"
if package.download_url
uri_path = package.download_url.split("/").last
endpoint_download_url = package.download_url.sub(uri_path, URI.encode_uri_component(uri_path))
end
path = package_path(package.category, package.subcategory, package.name, package.version)
headers = Api::FORM_ENCODED_HEADERS
headers = [["content-type", "application/x-www-form-urlencoded"], ["user-agent", Api::USER_AGENT]]
headers << ["authorization", "Bearer #{Store.account.access_token}"] if Store.account && !package.download_url
body = URI.encode_www_form("data": JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version }))
start_from_bytes = package.custom_partially_valid_at_bytes
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
@@ -63,67 +67,60 @@ class W3DHub
file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb")
if start_from_bytes.positive?
headers = Api::FORM_ENCODED_HEADERS + [["Range", "bytes=#{start_from_bytes}-"]]
headers << ["range", "bytes=#{start_from_bytes}-"]
file.pos = start_from_bytes
end
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
result = false
Sync do
uri = URI(endpoint_download_url)
response = Api.post("#{Api::ENDPOINT}/apis/launcher/1/get-package", headers, body)
response = W3DHub::Api.provision_http_client(uri.origin).send((package.download_url ? :get : :post), uri.request_uri, headers, body)
if response.success?
total_bytes = package.size
total_bytes = package.size
remaining_bytes = total_bytes - start_from_bytes
response.each do |chunk|
file.write(chunk)
response.each do |chunk|
file.write(chunk)
block.call(chunk, total_bytes - file.pos, total_bytes)
end
remaining_bytes -= chunk.size
result = true
end
block.call(chunk, remaining_bytes, total_bytes)
if response&.status == 200 || response&.status == 206
result = true
else
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
result = false
end
rescue Async::Timeout => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" timed out after: #{W3DHub::Api::API_TIMEOUT} seconds" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
result = false
rescue StandardError => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" errored:" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
result = false
ensure
file&.close
response&.close
end
response.status == 200
ensure
file&.close
result
end
# Download a W3D Hub package
def self.fetch_package(package, block)
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
start_from_bytes = package.custom_partially_valid_at_bytes
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
create_directories(path)
file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb")
if start_from_bytes.positive?
headers["Range"] = "bytes=#{start_from_bytes}-"
file.pos = start_from_bytes
end
streamer = lambda do |chunk, remaining_bytes, total_bytes|
file.write(chunk)
block.call(chunk, remaining_bytes, total_bytes)
end
# Create a new connection due to some weirdness somewhere in Excon
response = Excon.post(
"#{Api::ENDPOINT}/apis/launcher/1/get-package",
tcp_nodelay: true,
headers: headers,
body: "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}",
chunk_size: 50_000,
response_block: streamer
)
response.status == 200 || response.status == 206
ensure
file&.close
async_fetch_package(package, block)
end
def self.acquire_net_lock(key)

View File

@@ -1,4 +1,8 @@
class W3DHub
PLATFORM_WINDOWS = RbConfig::CONFIG["host_os"] =~ /(mingw|mswin|windows)/i
PLATFORM_DARWIN = RbConfig::CONFIG["host_os"] =~ /(darwin|mac os)/i
PLATFORM_LINUX = RbConfig::CONFIG["host_os"] =~ /(linux|bsd|aix|solaris)/i
def self.format_size(bytes)
case bytes
when 0..1023 # Bytes
@@ -17,21 +21,30 @@ class W3DHub
end
def self.windows?
RbConfig::CONFIG["host_os"] =~ /(mingw|mswin|windows)/i
PLATFORM_WINDOWS
end
def self.mac?
RbConfig::CONFIG["host_os"] =~ /(darwin|mac os)/i
PLATFORM_DARWIN
end
def self.linux?
RbConfig::CONFIG["host_os"] =~ /(linux|bsd|aix|solaris)/i
PLATFORM_LINUX
end
def self.unix?
linux? || mac?
end
# Detect system CA bundle path for SSL verification
def self.ca_bundle_path
[
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora/CentOS
"/etc/ssl/ca-bundle.pem" # Some other distros
].find { |path| File.exist?(path) }
end
def self.url(path)
raise "Hazardous input: #{path}" if path.include?("&&") || path.include?(";")
@@ -72,15 +85,19 @@ class W3DHub
)
end
def self.join_server(server, password)
def self.join_server(server:, username: Store.settings[:server_list_username], password: nil, multi: false)
if (
(server.status.password && password.length.positive?) ||
!server.status.password) &&
Store.settings[:server_list_username].to_s.length.positive?
username.to_s.length.positive?
Store.application_manager.join_server(
server.game,
server.channel, server, password
server.channel,
server,
username,
password,
multi
)
else
CyberarmEngine::Window.instance.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?")
@@ -127,18 +144,16 @@ class W3DHub
end
status.zero?
else
if block
IO.popen(command, "r") do |io|
io.each_line do |line|
block&.call(line)
end
elsif block
IO.popen(command, "r") do |io|
io.each_line do |line|
block&.call(line)
end
$CHILD_STATUS.success?
else
system(command)
end
$CHILD_STATUS.success?
else
system(command)
end
end
@@ -146,27 +161,60 @@ class W3DHub
File.expand_path("~")
end
def self.ask_file(title: "Open File", filter: "*game*.exe")
def self.ask_file(title: "Open File", filter: "*game*.exe", filters: [])
filters << filter if filters.empty?
if W3DHub.unix?
# search for command
cmds = %w{ zenity matedialog qarma kdialog }
cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd|
cmd if system("which #{cmd}")
end
path = case File.basename(command)
when "zenity", "matedialog", "qarma"
`#{command} --file-selection --title "#{title}" --file-filter "#{filter}"`
when "kdialog"
`#{command} --title "#{title}" --getopenfilename . "#{filter}"`
else
raise "No known command found for system file selection dialog!"
end
when "zenity", "matedialog", "qarma"
options = filters.map { |s| format("--file-filter=\"%s\"", s) }.join(" ")
`#{command} --file-selection --title \"#{title}\" #{options}`
when "kdialog"
`#{command} --title "#{title}" --getopenfilename . "#{filters.join(" ")}"`
else
raise "No known command found for system file selection dialog!"
end
path.strip
else
raise NotImplementedError
result_ptr = LibUI.open_file(LIBUI_WINDOW)
result = result_ptr.null? ? "" : result_ptr.to_s.gsub("\\", "/")
result.strip
end
end
def self.ask_folder(title: "Open Folder")
if W3DHub.unix?
# search for command
cmds = %w[zenity matedialog qarma kdialog]
command = cmds.find do |cmd|
cmd if system("which #{cmd}")
end
path = case File.basename(command)
when "zenity", "matedialog", "qarma"
`#{command} --file-selection --directory --title "#{title}"`
when "kdialog"
`#{command} --title "#{title}" --getexistingdirectory #{Dir.home}"`
else
raise "No known command found for system file selection dialog!"
end
path.strip
else
result_ptr = LibUI.open_folder(LIBUI_WINDOW)
result = result_ptr.null? ? "" : result_ptr.to_s.gsub("\\", "/")
result.strip
end
end
end

View File

@@ -1,52 +0,0 @@
module CyberarmEngine
class GuiState < CyberarmEngine::GameState
def menu(host_element, items:, width: 200)
container = CyberarmEngine::Element::Stack.new(
parent: host_element.parent,
width: width,
theme: W3DHub::THEME,
border_color: 0xff_000000,
border_thickness: 1
)
container.instance_variable_set(:"@__menu", host_element)
container.define_singleton_method(:recalculate_menu) do
@x = @__menu.x
@y = @__menu.y + @__menu.height
@y = @__menu.y - height if @y + height > window.height
end
def container.recalculate
super
recalculate_menu
end
items.each do |item|
btn = CyberarmEngine::Element::Button.new(
item[:label],
{
parent: container,
width: 1.0,
text_align: :left,
theme: W3DHub::THEME,
border_thickness: 0,
margin: 0
},
proc do
item[:block]&.call
end
)
container.add(btn)
end
container.recalculate
container.recalculate
container.recalculate
show_menu(container)
end
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

@@ -1,370 +0,0 @@
require "digest"
require "stringio"
class W3DHub
# https://github.com/TheUnstoppable/MixLibrary used for reference
class Mixer
DEFAULT_BUFFER_SIZE = 32_000_000
class MixParserException < RuntimeError; end
class MixFormatException < RuntimeError; end
class MemoryBuffer
def initialize(file_path:, mode:, buffer_size:, encoding: Encoding::ASCII_8BIT)
@mode = mode
@file = File.open(file_path, mode == :read ? "rb" : "wb")
@file.pos = 0
@file_size = File.size(file_path)
@buffer_size = buffer_size
@chunk = 0
@last_chunk = 0
@max_chunks = @file_size / @buffer_size
@last_cached_chunk = nil
@encoding = encoding
@last_buffer_pos = 0
@buffer = @mode == :read ? StringIO.new(@file.read(@buffer_size)) : StringIO.new
@buffer.set_encoding(encoding)
# Cache frequently accessed chunks to reduce disk hits
@cache = {}
end
def pos
@chunk * @buffer_size + @buffer.pos
end
def pos=(offset)
last_chunk = @chunk
@chunk = offset / @buffer_size
raise "No backsies! #{offset} (#{@chunk}/#{last_chunk})" if @mode == :write && @chunk < last_chunk
fetch_chunk(@chunk) if @mode == :read
@buffer.pos = offset % @buffer_size
end
# string of bytes
def write(bytes)
length = bytes.length
# Crossing buffer boundry
if @buffer.pos + length > @buffer_size
edge_size = @buffer_size - @buffer.pos
buffer_edge = bytes[0...edge_size]
bytes_to_write = bytes.length - buffer_edge.length
chunks_to_write = (bytes_to_write / @buffer_size.to_f).ceil
bytes_written = buffer_edge.length
@buffer.write(buffer_edge)
flush_chunk
chunks_to_write.times do |i|
i += 1
@buffer.write(bytes[bytes_written...bytes_written + @buffer_size])
bytes_written += @buffer_size
flush_chunk if string.length == @buffer_size
end
else
@buffer.write(bytes)
end
bytes
end
def write_header(data_offset:, name_offset:)
flush_chunk
@file.pos = 4
write_i32(data_offset)
write_i32(name_offset)
@file.pos = 0
end
def write_i32(int)
@file.write([int].pack("l"))
end
def read(bytes = nil)
raise ArgumentError, "Cannot read whole file" if bytes.nil?
raise ArgumentError, "Cannot under read buffer" if bytes.negative?
# Long read, need to fetch next chunk while reading, mostly defeats this class...?
if @buffer.pos + bytes > buffered
buff = string[@buffer.pos..buffered]
bytes_to_read = bytes - buff.length
chunks_to_read = (bytes_to_read / @buffer_size.to_f).ceil
chunks_to_read.times do |i|
i += 1
fetch_chunk(@chunk + 1)
if i == chunks_to_read # read partial
already_read_bytes = (chunks_to_read - 1) * @buffer_size
bytes_more_to_read = bytes_to_read - already_read_bytes
buff << @buffer.read(bytes_more_to_read)
else
buff << @buffer.read
end
end
buff
else
fetch_chunk(@chunk) if @last_chunk != @chunk
@buffer.read(bytes)
end
end
def readbyte
fetch_chunk(@chunk + 1) if @buffer.pos + 1 > buffered
@buffer.readbyte
end
def fetch_chunk(chunk)
raise ArgumentError, "Cannot fetch chunk #{chunk}, only #{@max_chunks} exist!" if chunk > @max_chunks
@last_chunk = @chunk
@chunk = chunk
@last_buffer_pos = @buffer.pos
cached = @cache[chunk]
if cached
@buffer.string = cached
else
@file.pos = chunk * @buffer_size
buff = @buffer.string = @file.read(@buffer_size)
# Cache the active chunk (implementation bounces from @file_data_chunk and back to this for each 'file' processed)
if @chunk != @file_data_chunk && @chunk != @last_cached_chunk
@cache.delete(@last_cached_chunk) unless @last_cached_chunk == @file_data_chunk
@cache[@chunk] = buff
@last_cached_chunk = @chunk
end
buff
end
end
# This is accessed quite often, keep it around
def cache_file_data_chunk!
@file_data_chunk = @chunk
last_buffer_pos = @buffer.pos
@buffer.pos = 0
@cache[@chunk] = @buffer.read
@buffer.pos = last_buffer_pos
end
def flush_chunk
@last_chunk = @chunk
@chunk += 1
@file.pos = @last_chunk * @buffer_size
@file.write(string)
@buffer.string = "".force_encoding(@encoding)
end
def string
@buffer.string
end
def buffered
@buffer.string.length
end
def close
@file&.close
end
end
class Reader
attr_reader :package
def initialize(file_path:, ignore_crc_mismatches: false, metadata_only: false, buffer_size: DEFAULT_BUFFER_SIZE)
@package = Package.new
@buffer = MemoryBuffer.new(file_path: file_path, mode: :read, buffer_size: buffer_size)
@buffer.pos = 0
# Valid header
if read_i32 == 0x3158494D
file_data_offset = read_i32
file_names_offset = read_i32
@buffer.pos = file_names_offset
file_count = read_i32
file_count.times do
@package.files << Package::File.new(name: read_string)
end
@buffer.pos = file_data_offset
@buffer.cache_file_data_chunk!
_file_count = read_i32
file_count.times do |i|
file = @package.files[i]
file.mix_crc = read_u32.to_s(16).rjust(8, "0")
file.content_offset = read_u32
file.content_length = read_u32
if !ignore_crc_mismatches && file.mix_crc != file.file_crc
raise MixParserException, "CRC mismatch for #{file.name}. #{file.mix_crc.inspect} != #{file.file_crc.inspect}"
end
pos = @buffer.pos
@buffer.pos = file.content_offset
file.data = @buffer.read(file.content_length) unless metadata_only
@buffer.pos = pos
end
else
raise MixParserException, "Invalid MIX file"
end
ensure
@buffer&.close
@buffer = nil # let GC collect
end
def read_i32
@buffer.read(4).unpack1("l")
end
def read_u32
@buffer.read(4).unpack1("L")
end
def read_string
buffer = ""
length = @buffer.readbyte
length.times do
buffer << @buffer.readbyte
end
buffer.strip
end
end
class Writer
attr_reader :package
def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE)
@package = package
@buffer = MemoryBuffer.new(file_path: file_path, mode: :write, buffer_size: buffer_size)
@buffer.pos = 0
@buffer.write("MIX1")
files = @package.files.sort { |a, b| a.file_crc <=> b.file_crc }
@buffer.pos = 16
files.each do |file|
file.content_offset = @buffer.pos
file.content_length = file.data.length
@buffer.write(file.data)
@buffer.pos += -@buffer.pos & 7
end
file_data_offset = @buffer.pos
write_i32(files.count)
files.each do |file|
write_u32(file.file_crc.to_i(16))
write_u32(file.content_offset)
write_u32(file.content_length)
end
file_name_offset = @buffer.pos
write_i32(files.count)
files.each do |file|
write_byte(file.name.length + 1)
@buffer.write("#{file.name}\0")
end
@buffer.write_header(data_offset: file_data_offset, name_offset: file_name_offset)
ensure
@buffer&.close
end
def write_i32(int)
@buffer.write([int].pack("l"))
end
def write_u32(uint)
@buffer.write([uint].pack("L"))
end
def write_byte(byte)
@buffer.write([byte].pack("c"))
end
end
# Eager loads patch file and streams target file metadata (doen't load target file data or generate CRCs)
# after target file metadata is loaded, create a temp file and merge patched files into list then
# build ordered file list and stream patched files and target file chunks into temp file,
# after that is done, replace target file with temp file
class Patcher
def initialize(patch_files:, target_file:, temp_file:, buffer_size: DEFAULT_BUFFER_SIZE)
@patch_files = patch_files.to_a.map { |f| Reader.new(file_path: f) }
@target_file = File.open(target_file)
@temp_file = File.open(temp_file, "a+b")
@buffer_size = buffer_size
end
end
class Package
attr_reader :files
def initialize(files: [])
@files = files
end
class File
attr_accessor :name, :mix_crc, :content_offset, :content_length, :data
def initialize(name:, mix_crc: nil, content_offset: nil, content_length: nil, data: nil)
@name = name
@mix_crc = mix_crc
@content_offset = content_offset
@content_length = content_length
@data = data
end
def file_crc
return "e6fe46b8" if @name.downcase == ".w3dhub.patch"
Digest::CRC32.hexdigest(@name.upcase)
end
def data_crc
Digest::CRC32.hexdigest(@data)
end
end
end
end
end

92
lib/network_manager.rb Normal file
View File

@@ -0,0 +1,92 @@
class W3DHub
# all http(s) requests for API calls and downloading images run through here
class NetworkManager
NetworkEvent = Data.define(:context, :result)
Request = Struct.new(:active, :context, :async, :callback)
Context = Data.define(
:request_id,
:method,
:url,
:headers,
:body
)
def initialize
@requests = []
@running = true
@thread = Thread.new do
@http_client = HttpClient.new
Sync do
while @running
request = @requests.find { |r| !r.active }
# goto sleep for an second if there is no work to be doing
unless request
sleep 1
next
end
request.active = true
Async do |task|
assigned_request = request
result = if assigned_request.context.url.empty?
assigned_request.callback.call(nil)
else
@http_client.handle(task, assigned_request)
end
@requests.delete(assigned_request)
# callback for this is already handled!
unless assigned_request.context.url.empty?
Store.main_thread_queue << -> { assigned_request.callback.call(result) }
end
end
end
end
end
end
def request(method, url, headers, body, async, &block)
request_id = SecureRandom.hex
request = Request.new(
false,
Context.new(
request_id,
method,
url,
headers,
body
),
async,
block
)
@requests << request
if async
request_id
else # Not async, process immediately.
raise "WTF? This should NOT happen!" unless Async::Task.current?
Sync do |task|
assigned_request = request
result = @http_client.handle(task, assigned_request)
@requests.delete(assigned_request)
# "return" callback "value"
assigned_request.callback.call(result)
end
end
end
def busy?
@requests.any?(&:active)
end
end
end

View File

@@ -0,0 +1,57 @@
class W3DHub
class NetworkManager
# non-blocking, http requests.
class HttpClient
def initialize
@http_clients = {}
end
def handle(task, request)
result = CyberarmEngine::Result.new
context = request.context
task.with_timeout(W3DHub::Api::API_TIMEOUT) do
uri = URI(context.url)
response = provision_http_client(uri.origin).send(
context.method,
uri.request_uri,
context.headers,
context.body
)
if response.success?
result.data = response.read
else
result.error = response
end
rescue Async::TimeoutError => e
result.error = e
rescue StandardError => e
result.error = e
ensure
response&.close
end
result
end
def provision_http_client(hostname)
return @http_clients[hostname.downcase] if @http_clients[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[hostname.downcase] = Async::HTTP::Client.new(endpoint)
end
def wrapped_error(error)
WrappedError.new(error.class, error.message.to_s, error.backtrace)
end
end
end
end

View File

@@ -47,9 +47,5 @@ class W3DHub
def button_up(id)
end
def menu(host_element, items:)
@host.menu(host_element, items: items)
end
end
end

View File

@@ -3,6 +3,7 @@ class W3DHub
class Community < Page
def setup
@w3dhub_news ||= nil
@w3dhub_news_expires ||= 0
body.clear do
stack(width: 1.0, height: 1.0, padding: 8) do
@@ -45,10 +46,10 @@ class W3DHub
tagline "<b>Help & Support</b>"
flow(width: 1.0) do
para "For help and support using this launcher or playing any W3D Hub game visit the"
link("W3D Hub forums", text_size: 16, tip: "https://w3dhub.com/forum/") { W3DHub.url("https://w3dhub.com/forum/") }
link("W3D Hub forums", text_size: 22, tip: "https://w3dhub.com/forum/") { W3DHub.url("https://w3dhub.com/forum/") }
para "or join us in"
image "#{GAME_ROOT_PATH}/media/social_media_icons/discord.png", height: 16, padding_top: 4
link("#tech-support", text_size: 16, tip: "https://discord.com/invite/GYhW7eV") { W3DHub.url("https://discord.com/invite/GYhW7eV") }
link("#tech-support", text_size: 22, tip: "https://discord.com/invite/GYhW7eV") { W3DHub.url("https://discord.com/invite/GYhW7eV") }
para "on the W3D Hub Discord server"
end
end
@@ -64,15 +65,23 @@ class W3DHub
para I18n.t(:"games.fetching_news"), padding: 8
end
BackgroundWorker.foreground_job(
-> { fetch_w3dhub_news },
lambda do |result|
if result
populate_w3dhub_news
Cache.release_net_lock(result)
end
end
)
fetch_w3dhub_news
end
end
def update
super
if Gosu.milliseconds >= @w3dhub_news_expires
@w3dhub_news = nil
@w3dhub_news_expires = Gosu.milliseconds + 30_000 # seconds
@wd3hub_news_container.clear do
title I18n.t(:"games.fetching_news"), padding: 8
end
fetch_w3dhub_news
end
end
@@ -80,18 +89,24 @@ class W3DHub
lock = Cache.acquire_net_lock("w3dhub_news")
return false unless lock
news = Api.news("launcher-home")
Cache.release_net_lock("w3dhub_news") unless news
Api.news("launcher-home") do |result|
news = result.data
return unless news
Cache.release_net_lock("w3dhub_news") unless news
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false)
next false unless news
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, backend: :w3dhub)
end
@w3dhub_news = news
@w3dhub_news_expires = Gosu.milliseconds + (60 * 60 * 1000) # 1 hour (in ms)
populate_w3dhub_news
ensure
Cache.release_net_lock("w3dhub_news")
end
@w3dhub_news = news
"w3dhub_news"
end
def populate_w3dhub_news
@@ -99,55 +114,28 @@ class W3DHub
if (feed = @w3dhub_news)
@wd3hub_news_container.clear do
# feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item|
# flow(width: 0.5, max_width: 312, height: 128, margin: 4) do
# # background 0x88_000000
# path = Cache.path(item.image)
# if File.exist?(path)
# image path, height: 1.0, padding: 4
# else
# image BLACK_IMAGE, height: 1.0, padding: 4
# end
# stack(width: 0.6, height: 1.0) do
# stack(width: 1.0, height: 112) do
# link "<b>#{item.title}</b>", text_size: 18 do
# W3DHub.url(item.uri)
# end
# inscription item.blurb.gsub(/\n+/, "\n").strip[0..180]
# end
# flow(width: 1.0) do
# inscription item.timestamp.strftime("%Y-%m-%d"), width: 0.499
# link I18n.t(:"games.read_more"), width: 0.5, text_align: :right, text_size: 14 do
# W3DHub.url(item.uri)
# end
# end
# end
# end
# end
feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item|
feed.items.sort_by(&:timestamp).reverse[0..9].each do |item|
image_path = Cache.path(item.image)
flow(width: 1.0, max_width: 1230, height: 200, margin: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(0xff_252525))) do
background 0x22_000000
background 0x44_000000
image image_path, height: 1.0
# Ensure the image file exists before trying to load it
if File.exist?(image_path)
image image_path, height: 1.0
else
logger.warn("W3DHub::Community") { "Image not found in cache: #{image_path}" }
image BLACK_IMAGE, height: 1.0
end
stack(fill: true, height: 1.0, background: 0x44_000000, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(0xff_252525))) do
stack(fill: true, height: 1.0, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(0xff_252525))) do
tagline "<b>#{item.title}</b>", width: 1.0
inscription item.blurb.gsub(/\n+/, "\n").strip[0..1024], fill: true
para item.blurb.gsub(/\n+/, "\n").strip[0..1024], fill: true
flow(fill: true)
flow(width: 1.0, height: 32, margin_top: 8) do
flow(width: 1.0, height: 36, margin_top: 8) do
stack(fill: true, height: 1.0) do
flow(fill: true)
inscription "#{item.author}#{item.timestamp.strftime("%Y-%m-%d")}"
flow(fill: true)
para "#{item.author}#{item.timestamp.strftime("%Y-%m-%d")}"
end
button I18n.t(:"games.read_more"), width: 1.0, max_width: 128, padding_top: 4, padding_bottom: 4 do

View File

@@ -31,27 +31,29 @@ class W3DHub
# TODO: Show correct application details here
flow(width: 1.0, height: 0.1, padding: 8) do
background task.application.color
app_color = Gosu::Color.new(task.application.color)
app_color.alpha = 0x88
background app_color
flow(width: 0.70, height: 1.0) do
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{task.app_id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{task.app_id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_path = File.exist?("#{CACHE_PATH}/#{task.app_id}.png") ? "#{CACHE_PATH}/#{task.app_id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
@application_image = image image_path, height: 1.0
stack(margin_left: 8, width: 0.75) do
@application_name_label = tagline "#{task.application.name}"
@application_version_label = inscription "Version: #{task.target_version} (#{task.channel.id})"
@application_version_label = para "Version: #{task.target_version} (#{task.channel.id})"
end
end
flow(width: 0.30, height: 1.0) do
stack(width: 0.499, height: 1.0) do
para "Download Speed", width: 1.0, text_align: :center
@download_speed_label = inscription "- b/s", width: 1.0, text_align: :center
@download_speed_label = para "- b/s", width: 1.0, text_align: :center
end
stack(width: 0.5, height: 1.0) do
para "Downloaded", width: 1.0, text_align: :center
inscription "---- b / ---- b", width: 1.0, text_align: :center
para "---- b / ---- b", width: 1.0, text_align: :center
end
end
end
@@ -64,15 +66,15 @@ class W3DHub
task.status.operations.each do |key, operation|
i += 1
stack(width: 1.0, height: 24, padding: 8) do
background 0xff_333333 if i.odd?
stack(width: 1.0, height: 26, padding: 8) do
background 0xaa_333333 if i.odd?
flow(width: 1.0, height: 22) do
@operation_info["#{key}_name"] = inscription operation.label, width: 0.7, text_wrap: :none, tag: "#{key}_name"
@operation_info["#{key}_status"] = inscription operation.value, width: 0.3, text_align: :right, text_wrap: :none, tag: "#{key}_status"
@operation_info["#{key}_name"] = para operation.label, width: 0.7, text_wrap: :none, tag: "#{key}_name"
@operation_info["#{key}_status"] = para operation.value, width: 0.3, text_align: :right, text_wrap: :none, tag: "#{key}_status"
end
@operation_info["#{key}_progress"] = progress fraction: operation.progress, height: 2, width: 1.0, tag: "#{key}_progress"
@operation_info["#{key}_progress"] = progress fraction: operation.progress, height: 2, width: 1.0, margin_top: 2, tag: "#{key}_progress"
end
end
end

View File

@@ -17,8 +17,7 @@ class W3DHub
end
# Game Menu
@game_page_container = stack(width: 1.0, fill: true, background_image: "#{GAME_ROOT_PATH}/media/textures/noiseb.png", background_image_mode: :tiled) do
# , background_image: "C:/Users/cyber/Downloads/vlcsnap-2022-04-24-22h24m15s854.png"
@game_page_container = stack(width: 1.0, fill: true) do
end
end
end
@@ -29,6 +28,22 @@ class W3DHub
populate_games_list
end
def update
super
@game_news.each do |key, value|
next unless key.end_with?("_expires")
next unless Gosu.milliseconds >= value
# try to refresh game news after last data 'expired', every 30 seconds until success
@game_news[key] = Gosu.milliseconds + 30_000 # seconds
game = Store.applications.games.find { |g| g.id == key.split("_").first }
fetch_game_news(game)
end
end
def populate_games_list
@games_list_container.clear do
background 0xaa_121920
@@ -51,19 +66,19 @@ class W3DHub
selected = game == @focused_game
game_button = stack(width: 64, height: 1.0, border_thickness_bottom: 4,
border_color_bottom: selected ? 0xff_00acff : 0x00_000000,
border_color_bottom: selected ? 0xff_0074e0 : 0x00_000000,
hover: { background: selected ? game.color : 0xff_444444 },
padding_left: 4, padding_right: 4, tip: game.name) do
background game.color if selected
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{game.id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_path = File.exist?("#{CACHE_PATH}/#{game.id}.png") ? "#{CACHE_PATH}/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_color = Store.application_manager.installed?(game.id, game.channels.first.id) ? 0xff_ffffff : 0x66_ffffff
flow(width: 1.0, height: 1.0, margin: 8, background_image: image_path, background_image_color: image_color, background_image_mode: :fill_height) do
image "#{GAME_ROOT_PATH}/media/ui_icons/import.png", width: 24, margin_left: -4, margin_top: -6, color: 0xff_ff8800 if Store.application_manager.updateable?(game.id, game.channels.first.id)
image "#{GAME_ROOT_PATH}/media/ui_icons/import.png", width: 24, margin_left: -4, margin_top: -6, color: 0xff_ff8800 if game.channels.any? { |channel| Store.application_manager.updateable?(game.id, channel.id) }
end
# inscription game.name, width: 1.0, text_align: :center, text_size: 14
# para game.name, width: 1.0, text_align: :center
end
def game_button.hit_element?(x, y)
@@ -71,7 +86,9 @@ class W3DHub
end
game_button.subscribe(:clicked_left_mouse_button) do
populate_game_page(game, game.channels.first)
channel = @focused_game == game ? @focused_channel : game.channels.first
populate_game_page(game, channel)
populate_games_list
end
end
@@ -87,32 +104,31 @@ class W3DHub
@game_page_container.clear do
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0x88
game_color.alpha = 0xaa
background game_color
@game_page_container.style.background_image_color = game_color
@game_page_container.style.default[:background_image_color] = game_color
@game_page_container.update_background_image
background_image_path = Cache.package_path(game.category, game.id, "background.png", "")
if File.exist?(background_image_path)
States::Interface.instance&.instance_variable_get(:"@interface_container")&.style&.background_image = get_image(background_image_path)
States::Interface.instance&.instance_variable_get(:"@interface_container")&.style&.default[:background_image] = get_image(background_image_path)
end
# Game Stuff
flow(width: 1.0, fill: true) do
# background 0xff_9999ff
# Game options
stack(width: 360, height: 1.0, padding: 8, scroll: true, border_thickness_right: 1, border_color_right: W3DHub::BORDER_COLOR) do
background 0x55_000000
stack(width: 360, height: 1.0, padding: 8, scroll: true, background: game_color, border_thickness_right: 1, border_color_right: W3DHub::BORDER_COLOR) do
# Game Logo
logo_image_path = Cache.package_path(game.category, game.id, "logo.png", "")
# Game Banner
image_path = "#{GAME_ROOT_PATH}/media/banners/#{game.id}.png"
if File.exist?(image_path)
image image_path, width: 1.0
if File.exist?(logo_image_path)
image logo_image_path, width: 1.0
else
banner game.name unless File.exist?(image_path)
banner game.name unless File.exist?(logo_image_path)
end
stack(width: 1.0, fill: true, scroll: true, margin_top: 32) do
if Store.application_manager.installed?(game.id, channel.id)
para "v#{Store.application_manager.installed?(game.id, channel.id)[:installed_version]}"
Hash.new.tap { |hash|
# hash[I18n.t(:"games.game_settings")] = { icon: "gear", block: proc { Store.application_manager.settings(game.id, channel.id) } }
# hash[I18n.t(:"games.wine_configuration")] = { icon: "gear", block: proc { Store.application_manager.wine_configuration(game.id, channel.id) } } if W3DHub.unix?
@@ -128,7 +144,7 @@ class W3DHub
flow(width: 1.0, height: 22, margin_bottom: 8) do
image "#{GAME_ROOT_PATH}/media/ui_icons/#{hash[:icon]}.png", width: 24 if hash[:icon]
image EMPTY_IMAGE, width: 24 unless hash[:icon]
link key, text_size: 18, enabled: hash.key?(:enabled) ? hash[:enabled] : true do
link key, text_size: 22, enabled: hash.key?(:enabled) ? hash[:enabled] : true do
hash[:block]&.call
end
end
@@ -138,7 +154,7 @@ class W3DHub
game.web_links.each do |item|
flow(width: 1.0, height: 22, margin_bottom: 8) do
image "#{GAME_ROOT_PATH}/media/ui_icons/share1.png", width: 24
link item.name, text_size: 18 do
link item.name, text_size: 22 do
W3DHub.url(item.uri)
end
end
@@ -148,7 +164,7 @@ class W3DHub
if game.channels.count > 1
# Release channel
inscription I18n.t(:"games.game_version"), width: 1.0, text_align: :center
para I18n.t(:"games.game_version"), width: 1.0, text_align: :center
flow(width: 1.0, height: 48) do
# background 0xff_444411
@@ -159,17 +175,17 @@ class W3DHub
end
# Play buttons
flow(width: 1.0, height: 48, padding_top: 6, margin_bottom: 16) do
flow(width: 1.0, height: 52, padding_top: 6) do
# background 0xff_551100
if Store.application_manager.installed?(game.id, channel.id)
if Store.application_manager.updateable?(game.id, channel.id)
button "<b>#{I18n.t(:"interface.install_update")}</b>", fill: true, text_size: 32, **UPDATE_BUTTON do
button "<b>#{I18n.t(:"interface.install_update")}</b>", fill: true, text_size: 30, **UPDATE_BUTTON do
Store.application_manager.update(game.id, channel.id)
end
else
play_now_server = Store.application_manager.play_now_server(game.id, channel.id)
play_now_button = button "<b>#{I18n.t(:"interface.play")}</b>", fill: true, text_size: 32, enabled: !play_now_server.nil? do
play_now_button = button "<b>#{I18n.t(:"interface.play")}</b>", fill: true, text_size: 30, enabled: !play_now_server.nil? do
Store.application_manager.play_now(game.id, channel.id)
end
@@ -185,34 +201,52 @@ class W3DHub
end
button get_image("#{GAME_ROOT_PATH}/media/ui_icons/gear.png"), tip: I18n.t(:"games.game_options"), image_height: 32, margin_left: 0 do |btn|
items = []
menu(parent: btn) do
menu_item(I18n.t(:"games.game_settings")) do
if game.uses_engine_cfg?
push_state(States::GameSettingsDialog, app_id: game.id, channel: channel.id)
else
Store.application_manager.wwconfig(game.id, channel.id)
end
end
items << { label: I18n.t(:"games.game_settings"), block: proc { push_state(States::GameSettingsDialog, app_id: game.id, channel: channel.id) } } #, block: proc { Store.application_manager.wwconfig(game.id, channel.id) } }
# items << { label: I18n.t(:"games.game_settings"), block: proc { Store.application_manager.settings(game.id, channel.id) } }
items << { label: I18n.t(:"games.wine_configuration"), block: proc { Store.application_manager.wine_configuration(game.id, channel.id) } } if W3DHub.unix?
items << { label: I18n.t(:"games.game_modifications"), block: proc { populate_game_modifications(game, channel) } } unless Store.offline_mode
if game.id != "ren"
items << { label: I18n.t(:"games.repair_installation"), block: proc { Store.application_manager.repair(game.id, channel.id) } } unless Store.offline_mode
items << { label: I18n.t(:"games.uninstall_game"), block: proc { Store.application_manager.uninstall(game.id, channel.id) } } unless Store.offline_mode
end
if W3DHub.unix?
menu_item(I18n.t(:"games.wine_configuration")) do
Store.application_manager.wine_configuration(game.id, channel.id)
end
end
# From gui_state_ext.rb
# TODO: Implement in engine proper
menu(btn, items: items)
unless Store.offline_mode
if W3DHUB_DEVELOPER
menu_item(I18n.t(:"games.game_modifications")) do
populate_game_modifications(game, channel)
end
end
if game.id != "ren"
menu_item(I18n.t(:"games.repair_installation")) do
Store.application_manager.repair(game.id, channel.id)
end
menu_item(I18n.t(:"games.uninstall_game")) do
Store.application_manager.uninstall(game.id, channel.id)
end
end
end
end.show
end
else
installing = Store.application_manager.task?(:installer, game.id, channel.id)
unless game.id == "ren"
button "<b>#{I18n.t(:"interface.install")}</b>", fill: true, margin_right: 8, text_size: 32, enabled: !installing do |button|
button "<b>#{I18n.t(:"interface.install")}</b>", fill: true, margin_right: 8, text_size: 30, enabled: !installing do |button|
button.enabled = false
@import_button.enabled = false
Store.application_manager.install(game.id, channel.id)
end
end
@import_button = button "<b>#{I18n.t(:"interface.import")}</b>", fill: true, margin_left: 8, text_size: 32, enabled: !installing do
@import_button = button "<b>#{I18n.t(:"interface.import")}</b>", fill: true, margin_left: 8, text_size: 30, enabled: !installing do
Store.application_manager.import(game.id, channel.id)
end
end
@@ -225,12 +259,12 @@ class W3DHub
# Height should match Game Banner container height
stack(width: 1.0, padding: 16) do
title "About #{game.name}", border_bottom_color: 0xff_666666, border_bottom_thickness: 1, width: 1.0
para "Command & Conquer: Tiberian Sun is a 1999 real-time stretegy video game by Westwood Studios, published by Electronic Arts, releaseed exclusively for Microsoft Windows on August 27th, 1999. The game is the sequel to the 1995 game Command & Conquer. It featured new semi-3D graphics, a more futuristic sci-fi setting, and new gameplay features such as vehicles capable of hovering and burrowing.", width: 1.0, text_size: 20
para "Command & Conquer: Tiberian Sun is a 1999 real-time stretegy video game by Westwood Studios, published by Electronic Arts, releaseed exclusively for Microsoft Windows on August 27th, 1999. The game is the sequel to the 1995 game Command & Conquer. It featured new semi-3D graphics, a more futuristic sci-fi setting, and new gameplay features such as vehicles capable of hovering and burrowing.", width: 1.0
end
end
# Game Events
@game_events_container = flow(width: 1.0, height: 128, padding: 8, visible: false) do
@game_events_container = stack(width: 1.0, height: 128, padding: 8, scroll: true, visible: false) do
end
# Game News
@@ -244,22 +278,6 @@ class W3DHub
return if Store.offline_mode
unless Cache.net_lock?("game_news_#{game.id}")
if @game_events[game.id]
populate_game_events(game)
else
BackgroundWorker.foreground_job(
-> { fetch_game_events(game) },
lambda do |result|
if result
populate_game_events(game)
Cache.release_net_lock(result)
end
end
)
end
end
unless Cache.net_lock?("game_events_#{game.id}")
if @game_news[game.id]
populate_game_news(game)
else
@@ -267,26 +285,21 @@ class W3DHub
title I18n.t(:"games.fetching_news"), padding: 8
end
BackgroundWorker.foreground_job(
-> { fetch_game_news(game) },
lambda do |result|
if result
populate_game_news(game)
Cache.release_net_lock(result)
end
end
)
fetch_game_news(game)
end
end
unless Cache.net_lock?("game_events_#{game.id}")
if @game_events[game.id]
populate_game_events(game)
else
fetch_game_events(game)
end
end
end
def populate_all_games_view
@game_page_container.clear do
background 0x88_353535
@game_page_container.style.background_image_color = 0x88_353535
@game_page_container.style.default[:background_image_color] = 0x88_353535
@game_page_container.update_background_image
@focused_game = nil
@focused_channel = nil
@@ -323,10 +336,10 @@ class W3DHub
flow(width: 1.0, fill: true, scroll: true) do
Store.applications.games.each do |game|
stack(width: 166, height: 224, margin: 8, background: 0x88_151515, border_color: game.color, border_thickness: 1) do
flow(width: 1.0, height: 24, padding: 8) do
flow(width: 1.0, height: 28, padding: 8) do
para "Favorite", fill: true
toggle_button checked: Store.application_manager.favorite?(game.id), text_size: 18, padding_top: 3, padding_right: 3, padding_bottom: 3, padding_left: 3 do |btn|
Store.application_manager.favorive(game.id, btn.value)
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.favorite(game.id, btn.value)
Store.settings.save_settings
populate_games_list
@@ -334,7 +347,7 @@ class W3DHub
end
container = stack(fill: true, width: 1.0, padding: 8) do
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{game.id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_path = File.exist?("#{CACHE_PATH}/#{game.id}.png") ? "#{CACHE_PATH}/#{game.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
flow(width: 1.0, margin_top: 8) do
flow(fill: true)
image image_path, width: 0.5
@@ -370,72 +383,52 @@ class W3DHub
lock = Cache.acquire_net_lock("game_news_#{game.id}")
return false unless lock
news = Api.news(game.id)
Cache.release_net_lock("game_news_#{game.id}") unless news
Api.news(game.id) do |result|
news = result.data
return false unless news
unless news
@game_news["#{game.id}_expires"] = Gosu.milliseconds + 30_000 # retry in 30 seconds
next false
end
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false)
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, backend: :w3dhub)
end
@game_news[game.id] = news
@game_news["#{game.id}_expires"] = Gosu.milliseconds + (60 * 60 * 1000) # 1 hour (in ms)
populate_game_news(@focused_game)
ensure
Cache.release_net_lock("game_news_#{game.id}")
end
@game_news[game.id] = news
"game_news_#{game.id}"
end
def populate_game_news(game)
return unless @focused_game == game
if (feed = @game_news[game.id])
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0xaa
@game_news_container.clear do
# Patch Notes
if false # Patch notes
flow(width: 1.0, max_width: 346 * 3 + (8 * 4), height: 346, margin: 8, margin_right: 32, border_thickness: 1, border_color: darken(Gosu::Color.new(game.color))) do
background darken(Gosu::Color.new(game.color), 10)
stack(width: 346, height: 1.0, padding: 8) do
background 0xff_181d22
para "Patch Notes"
tagline "<b>Patch 2.0 is now out!</b>"
para "words go here " * 20
flow(fill: true)
button "Read More", width: 1.0
end
flow(fill: true)
title "Eye Candy Banner Goes Here."
end
end
feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item|
feed.items.sort_by(&:timestamp).reverse[0..9].each do |item|
image_path = Cache.path(item.image)
flow(width: 1.0, max_width: 869, height: 200, margin: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
background 0x88_000000
flow(width: 1.0, max_width: 869, height: 200, margin: 8, background: game_color, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
image image_path, height: 1.0 if File.file?(image_path)
image image_path, height: 1.0
stack(fill: true, height: 1.0, background: 0x44_000000, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(game.color))) do
stack(fill: true, height: 1.0, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(game.color))) do
tagline "<b>#{item.title}</b>", width: 1.0
inscription item.blurb.gsub(/\n+/, "\n").strip[0..1024], fill: true
para item.blurb.gsub(/\n+/, "\n").strip[0..1024], fill: true
flow(fill: true)
flow(width: 1.0, height: 32, margin_top: 8) do
flow(width: 1.0, height: 36, margin_top: 8) do
stack(fill: true, height: 1.0) do
flow(fill: true)
inscription "#{item.author}#{item.timestamp.strftime("%Y-%m-%d")}"
flow(fill: true)
para "#{item.author}#{item.timestamp.strftime("%Y-%m-%d")}"
end
button I18n.t(:"games.read_more"), width: 1.0, max_width: 128, padding_top: 4, padding_bottom: 4 do
button I18n.t(:"games.read_more"), width: 1.0, max_width: 128, padding_top: 4, padding_bottom: 4, margin_left: 0, margin_top: 0, margin_bottom: 0, margin_right: 0 do
W3DHub.url(item.uri)
end
end
@@ -450,14 +443,14 @@ class W3DHub
lock = Cache.acquire_net_lock("game_events_#{game.id}")
return false unless lock
events = Api.events(game.id)
Cache.release_net_lock("game_events_#{game.id}") unless events
Api.events(game.id) do |result|
next unless result.okay?
return false unless events
@game_events[game.id] = events
"game_events_#{game.id}"
@game_events[game.id] = result.data
populate_game_events(game)
ensure
Cache.release_net_lock("game_events_#{game.id}")
end
end
def populate_game_events(game)
@@ -469,15 +462,15 @@ class W3DHub
@game_events_container.show unless events.empty?
@game_events_container.hide if events.empty?
@game_events_container.clear do
events.flatten.each do |event|
stack(fill: true, height: 1.0, margin_left: 8, margin_right: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
background 0xaa_222222
return unless (event = events.flatten.first)
title event.title, width: 1.0, text_align: :center
tagline event.start_time.strftime("%A"), text_size: 36, width: 1.0, text_align: :center
caption event.start_time.strftime("%B %e, %Y %l:%M %p"), width: 1.0, text_align: :center
end
@game_events_container.clear do
stack(width: 1.0, fill: true, margin_left: 8, margin_right: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
background 0x44_000000
title event.title, width: 1.0, text_align: :center
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
@@ -508,12 +501,12 @@ class W3DHub
stack(width: 0.75, height: 1.0) do
stack(width: 1.0, height: 128 - 28) do
link(mod[:name]) { W3DHub.url(mod[:url]) }
inscription "Author: #{mod[:author]} | #{mod[:type]} | #{mod[:subtype]}"
para "Author: #{mod[:author]} | #{mod[:type]} | #{mod[:subtype]}"
para mod[:description][0..180]
end
flow(width: 1.0, height: 28, padding: 4) do
inscription "Version", width: 0.25, text_align: :center
para "Version", width: 0.25, text_align: :center
list_box items: mod[:versions], width: 0.5, enabled: mod[:versions].size > 1, padding_top: 0, padding_bottom: 0
button "Install", width: 0.25, padding_top: 0, padding_bottom: 0
end
@@ -524,4 +517,4 @@ class W3DHub
end
end
end
end
end

View File

@@ -4,11 +4,9 @@ class W3DHub
def setup
body.clear do
flow(width: 1.0, height: 1.0, padding: 32) do
background 0xff_252535
background 0xaa_25253f
stack(width: 0.28)
stack(width: 0.48) do
stack(width: 610, height: 380, v_align: :center, h_align: :center) do
flow(width: 1.0) do
stack(width: 0.4)
image "#{GAME_ROOT_PATH}/media/icons/w3dhub.png", width: 0.20
@@ -20,14 +18,14 @@ class W3DHub
@username = edit_line "", width: 0.75, autofocus: true, focus: true
end
flow(width: 1.0) do
flow(width: 1.0, margin_top: 8) do
tagline "Password", width: 0.25, text_align: :right
@password = edit_line "", width: 0.75, type: :password
end
flow(width: 1.0) do
tagline "", width: 0.25
button "Log In" do |btn|
@action_button = button "Log In" do |btn|
@username.enabled = false
@password.enabled = false
btn.enabled = false
@@ -36,9 +34,8 @@ class W3DHub
# Do network stuff
BackgroundWorker.foreground_job(
lambda do
account = Api.user_login(@username.value, @password.value)
Api.user_login(@username.value, @password.value) do |result|
if result.okay?
applications = nil
if account
@@ -46,13 +43,16 @@ class W3DHub
Store.settings[:account][:data] = account
Store.settings.save_settings
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false) if account
applications = Api.applications if account
if account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, backend: :w3dhub) {}
Api._applications do |r|
applications = r.result if r.okay?
end
end
end
[account, applications]
end,
lambda do |result|
else
account, applications = result
if account
@@ -70,7 +70,7 @@ class W3DHub
@error_label.value = "Incorrect username or password.\nOr too many failed login attempts, try again in a few minutes."
end
end
)
end
end
@error_label = caption "", width: 1.0, text_align: :center, color: 0xff_800000
@@ -80,41 +80,68 @@ class W3DHub
end
if Store.account
BackgroundWorker.foreground_job(
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false) },
->(result) {
populate_account_info
page(W3DHub::Pages::Games)
}
)
Cache.fetch(uri: Store.account.avatar_uri, backend: :w3dhub) do |result|
populate_account_info
page(W3DHub::Pages::Games)
end
end
end
def button_down(id)
case id
when Gosu::KB_TAB
if @username.focused?
window.current_state.request_focus(@password)
else
window.current_state.request_focus(@username)
end
when Gosu::KB_ENTER, Gosu::KB_RETURN
@action_button.enabled? && @action_button.clicked_left_mouse_button(@action_button, 0, 0)
end
end
def populate_account_info
return if Store.offline_mode
@host.instance_variable_get(:"@account_container").clear do
stack(width: 0.7, height: 1.0) do
tagline "<b>#{Store.account.username}</b>"
flow(width: 1.0) do
link(I18n.t(:"interface.log_out"), text_size: 16, width: 0.5) { depopulate_account_info }
link I18n.t(:"interface.profile"), text_size: 16, width: 0.49 do
W3DHub.url("https://secure.w3dhub.com/forum/index.php?showuser=#{Store.account.id}")
end
end
end
flow(fill: true, height: 1.0) do
flow(fill: true) # Fill empty space to push image over to container edge
avatar_image = get_image(Cache.path(Store.account.avatar_uri))
avatar_image = begin
get_image(Cache.path(Store.account.avatar_uri))
rescue
get_image("#{GAME_ROOT_PATH}/media/icons/default_icon.png")
end
mask_image = get_image("#{GAME_ROOT_PATH}/media/textures/circle_mask.png")
composite_image = Gosu.render(256, 256) do
avatar_image.draw(0, 0, 0)
scale = 1.0
if avatar_image.width > avatar_image.height
# avatar image is wider than tall, use `height` for scaling to ensure we fill the canvas
scale = 256.0 / avatar_image.height
elsif avatar_image.width < avatar_image.height
# avatar image is taller than wide, use `width` for scaling to ensure we fill the canvas
scale = 256.0 / avatar_image.width
else
# avatar image is square, use width for scale to ensure we fit to the canvas
scale = 256.0 / avatar_image.width
end
# Position image center in middle of composite
avatar_image.draw_rot(128, 128, 0, 0, 0.5, 0.5, scale, scale)
# Render mask image with mode :multiply so we get a clean circle cutout of the scaled avatar image
mask_image.draw(0, 0, 1, 1, 1, 0xff_ffffff, :multiply)
end
image composite_image, width: 1.0
end
stack(width: 0.7, height: 1.0, margin_left: 8) do
link Store.account.username, text_size: 24, font: BOLD_FONT, tip: I18n.t(:"interface.profile"), margin_top: 16, width: 1.0, text_wrap: :none do
W3DHub.url("https://secure.w3dhub.com/forum/index.php?showuser=#{Store.account.id}")
end
link(I18n.t(:"interface.log_out"), text_size: 22) { depopulate_account_info }
end
end
end
@@ -123,29 +150,26 @@ class W3DHub
Store.settings.save_settings
Store.account = nil
BackgroundWorker.foreground_job(
-> { Api.applications },
lambda do |applications|
if applications
Store.applications = applications
page(W3DHub::Pages::Games) if @host.current_page.is_a?(W3DHub::Pages::Games)
page(W3DHub::Pages::ServerBrowser) if @host.current_page.is_a?(W3DHub::Pages::ServerBrowser)
end
Api._applications do |result|
if result.okay?
Store.applications = result.data
page(W3DHub::Pages::Games) if @host.current_page.is_a?(W3DHub::Pages::Games)
page(W3DHub::Pages::ServerBrowser) if @host.current_page.is_a?(W3DHub::Pages::ServerBrowser)
end
@host.instance_variable_get(:"@account_container").clear do
stack(width: 1.0, height: 1.0) do
tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none
@host.instance_variable_get(:"@account_container").clear do
stack(width: 1.0, height: 1.0) do
tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none
flow(width: 1.0) do
link(I18n.t(:"interface.log_in"), text_size: 16, width: 0.5) { page(W3DHub::Pages::Login) }
link I18n.t(:"interface.register"), text_size: 16, width: 0.49 do
W3DHub.url("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
end
flow(width: 1.0) do
link(I18n.t(:"interface.log_in"), text_size: 22, width: 0.5) { page(W3DHub::Pages::Login) }
link I18n.t(:"interface.register"), text_size: 22, width: 0.49 do
W3DHub.url("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
end
end
end
end
)
end
end
end
end

View File

@@ -3,12 +3,12 @@ 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
@selected_server_container ||= nil
@selected_color = 0xff_666655
@selected_color = 0xaa_666655
@filters = Store.settings[:server_list_filters] || {}
@filter_region = Store.settings[:server_list_region] || "Any" # "Any", "North America", "Europe"
@@ -22,14 +22,14 @@ class W3DHub
stack(width: 1.0, height: 1.0, padding: 8) do
background 0xaa_252525
stack(width: 1.0, height: 18) do
inscription "<b>#{I18n.t(:"server_browser.filters")}</b>"
stack(width: 1.0, height: 22) do
para "<b>#{I18n.t(:"server_browser.filters")}</b>", font: BOLD_FONT
end
flow(width: 1.0, height: 32) do
flow(width: 1.0, height: 36) do
flow(width: 128, height: 1.0) do
# para I18n.t(:"server_browser.region"), width: 0.5
list_box items: ["Any", "North America", "Europe"], choose: Store.settings[:server_list_region], width: 1.0, height: 1.0, padding_top: 4, padding_bottom: 4 do |value|
list_box items: ["Any", "North America", "Europe", "Asia"], choose: Store.settings[:server_list_region], width: 1.0, height: 1.0, padding_top: 4, padding_bottom: 4 do |value|
@filter_region = value
Store.settings[:server_list_region] = @filter_region
Store.settings.save_settings
@@ -43,7 +43,7 @@ class W3DHub
app = Store.applications.games.find { |a| a.id == app_id.to_s }
next unless app
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{app_id}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{app_id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_path = File.exist?("#{CACHE_PATH}/#{app.id}.png") ? "#{CACHE_PATH}/#{app.id}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image image_path, tip: "#{app.name}", height: 1.0,
border_thickness_bottom: 1, border_color_bottom: 0x00_000000,
@@ -63,23 +63,17 @@ class W3DHub
populate_server_list
end
end
# button get_image("#{GAME_ROOT_PATH}/media/ui_icons/return.png"), tip: I18n.t(:"server_browser.refresh"), image_height: 1.0, margin_left: 16, padding_left: 2, padding_right: 2, padding_top: 2, padding_bottom: 2 do
# fetch_server_list
# end
flow(fill: true)
button "Direct Connect", height: 1.0, padding_top: 4, padding_bottom: 4 do
push_state(W3DHub::States::DirectConnectDialog)
end
end
flow(min_width: 372, width: 0.38, max_width: 512, height: 1.0) do |container|
button "Direct Connect", height: 1.0, padding_top: 4, padding_bottom: 4 do
push_state(W3DHub::States::DirectConnectDialog)
end
flow(fill: true)
inscription "#{I18n.t(:"server_browser.nickname")}:"
@nickname_label = inscription "#{Store.settings[:server_list_username]}"
para "#{I18n.t(:"server_browser.nickname")}:"
@nickname_label = para "#{Store.settings[:server_list_username]}"
image "#{GAME_ROOT_PATH}/media/ui_icons/wrench.png", height: 16, hover: { color: 0xaa_ffffff }, tip: I18n.t(:"server_browser.set_nickname") do
# Prompt for player name
W3DHub.prompt_for_nickname(
@@ -105,23 +99,23 @@ class W3DHub
# Players
# Ping
flow(width: 1.0, height: 24) do
stack(width: 48, padding: 4) do
stack(width: 56, padding: 4) do
end
stack(width: 0.45, height: 1.0) do
para "<b>#{I18n.t(:"server_browser.hostname")}</b>", text_wrap: :none, width: 1.0
para "<b>#{I18n.t(:"server_browser.hostname")}</b>", text_wrap: :none, width: 1.0, font: BOLD_FONT
end
flow(fill: true, height: 1.0) do
para "<b>#{I18n.t(:"server_browser.current_map")}</b>", text_wrap: :none, width: 1.0
para "<b>#{I18n.t(:"server_browser.current_map")}</b>", text_wrap: :none, width: 1.0, font: BOLD_FONT
end
flow(width: 0.11, height: 1.0) do
para "<b>#{I18n.t(:"server_browser.players")}</b>", text_wrap: :none, width: 1.0
para "<b>#{I18n.t(:"server_browser.players")}</b>", text_wrap: :none, width: 1.0, font: BOLD_FONT
end
stack(width: 48) do
para "<b>#{I18n.t(:"server_browser.ping")}</b>", text_wrap: :none, width: 1.0
stack(width: 56) do
para "<b>#{I18n.t(:"server_browser.ping")}</b>", text_wrap: :none, width: 1.0, font: BOLD_FONT
end
end
@@ -144,22 +138,14 @@ 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
if @selected_server&.id == @refresh_server&.id
if @refresh_server
BackgroundWorker.foreground_job(
-> { fetch_server_details(@refresh_server) },
->(result) {
populate_server_info(@refresh_server) if @refresh_server == @selected_server
@refresh_server = nil
}
)
end
fetch_server_details(@refresh_server) if @refresh_server
end
end
end
@@ -202,13 +188,13 @@ class W3DHub
def ping_icon(server)
case server.ping
when 0..160
when 0..50
@ping_icons[:good]
when 161..250
when 51..150
@ping_icons[:fair]
when 251..1_000
when 151..200
@ping_icons[:poor]
when 1_001..5_000
when 201..1_000
@ping_icons[:bad]
else
@ping_icons[:unknown]
@@ -216,27 +202,31 @@ class W3DHub
end
def ping_tip(server)
server.ping.negative? ? "Ping failed" : "Ping #{server.ping}ms"
server.ping == W3DHub::Api::ServerListServer::NO_OR_BAD_PING ? "Ping failed" : "Ping #{server.ping}ms"
end
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)
def refresh_server_list(server, mode = :update) # :remove, :refresh_all
if mode == :refresh_all
populate_server_list
return
end
return list.first
end
def refresh_server_list(server)
@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)
case mode
when :update
if server.status && !server_container
@server_list_container.append do
create_server_container(server)
end
end
when :remove
@server_list_container.remove(server_container) if server_container
return
end
return unless server_container
game_icon = find_element_by_tag(server_container, :game_icon)
@@ -247,13 +237,14 @@ class W3DHub
player_count = find_element_by_tag(server_container, :player_count)
server_ping = find_element_by_tag(server_container, :ping)
server_name.value = "<b>#{server&.status&.name}</b>"
server_channel.value = server.channel
server_region.value = server.region
server_map.value = server&.status&.map
player_count.value = "#{server&.status&.player_count}/#{server&.status&.max_players}"
server_ping.value = ping_icon(server)
server_ping.parent.parent.tip = ping_tip(server)
game_icon&.value = game_icon(server)
server_name&.value = "<b>#{server&.status&.name}</b>"
server_channel&.value = Store.application_manager.channel_name(server.game, server.channel).to_s
server_region&.value = server.region
server_map&.value = server&.status&.map
player_count&.value = "#{server&.status&.player_count}/#{server&.status&.max_players}"
server_ping&.value = ping_icon(server)
server_ping&.parent.parent.tip = ping_tip(server)
end
def update_server_ping(server)
@@ -270,10 +261,6 @@ class W3DHub
end
def stylize_selected_server(server_container)
server_container.style.server_item_background = server_container.style.default[:background]
server_container.style.server_item_hover_background = server_container.style.hover[:background]
server_container.style.server_item_active_background = server_container.style.active[:background]
server_container.style.background = @selected_color
server_container.style.default[:background] = @selected_color
@@ -285,106 +272,107 @@ class W3DHub
@server_list_container.children.sort_by! do |child|
s = Store.server_list.find { |s| s.id == child.style.tag }
[s&.status&.player_count, s&.id]
[s.status.player_count, -s.ping]
end.reverse!.each_with_index do |child, i|
child.style.background = 0xff_333333 if i.even?
child.style.background = 0 if i.odd?
next if @selected_server_container && child == @selected_server_container
child.style.hover[:background] = 0xaa_555566
child.style.hover[:active] = 0xaa_555588
child.style.default[:background] = 0xaa_333333 if i.even?
child.style.default[:background] = 0x00_000000 if i.odd?
end
@server_list_container.recalculate
end
def populate_server_list
Store.server_list = Store.server_list.sort_by! { |s| [s&.status&.player_count, s&.id] }.reverse if Store.server_list
Store.server_list = Store.server_list.sort_by! { |s| [s.status.player_count, s.id] }.reverse
@server_list_container.clear do
i = -1
Store.server_list.each do |server|
next unless @filters[server.game.to_sym]
next unless server.region == @filter_region || @filter_region == "Any"
# next unless server.channel == "release"
i += 1
server_container = flow(width: 1.0, height: 48, hover: { background: 0xff_555566 }, active: { background: 0xff_555588 }, tag: server.id, tip: ping_tip(server)) do
background 0xff_333333 if i.even?
flow(width: 48, height: 1.0, padding: 4) do
image game_icon(server), height: 1.0, tag: :game_icon
end
stack(width: 0.45, height: 1.0) do
inscription "<b>#{server&.status&.name}</b>", tag: :server_name
flow(width: 1.0, height: 1.0) do
inscription server.channel, margin_right: 64, text_size: 14, tag: :server_channel
inscription server.region, text_size: 14, tag: :server_region
end
end
flow(fill: true, height: 1.0) do
inscription "#{server&.status&.map}", tag: :server_map
end
flow(width: 0.11, height: 1.0) do
inscription "#{server&.status&.player_count}/#{server&.status&.max_players}", tag: :player_count
end
flow(width: 48, height: 1.0, padding: 4) do
image ping_icon(server), height: 1.0, tag: :ping
end
end
def server_container.hit_element?(x, y)
self if hit?(x, y)
end
server_container.subscribe(:clicked_left_mouse_button) do
if @selected_server_container
@selected_server_container.style.background = @selected_server_container.style.server_item_background
@selected_server_container.style.default[:background] = @selected_server_container.style.server_item_background
@selected_server_container.style.hover[:background] = @selected_server_container.style.server_item_hover_background
@selected_server_container.style.active[:background] = @selected_server_container.style.server_item_active_background
end
stylize_selected_server(server_container)
@selected_server_container = server_container
@selected_server = server
BackgroundWorker.foreground_job(
-> { fetch_server_details(server) },
->(result) { populate_server_info(server) if server == @selected_server }
)
end
stylize_selected_server(server_container) if server.id == @selected_server&.id
create_server_container(server)
end
end
reorder_server_list
end
def create_server_container(server)
return unless @filters[server.game.to_sym]
return unless server.status
return unless server.region == @filter_region || @filter_region == "Any"
return unless Store.application_manager.channel_name(server.game, server.channel) # can user access required game and channel for this server?
server_container = flow(width: 1.0, height: 56, hover: { background: 0xaa_555566 }, active: { background: 0xaa_555588 }, tag: server.id, tip: ping_tip(server)) do
flow(width: 56, height: 1.0, padding: 4) do
image game_icon(server), height: 1.0, tag: :game_icon
end
stack(width: 0.45, height: 1.0) do
para server&.status&.name, tag: :server_name, font: BOLD_FONT, text_wrap: :none
flow(width: 1.0, height: 1.0) do
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
flow(fill: true, height: 1.0) do
para "#{server&.status&.map}", tag: :server_map
end
flow(width: 0.11, height: 1.0) do
para "#{server&.status&.player_count}/#{server&.status&.max_players}", tag: :player_count
end
flow(width: 56, height: 1.0, padding: 4) do
image ping_icon(server), height: 1.0, tag: :ping
end
end
def server_container.hit_element?(x, y)
self if hit?(x, y)
end
server_container.subscribe(:clicked_left_mouse_button) do
stylize_selected_server(server_container)
@selected_server_container = server_container
@selected_server = server
reorder_server_list if @selected_server_container
fetch_server_details(server)
end
stylize_selected_server(server_container) if server.id == @selected_server&.id
end
def populate_server_info(server)
@game_server_info_container.clear do
stack(width: 1.0, height: 1.0, padding: 8) do
stack(width: 1.0, height: 220) do
flow(width: 1.0, height: 0.2) do
stack(width: 1.0, height: 208) do
flow(width: 1.0, height: 34) do
flow(fill: true)
image game_icon(server), width: 0.05
tagline server.status.name, text_wrap: :none
image game_icon(server), height: 1.0
title server.status.name[0..30], text_wrap: :none
flow(fill: true)
end
flow(width: 1.0, height: 0.2) 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_updatable = Store.application_manager.updateable?(server.game, server.channel)
style = server.channel != "release" ? TESTING_BUTTON : {}
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
@@ -401,11 +389,11 @@ class W3DHub
if server.status.password
W3DHub.prompt_for_password(
accept_callback: proc do |password|
W3DHub.join_server(server, password)
W3DHub.join_server(server: server, password: password)
end
)
else
W3DHub.join_server(server, nil)
W3DHub.join_server(server: server)
end
end
)
@@ -413,48 +401,49 @@ class W3DHub
if server.status.password
W3DHub.prompt_for_password(
accept_callback: proc do |password|
W3DHub.join_server(server, password)
W3DHub.join_server(server: server, password: password)
end
)
else
W3DHub.join_server(server, nil)
W3DHub.join_server(server: server)
end
end
end
if Store.developer_mode
list_box(items: (1..12).to_a.map(&:to_s), margin_left: 16, **TESTING_BUTTON)
button "Multijoin", tip: "Launch multiple clients with configured username_\#{number}", **TESTING_BUTTON, enabled: true
if W3DHUB_DEVELOPER
client_instances = list_box(items: (1..12).to_a.map(&:to_s), margin_left: 16, width: 72, tip: "Number of game clients", enabled: (game_installed && !game_updatable), **TESTING_BUTTON)
button("Multijoin", tip: "Launch multiple clients with configured username_\#{number}", enabled: (game_installed && !game_updatable), **TESTING_BUTTON) do
username = Store.settings[:server_list_username]
client_instances.value.to_i.times do |i|
W3DHub.join_server(server: server, username: format("%s_%d", username, i), multi: true)
end
end
end
flow(fill: true)
end
# Server Info
stack(width: 1.0, fill: true, margin_top: 16) do
stack(width: 1.0, fill: true, margin_bottom: 16) do
flow(width: 1.0) do
inscription "<b>#{I18n.t(:"server_browser.game")}</b>", width: 0.28, text_wrap: :none
inscription "#{game_name(server.game)} (#{server.channel})", width: 0.71, text_wrap: :none
para "<b>#{I18n.t(:"server_browser.game")}</b>", width: 0.12, text_wrap: :none, font: BOLD_FONT
para "#{game_name(server.game)} (#{server.channel})", width: 0.71, text_wrap: :none
end
flow(width: 1.0) do
inscription "<b>#{I18n.t(:"server_browser.map")}</b>", width: 0.28, text_wrap: :none
inscription server.status.map, width: 0.71, text_wrap: :none
para "<b>#{I18n.t(:"server_browser.map")}</b>", width: 0.12, text_wrap: :none, font: BOLD_FONT
para server.status.map, width: 0.71, text_wrap: :none
end
flow(width: 1.0) do
inscription "<b>#{I18n.t(:"server_browser.max_players")}</b>", width: 0.28, text_wrap: :none
inscription "#{server.status.max_players}", width: 0.71, text_wrap: :none
end
para "<b>#{I18n.t(:"server_browser.time")}</b>", width: 0.12, text_wrap: :none, font: BOLD_FONT
para formatted_rentime(server.status.started), text_wrap: :none
flow(width: 1.0) do
inscription "<b>#{I18n.t(:"server_browser.time")}</b>", width: 0.28, text_wrap: :none
inscription formatted_rentime(server.status.started), width: 0.71, text_wrap: :none
end
flow(width: 1.0) do
inscription "<b>#{I18n.t(:"server_browser.remaining")}</b>", width: 0.28, text_wrap: :none
inscription "#{server.status.remaining}", width: 0.71, text_wrap: :none
unless server.status.remaining =~ /00:00:00|00.00.00/
para "<b>#{I18n.t(:"server_browser.remaining")}</b>", margin_left: 16, margin_right: 8, text_wrap: :none, font: BOLD_FONT
para "#{server.status.remaining}", text_wrap: :none
end
end
end
end
@@ -462,9 +451,9 @@ class W3DHub
game_balance = server_game_balance(server)
# Game score and balance display
flow(width: 1.0, height: 48, border_thickness_bottom: 2, border_color_bottom: 0x44_ffffff) do
flow(width: 1.0, height: 52, border_thickness_bottom: 2, border_color_bottom: 0x44_ffffff) do
stack(fill: true, height: 1.0) do
para "<b>#{server.status.teams[0].name} (#{server.status.players.select { |pl| pl.team == 0 }.count})</b>", width: 1.0, text_align: :center
para "#{server.status.teams[0].name} (#{server.status.players.select { |pl| pl.team == 0 }.count})", width: 1.0, text_align: :center, font: BOLD_FONT
para formatted_score(game_balance[:team_0_score].to_i), width: 1.0, text_align: :center
end
@@ -479,7 +468,7 @@ class W3DHub
end
stack(fill: true, height: 1.0) do
para "<b>#{server.status.teams[1].name} (#{server.status.players.select { |pl| pl.team == 1 }.count})</b>", width: 1.0, text_align: :center
para "#{server.status.teams[1].name} (#{server.status.players.select { |pl| pl.team == 1 }.count})", width: 1.0, text_align: :center, font: BOLD_FONT
para formatted_score(game_balance[:team_1_score].to_i), width: 1.0, text_align: :center
end
end
@@ -488,15 +477,15 @@ class W3DHub
flow(width: 1.0, fill: true, scroll: true) do
stack(width: 0.5) do
server.status.players.select { |ply| ply.team == 0 }.sort_by { |ply| ply.score }.reverse.each_with_index do |player, i|
flow(width: 1.0, height: 18) do
background 0xff_333333 if i.even?
flow(width: 1.0, height: 26) do
background 0xaa_333333 if i.even?
stack(width: 0.6, height: 1.0) do
inscription player.nick, text_size: 14, text_wrap: :none
para player.nick, text_wrap: :none
end
stack(width: 0.4, height: 1.0) do
inscription formatted_score(player.score), text_size: 14, width: 1.0, text_align: :right, text_wrap: :none
para formatted_score(player.score), width: 1.0, text_align: :right, text_wrap: :none
end
end
end
@@ -504,15 +493,15 @@ class W3DHub
stack(width: 0.5, border_thickness_left: 2, border_color_left: 0xff_000000) do
server.status.players.select { |ply| ply.team == 1 }.sort_by { |ply| ply.score }.reverse.each_with_index do |player, i|
flow(width: 1.0, height: 18) do
background 0xff_333333 if i.even?
flow(width: 1.0, height: 26) do
background 0xaa_333333 if i.even?
stack(width: 0.6, height: 1.0) do
inscription player.nick, text_size: 14, text_wrap: :none
para player.nick, text_wrap: :none
end
stack(width: 0.4, height: 1.0) do
inscription formatted_score(player.score), text_size: 14, width: 1.0, text_align: :right, text_wrap: :none
para formatted_score(player.score), width: 1.0, text_align: :right, text_wrap: :none
end
end
end
@@ -523,14 +512,17 @@ class W3DHub
end
def fetch_server_details(server)
BackgroundWorker.foreground_job(
-> { Api.server_details(server.id, 2) },
->(server_data) { server.update(server_data) if server_data }
)
Api.server_details(server.id, 2) do |result|
if result.okay?
server.update(result.data)
populate_server_info(server) if server == @selected_server
@refresh_server = nil
end
end
end
def game_icon(server)
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{server.game.nil? ? 'ren' : server.game}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{server.game.nil? ? 'ren' : server.game}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_path = File.exist?("#{CACHE_PATH}/#{server.game.nil? ? 'ren' : server.game}.png") ? "#{CACHE_PATH}/#{server.game.nil? ? 'ren' : server.game}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
if server.status.password
@server_locked_icons[server.game] ||= Gosu.render(96, 96) do

View File

@@ -3,82 +3,92 @@ class W3DHub
class Settings < Page
def setup
body.clear do
stack(width: 1.0, height: 1.0, padding: 16, scroll: true) do
stack(width: 1.0, height: 1.0, padding: 16) do
background 0xaa_252525
para "<b>Language</b>"
flow(width: 1.0, height: 0.12) do
para "<b>Launcher Language</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@language_menu = list_box items: I18n.available_locales.map { |l| expand_language_code(l.to_s) }, choose: expand_language_code(Store.settings[:language]), width: 1.0
inscription "Select the UI language you'd like to use in the W3D Hub Launcher."
stack(width: 1.0, fill: true, max_width: 720, h_align: :center, scroll: true) do
tagline "Launcher Language"
@language_menu = list_box items: I18n.available_locales.map { |l| expand_language_code(l.to_s) }, choose: expand_language_code(Store.settings[:language]), width: 1.0, margin_left: 16
para "Select the UI language you'd like to use in the W3D Hub Launcher.", margin_left: 16
tagline "Launcher Directories", margin_top: 16
caption "Applications Install Directory", margin_left: 16
flow(width: 1.0, margin_left: 16) do
@app_install_dir_input = edit_line Store.settings[:app_install_dir], fill: true
button "Browse...", width: 128, tip: "Browse for applications install directory" do
path = W3DHub.ask_folder
@app_install_dir_input.value = path unless path.empty?
end
end
caption "Package Cache Directory", margin_left: 16, margin_top: 16
flow(width: 1.0, margin_left: 16) do
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
button "Browse...", width: 128, tip: "Browse for package cache directory" do
path = W3DHub.ask_folder
@package_cache_dir_input.value = path unless path.empty?
end
end
if W3DHub.unix?
tagline "Wine - Windows compatibility layer", margin_top: 16
caption "Wine Command", margin_left: 16
flow(width: 1.0, margin_left: 16) do
@wine_command_input = edit_line Store.settings[:wine_command], fill: true
button "Browse...", width: 128, tip: "Browse for wine executable" do
path = W3DHub.ask_file(filters: %w[wine proton])
@wine_command_input.value = path unless path.empty?
end
end
para "Command to use to for Windows compatiblity layer.", margin_left: 16
caption "Wine Prefix", margin_left: 16, margin_top: 16
flow(width: 1.0, margin_left: 16) do
@wine_prefix_input = edit_line Store.settings[:wine_prefix], fill: true
button "Browse...", width: 128, tip: "Browse for wine prefix directory" do
path = W3DHub.ask_folder
@wine_prefix_input.value = path unless path.empty?
end
end
para "Leave empty to use default global prefix.", margin_left: 16
link "Wiki: Getting Started With Wine", tip: "https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine", margin_top: 16, margin_left: 16, border_color_bottom: 0xff_777777 do
W3DHub.url("https://github.com/cyberarm/w3d_hub_linux_launcher/wiki/Getting-Started-With-Wine")
end
# TODO: support winetricks stuff
# tagline "Winetricks", margin_top: 16
# caption "Winetricks Command", margin_left: 16
# flow(width: 1.0, margin_left: 16) do
# @winetricks_command_input = edit_line Store.settings[:winetricks_command], fill: true, enabled: false
# button "Browse...", width: 128, tip: "Browse for winetricks executable", enabled: false do
# path = W3DHub.ask_file(filters: %w[winetricks protontricks])
# @winetricks_command_input.value = path unless path.empty?
# end
# end
# caption "Fixups", margin_left: 16, margin_top: 16
# button "Install d3dcompiler_47", margin_left: 16, enabled: false
# para "Fixes games instantly crashing at startup due to not being able to compile shaders.", margin_left: 16
# button "Install DXVK", margin_left: 16, margin_top: 16, enabled: false
# para "Use Vulkan-based DirectX translation layers.", margin_left: 16
# para "WARNING: Games will stop working if your hardware does not support Vulkan!", margin_left: 16
end
end
para "<b>Folder Paths</b>", margin_top: 8, padding_top: 8, border_thickness_top: 2, border_color_top: 0xee_ffffff, width: 1.0
stack(width: 1.0, height: 0.3) do
flow(width: 1.0, height: 0.5) do
para "<b>App Install Folder</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@app_install_dir_input = edit_line Store.settings[:app_install_dir], width: 1.0
inscription "The folder into which new games and apps will be installed by the launcher"
end
end
flow(width: 1.0, margin_top: 16) do
para "<b>Package Cache Folder</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], width: 1.0
inscription "A folder which will be used to cache downloaded packages used to install games and apps"
end
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
if true # W3DHub.unix?
para "<b>Wine</b>", margin_top: 8, padding_top: 8, border_thickness_top: 2, border_color_top: 0xee_ffffff, width: 1.0
flow(width: 1.0, height: 0.12) do
para "<b>Wine Command</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0
inscription "Command to use to for Windows compatiblity layer"
end
end
flow(width: 1.0, height: 0.13, margin_top: 16) do
para "<b>Wine Prefix</b>", width: 0.249, margin_left: 32, margin_top: 12
stack(width: 0.75) do
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix]
inscription "Whether each game gets its own prefix. Uses global/default prefix by default."
end
end
end
button "Save" do
old_language = Store.settings[:language]
Store.settings[:language] = language_code(@language_menu.value)
Store.settings[:app_install_dir] = @app_install_dir_input.value
Store.settings[:package_cache_dir] = @package_cache_dir_input.value
Store.settings[:wine_command] = @wine_command_input.value
Store.settings[:wine_prefix] = @wine_prefix_toggle.value
Store.settings.save_settings
begin
I18n.locale = Store.settings[:language]
rescue I18n::InvalidLocale
I18n.locale = :en
end
if old_language == Store.settings[:language]
page(Pages::Games)
else
# pop back to Boot state which will immediately push a new instance of Interface
pop_state
end
button("Clear package cache: #{W3DHub.format_size(Dir.glob("#{Store.settings[:package_cache_dir]}/**/**").map { |f| File.file?(f) ? File.size(f) : 0}.sum)}", tip: "Purge #{Store.settings[:package_cache_dir]}", **DANGEROUS_BUTTON) do |btn|
logger.info(LOG_TAG) { "Purging cache (#{Store.settings[:package_cache_dir]})..." }
FileUtils.remove_dir(Store.settings[:package_cache_dir], force: true)
btn.value = "Clear package cache: #{W3DHub.format_size(Dir.glob("#{Store.settings[:package_cache_dir]}/**/**").map { |f| File.file?(f) ? File.size(f) : 0}.sum)}"
end
end
end
@@ -108,7 +118,37 @@ class W3DHub
when "es"
"Español"
else
raise "Unknown language error"
logger.warn("W3DHub::Settings") { "Unknown language code: #{string.inspect}" }
"UNKNOWN"
end
end
def save_settings!
old_language = Store.settings[:language]
Store.settings[:language] = language_code(@language_menu.value)
Store.settings[:app_install_dir] = @app_install_dir_input.value
Store.settings[:package_cache_dir] = @package_cache_dir_input.value
Store.settings[:wine_command] = @wine_command_input.value
Store.settings[:wine_prefix] = @wine_prefix_input.value
Store.settings[:winetricks_command] = @winetricks_command_input.value if @winetricks_command_input
Store.settings.save_settings
begin
I18n.locale = Store.settings[:language]
rescue I18n::InvalidLocale
I18n.locale = :en
end
if old_language == Store.settings[:language]
page(Pages::Games)
else
# pop back to Boot state which will immediately push a new instance of Interface
pop_state
end
end
end

166
lib/ping.rb Normal file
View File

@@ -0,0 +1,166 @@
require "async"
require "socket"
require "securerandom"
class W3DHub
class Ping
ICMPHeader = Data.define(:type, :code, :checksum, :_ping_id, :_sequence_id, :data)
EchoRequest = Struct.new(:ping_id, :sequence_id, :data, :time, :timed_out)
ICMP_ECHOREPLY = 0
ICMP_ECHO = 8
ICMP_SUBCODE = 0
BIT_PACKER = "C2 n3 A*".freeze
MINIMUM_INTERVAL = 250 # ms # intervals below 200ms are considered rude and may be dropped due to flooding.
ECHO_REQUEST_HISTORY = 30 # 100 # keep the last n requests
attr_reader :address
def initialize(address:, count: 10, ttl: 120, interval: 1_000, data: nil)
@address = address
@count = count
@ttl = ttl
@interval = interval.to_i < MINIMUM_INTERVAL ? MINIMUM_INTERVAL : interval # ms
@data = data
# circular buffer
@echo_requests = Array.new(ECHO_REQUEST_HISTORY) { EchoRequest.new(-1, -1, "", nil, false) }
@echo_requests_index = 0
# NOTE: The PING_ID _might_ be overruled by the kernel and should not be used
# to check that any received echo replies are ours.
#
# Sequence ID and Data appear to be unmodified.
@ping_id = SecureRandom.hex.to_i(16) & 0xffff
@sequence_id = SecureRandom.hex.to_i(16) & 0xffff
addresses = Addrinfo.getaddrinfo(@address, nil, Socket::AF_INET, :DGRAM)
raise "NO ADDRESSES!" if addresses.empty?
@socket_address = addresses.sample.to_sockaddr
@socket = Socket.new(Socket::AF_INET, Socket::SOCK_DGRAM, Socket::IPPROTO_ICMP)
@socket.setsockopt(Socket::SOL_SOCKET, Socket::IP_TTL, @ttl)
end
# Perform a checksum on the message. This is the sum of all the short
# words and it folds the high order bits into the low order bits.
def message_checksum(message)
length = message.length
num_short = length / 2
check = 0
message.unpack("n#{num_short}").each do |short|
check += short
end
check += message[length - 1, 1].unpack1("C") << 8 if (length % 2).positive?
check = (check >> 16) + (check & 0xffff)
~((check >> 16) + check) & 0xffff
end
def random_data
SecureRandom.hex
end
def monotonic_time
Process.clock_gettime(:CLOCK_MONOTONIC, :millisecond)
end
def verified?(message)
data = message.unpack(BIT_PACKER)
checksum = data[2]
# set checksum in message to 0
data[2] = 0
checksum == message_checksum(data.pack(BIT_PACKER))
end
def request_complete?(request)
request.timed_out || !request.time.nil?
end
def packet_loss
completed_requests = @echo_requests.select { |r| request_complete?(r) }
failed_requests = completed_requests.select(&:timed_out)
# 0% packet loss 😎
return 0.0 if failed_requests.empty?
# 100% packet loss
return 1.0 if failed_requests.size == completed_requests.size
failed_requests.size / completed_requests.size.to_f
end
def average_ping
times = @echo_requests.select { |r| request_complete?(r) && !r.timed_out }.map(&:time)
return -1 unless times.size.positive?
times.sum.to_f / times.size
end
# returns true if any echo requests have completed (reply received or timed out) and packet loss is less than 30%
def okay?
completed_requests = @echo_requests.select { |r| request_complete?(r) }.size
completed_requests.positive? && packet_loss < 0.3
end
def ping(count = @count)
return if count <= 0
Async do |task|
@count.times do
task.Async do |subtask|
@sequence_id = (@sequence_id + 1) % 0xffff
data = @data || random_data
checksum = 0
message = [ICMP_ECHO, ICMP_SUBCODE, checksum, @ping_id, @sequence_id, data].pack(BIT_PACKER)
checksum = message_checksum(message)
message = [ICMP_ECHO, ICMP_SUBCODE, checksum, @ping_id, @sequence_id, data].pack(BIT_PACKER)
@socket.send(message, 0, @socket_address)
s = monotonic_time
request = @echo_requests[@echo_requests_index]
request.ping_id = @ping_id
request.sequence_id = @sequence_id
request.data = data
request.time = nil
request.timed_out = false
@echo_requests_index = (@echo_requests_index + 1) % ECHO_REQUEST_HISTORY
subtask.with_timeout(2) do
loop do
data, _addrinfo = @socket.recvfrom(1500)
# ignore corruption
next unless verified?(data)
header = ICMPHeader.new(*data.unpack(BIT_PACKER))
if header.type == ICMP_ECHOREPLY && header._sequence_id == request.sequence_id && header.data == request.data
duration = monotonic_time - s
request.time = duration
break
end
end
rescue Async::TimeoutError
request.timed_out = true
end
end
# Don't send out pings in a flood, it's considered rude.
sleep @interval / 1000.0
end
end
end
end
end

54
lib/ping_manager.rb Normal file
View File

@@ -0,0 +1,54 @@
class W3DHub
class PingManager
Container = Struct.new(:pinger, :last_ping_time_ms)
PING_INTERVAL = 60_000
def initialize
@containers = {}
@addresses = []
end
def monitor(task)
task.async do |subtask|
while BackgroundWorker.alive?
# activate new addresses
@addresses.each do |address|
@containers[address] ||= Container.new(Ping.new(address: address), -PING_INTERVAL * 2)
end
# cleanup old addresses
@containers.each_key do |key|
@containers.delete(key) unless @addresses.find { |a| a == key }
end
# ping the pingers
@containers.each_value do |container|
next unless Gosu.milliseconds - container.last_ping_time_ms >= PING_INTERVAL
container.last_ping_time_ms = Gosu.milliseconds
subtask.async do
container.pinger.ping
# pp [container.pinger.address, container.pinger.average_ping]
end
end
sleep 0.001
end
end
end
def add_address(address)
@addresses << address
@addresses.uniq!
end
def ping_for(address)
@containers[address]&.pinger&.average_ping&.round || -1
end
def remove_address(address)
@addresses.delete(address)
end
end
end

View File

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

View File

@@ -8,40 +8,42 @@ class W3DHub
theme(W3DHub::THEME)
background 0xff_252525
@fraction = 0.0
@w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png")
@tasks = {
# connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com?
connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com?
# launcher_updater: { started: false, complete: false },
server_list: { started: false, complete: false },
refresh_user_token: { started: false, complete: false },
service_status: { started: false, complete: false },
applications: { started: false, complete: false },
app_icons: { started: false, complete: false },
server_list: { started: false, complete: false }
app_logos_and_backgrounds: { started: false, complete: false }
}
@offline_mode = false
@task_index = 0
stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR) 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
stack(width: 1.0, height: 75) do
@progressbar = progress height: 25, width: 1.0
flow(width: 1.0, fill: true, padding_left: 16, padding_right: 16, padding_bottom: 8, padding_top: 8) do
@status_label = caption "Starting #{I18n.t(:app_name_simple)}...", width: 0.5
inscription "#{I18n.t(:app_name)} #{W3DHub::VERSION}", width: 0.5, text_align: :right
stack(width: 1.0, height: 60) do
flow(width: 1.0, height: 26, margin_left: 16, margin_right: 16, margin_bottom: 8, margin_top: 8) do
@status_label = caption "Starting #{I18n.t(:app_name_simple)}...", fill: true
para "#{I18n.t(:app_name)} #{W3DHub::VERSION}", text_align: :right
end
@progressbar = progress height: 4, width: 1.0, margin_left: 16, margin_right: 16, margin_bottom: 8
end
end
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, 0x44_000000, 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
@@ -54,10 +56,35 @@ class W3DHub
@progressbar.value = @fraction
load_offline_applications_list if @offline_mode
if @offline_mode
load_offline_applications_list
unless Store.applications
@progressbar.value = 0.0
@status_label.value = "<c=f80>Unable to connect to W3D Hub API. No application data cached, unable to continue.</c>"
return
end
end
if @offline_mode || (@progressbar.value >= 1.0 && @task_index == @tasks.size)
pop_state
# --- Repair/Upgrade settings schema/data
Store.settings[:favorites] ||= {}
# add game colo[u]r and uses_engine_cfg to application data
unless @offline_mode
Store.settings[:games].each do |key, game|
application = Store.applications.games.find { |g| g.id == key.to_s.split("_", 2).first }
next unless application
game[:colour] = "##{application.color.to_s(16)}"
game[:uses_engine_cfg] = application.uses_engine_cfg?
end
end
Store.settings.save_settings
push_state(States::Interface)
end
@@ -88,9 +115,8 @@ class W3DHub
Api.on_thread(:refresh_user_login, account.refresh_token) do |refreshed_account|
update_account_data(refreshed_account)
end
else
BackgroundWorker.foreground_job(-> { update_account_data(account) }, ->(_) {})
Store.main_thread_queue << -> { update_account_data(account) }
end
else
@@ -104,7 +130,7 @@ class W3DHub
Store.settings[:account][:data] = account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false)
Cache.fetch(uri: account.avatar_uri, force_fetch: true, backend: :w3dhub)
else
Store.settings[:account] = {}
end
@@ -114,9 +140,44 @@ 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
@status_label.value = "Checking service status..." #I18n.t(:"server_browser.fetching_server_list")
Api.on_thread(:service_status) do |result|
@service_status = result.okay? ? result.data : nil
if @service_status
Store.service_status = @service_status
@@ -127,7 +188,7 @@ class W3DHub
@tasks[:service_status][:complete] = true
else
BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = I18n.t(:"boot.w3dhub_service_is_down") })
Store.main_thread_queue << -> { @status_label.value = I18n.t(:"boot.w3dhub_service_is_down") }
@tasks[:service_status][:complete] = true
@offline_mode = true
@@ -136,17 +197,42 @@ class W3DHub
end
end
def launcher_updater
@status_label.value = "Checking for Launcher updates..." # I18n.t(:"boot.checking_for_updates")
Api.on_thread(:fetch, "https://api.github.com/repos/cyberarm/w3d_hub_linux_launcher/releases/latest") do |result|
if result.okay?
hash = JSON.parse(result.data, symbolize_names: true)
available_version = hash[:tag_name].downcase.sub("v", "")
pp Gem::Version.new(available_version) > Gem::Version.new(W3DHub::VERSION)
pp [Gem::Version.new(available_version), Gem::Version.new(W3DHub::VERSION)]
push_state(
LauncherUpdaterDialog,
release_data: hash,
available_version: available_version,
cancel_callback: -> { @tasks[:launcher_updater][:complete] = true },
accept_callback: -> { @tasks[:launcher_updater][:complete] = true }
)
else
# Failed to retrieve release data from github
log "Failed to retrieve release data from Github"
@tasks[:launcher_updater][:complete] = true
end
end
end
def applications
@status_label.value = I18n.t(:"boot.checking_for_updates")
Api.on_thread(:applications) do |applications|
if applications
Store.applications = applications
Api.on_thread(:_applications) do |result|
if result.okay?
Store.applications = result.data
Store.settings.save_application_cache(Store.applications.data.to_json)
@tasks[:applications][:complete] = true
else
# FIXME: Failed to retreive!
BackgroundWorker.foreground_job(-> {}, ->(_){ @status_label.value = "FAILED TO RETREIVE APPS LIST" })
@status_label.value = "FAILED TO RETRIEVE APPS LIST"
@offline_mode = true
Store.offline_mode = true
@@ -157,50 +243,94 @@ class W3DHub
def app_icons
return unless Store.applications
@status_label.value = "Retrieving application icons, this might take a moment..." # I18n.t(:"boot.checking_for_updates")
packages = []
failure = false
Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" }
end
Api.on_thread(:package_details, packages) do |package_details|
package_details ||= nil
Api.on_thread(:package_details, packages, :alt_w3dhub) do |result|
if result.okay?
result.data.each do |package|
next if package.error?
package_details&.each do |package|
path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
generated_icon_path = "#{GAME_ROOT_PATH}/media/icons/#{package.subcategory}.png"
path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
generated_icon_path = "#{CACHE_PATH}/#{package.subcategory}.png"
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
regenerate = !File.exist?(generated_icon_path)
else
Cache.fetch_package(package, proc {})
regenerate = true
end
if File.exist?(path) && !broken_or_out_dated_icon
regenerate = !File.exist?(generated_icon_path)
else
begin
Cache.fetch_package(package, proc {})
regenerate = true
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) })
next unless regenerate
icon = ICO.new(file: path)
icon.save(icon.images.max_by(&:width), generated_icon_path)
end
end
@tasks[:app_icons][:complete] = true
@tasks[:app_icons][:complete] = true unless failure
end
end
def app_logos_and_backgrounds
return unless Store.applications
@status_label.value = "Retrieving application image assets, this might take a moment..." # I18n.t(:"boot.checking_for_updates")
packages = []
Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "logo.png", version: "" }
packages << { category: app.category, subcategory: app.id, name: "background.png", version: "" }
end
Api.on_thread(:package_details, packages, :alt_w3dhub) do |result|
if result.okay?
result.data.each do |package|
next if package.error?
package_cache_path = Cache.package_path(package.category, package.subcategory, package.name,
package.version)
missing_or_broken_image = File.exist?(package_cache_path) ? Digest::SHA256.new.hexdigest(File.binread(package_cache_path)).upcase != package.checksum.upcase : true
Cache.fetch_package(package, proc {}) if missing_or_broken_image
end
end
@tasks[:app_logos_and_backgrounds][:complete] = true
end
end
def server_list
@status_label.value = I18n.t(:"server_browser.fetching_server_list")
Api.on_thread(:server_list, 2) do |list|
if list
Store.server_list = list.sort_by! { |s| s&.status&.players&.size }.reverse
Api.on_thread(:server_list, 2) do |result|
if result.okay?
Store.server_list = result.data.sort_by! { |s| s&.status&.players&.size }.reverse
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance
list.each do |server|
Store.server_list.each do |server|
server.send_ping(true)
end
else
@@ -213,6 +343,12 @@ class W3DHub
end
def load_offline_applications_list
if (application_cache = Store.settings.load_application_cache)
Store.applications = Api::Applications.new(application_cache.to_json)
return
end
hash = {
applications: []
}
@@ -230,7 +366,10 @@ class W3DHub
"studio-id": "",
channels: [],
"web-links": [],
"extended-data": [{ name: "colour", value: "#353535" }]
"extended-data": [
{ name: "colour", value: game[:colour] },
{ name: "usesEngineCfg", value: game[:uses_engine_cfg] }
]
}
channel = {
@@ -245,7 +384,7 @@ class W3DHub
hash[:applications] << app unless app_in_array
end
Store.applications = Api::Applications.new(hash.to_json)
Store.applications = Api::Applications.new(hash.to_json) unless hash[:applications].empty?
end
end
end

View File

@@ -6,22 +6,22 @@ class W3DHub
theme(W3DHub::THEME)
background 0xee_444444
background 0xaa_525252
stack(width: 1.0, max_width: 720, height: 1.0, max_height: 480, v_align: :center, h_align: :center, background: 0xee_222222) do
flow(width: 1.0, height: 0.1, padding: 8) do
flow(width: 1.0, height: 48, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", width: 0.04, align: :center, color: 0xaa_ff0000
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", height: 1.0, align: :center, color: 0xaa_ff0000
tagline "<b>#{@options[:title]}</b>", width: 0.9, text_align: :center
title "<b>#{@options[:title]}</b>", width: 0.9, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, height: 0.78, padding: 16) do
stack(width: 1.0, fill: true, padding: 16) do
para @options[:message], width: 1.0, text_align: :center
end
flow(width: 1.0, height: 0.1, padding: 8) do
flow(width: 1.0, height: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call
@@ -29,7 +29,7 @@ class W3DHub
stack(width: 0.5)
button "Confirm", width: 0.25, background: 0xff_800000, hover: { background: 0xff_d00000 }, active: { background: 0xff_600000, color: 0xff_ffffff } do
button "Confirm", width: 0.25, **DANGEROUS_BUTTON do
pop_state
@options[:accept_callback]&.call
end

View File

@@ -7,20 +7,20 @@ class W3DHub
theme(W3DHub::THEME)
background 0xee_444444
background 0xaa_525252
stack(width: 1.0, max_width: 720, height: 1.0, max_height: 540, v_align: :center, h_align: :center, background: 0xee_222222) do
stack(width: 1.0, max_width: 720, height: 1.0, max_height: 576, v_align: :center, h_align: :center, background: 0xee_222222) do
# Title bar
flow(width: 1.0, height: 32, padding: 8) do
flow(width: 1.0, height: 36, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/export.png", width: 32, align: :center, color: 0xaa_ffffff
image "#{GAME_ROOT_PATH}/media/ui_icons/export.png", height: 1.0, align: :center, color: 0xaa_ffffff
tagline "<b>#{I18n.t(:"server_browser.direct_connect")}</b>", fill: true, text_align: :center
title "<b>#{I18n.t(:"server_browser.direct_connect")}</b>", fill: true, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, scroll: true) do
stack(width: 1.0, height: 66, margin_left: 8, margin_right: 8) do
stack(width: 1.0, height: 72, margin_left: 8, margin_right: 8) do
para "Server profiles", text_align: :center, width: 1.0
flow(width: 1.0, fill: true) do
@@ -41,7 +41,7 @@ class W3DHub
end
@server_delete_button = button get_image("#{GAME_ROOT_PATH}/media/ui_icons/minus.png"), image_height: 1.0, tip: "Remove selected profile" do
push_state(ConfirmDialog, message: "Purge server profile")
push_state(ConfirmDialog, title: "Are you sure?", message: "Remove Server Profile: \"#{@server_profiles_list.value}\"?", accept_callback: -> { delete_server_profile(server_profile_from_name(@server_profiles_list.value)) })
end
@server_edit_button = button get_image("#{GAME_ROOT_PATH}/media/ui_icons/save.png"), image_height: 1.0, tip: "Edit and save selected profile" do
@@ -51,7 +51,7 @@ class W3DHub
end
stack(width: 1.0, fill: true, margin_top: 8, padding: 8, border_color: 0xff_111111, border_thickness: 1) do
flow(width: 1.0, height: 66) do
flow(width: 1.0, height: 72) do
stack(width: 0.5, height: 1.0) do
para "Nickname:"
@server_nickname = edit_line "", width: 1.0, fill: true
@@ -73,7 +73,7 @@ class W3DHub
end
end
flow(width: 1.0, height: 66) do
flow(width: 1.0, height: 72) do
stack(width: 0.5, height: 1.0) do
para "Server IP or Hostname:"
@server_hostname = edit_line "", width: 1.0, fill: true
@@ -95,7 +95,7 @@ class W3DHub
end
end
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "Game or Mod:"
flow(width: 1.0, fill: true) do
@@ -125,7 +125,7 @@ class W3DHub
end
end
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "Launch arguments (Optional):"
@launch_arguments = edit_line "", width: 1.0, fill: true
@launch_arguments.subscribe(:changed) do |e|
@@ -135,7 +135,7 @@ class W3DHub
end
end
stack(width: 1.0, height: 66) do
stack(width: 1.0, height: 72) do
para "IRC Profile (Optional):"
flow(width: 1.0, fill: true) do
@@ -162,7 +162,7 @@ class W3DHub
end
end
flow(width: 1.0, height: 40, padding: 8) do
flow(width: 1.0, height: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
end
@@ -257,7 +257,7 @@ class W3DHub
server_profile: @server_profiles_list.value,
server_hostname: @server_hostname.value,
server_port: @server_port.value,
game: @games_list.value,
game_title: @games_list.value,
launch_arguments: @launch_arguments.value,
irc_profile: @irc_profiles_list.value
}
@@ -275,6 +275,24 @@ class W3DHub
@changes_made = false
end
def delete_server_profile(profile)
index = W3DHub::Store[:asterisk_config].server_profiles.index(profile)
return unless index
W3DHub::Store[:asterisk_config].server_profiles.delete(profile)
W3DHub::Store[:asterisk_config].save_config
@server_profiles_list.items = W3DHub::Store[:asterisk_config].server_profiles.map { |pf| pf.name }
if W3DHub::Store[:asterisk_config].server_profiles.size.positive?
@server_profiles_list.choose = W3DHub::Store[:asterisk_config].server_profiles[index - 1 > 0 ? index - 1 : 0].name
end
end
def server_profile_from_name(name)
W3DHub::Store[:asterisk_config].server_profiles.find { |pf| name == pf.name }
end
def game_from_title(title)
W3DHub::Store[:asterisk_config].games.find { |g| title == g.title }
end
@@ -362,7 +380,8 @@ class W3DHub
end
def delete_irc_profile(profile)
index = W3DHub::Store[:asterisk_config].irc_profiles.index(profile) || 0
index = W3DHub::Store[:asterisk_config].irc_profiles.index(profile)
return unless index
W3DHub::Store[:asterisk_config].irc_profiles.delete(profile)

View File

@@ -1,7 +1,11 @@
class W3DHub
class States
class GameSettingsDialog < Dialog
BUTTON_STYLE = { text_size: 18, padding_top: 3, padding_bottom: 3, padding_left: 3, padding_right: 3 }
BUTTON_STYLE = { text_size: 18, padding_top: 3, padding_bottom: 3, padding_left: 3, padding_right: 3, height: 18 }
LIST_ITEM_THEME = Marshal.load(Marshal.dump(THEME))
BUTTON_STYLE.each do |key, value|
LIST_ITEM_THEME[:Button][key] = value
end
def setup
window.show_cursor = true
@@ -13,13 +17,13 @@ class W3DHub
@game_settings = GameSettings.new(@app_id, @channel)
background 0xee_444444
background 0xaa_525252
stack(width: 1.0, max_width: 720, height: 1.0, max_height: 680, v_align: :center, h_align: :center, background: 0xee_222222, border_thickness: 2, border_color: 0xff_444444, padding: 10) do
flow(width: 1.0, height: 0.1, padding: 8) do
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 720, v_align: :center, h_align: :center, background: 0xee_222222, border_thickness: 2, border_color: 0xee_222222, padding: 10) do
flow(width: 1.0, height: 36, padding: 8) do
background Store.application_manager.color(@app_id)
title @options[:title] || Store.application_manager.name(@app_id) || "Game Settings", fill: true, text_align: :center
title @options[:title] || Store.application_manager.name(@app_id) || "Game Settings", fill: true, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding: 16, margin_top: 10) do
@@ -27,50 +31,50 @@ class W3DHub
stack(width: 0.5, height: 1.0, margin_right: 8) do
tagline "General"
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Default to First Person", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Default to First Person", fill: true, text_wrap: :none
toggle_button tip: "Default to First Person", checked: @game_settings.get_value(:default_to_first_person), **BUTTON_STYLE do |btn|
@game_settings.set_value(:default_to_first_person, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Background Downloads", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Background Downloads", fill: true, text_wrap: :none
toggle_button tip: "Background Downloads", checked: @game_settings.get_value(:background_downloads), **BUTTON_STYLE do |btn|
@game_settings.set_value(:background_downloads, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Enable Hints", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Enable Hints", fill: true, text_wrap: :none
toggle_button tip: "Enable Hints", checked: @game_settings.get_value(:hints_enabled), **BUTTON_STYLE do |btn|
@game_settings.set_value(:hints_enabled, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Enable Chat Log", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Enable Chat Log", fill: true, text_wrap: :none
toggle_button tip: "Enable Chat Log", checked: @game_settings.get_value(:chat_log), **BUTTON_STYLE do |btn|
@game_settings.set_value(:chat_log, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Show FPS", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Show FPS", fill: true, text_wrap: :none
toggle_button tip: "Show FPS", checked: @game_settings.get_value(:show_fps), **BUTTON_STYLE do |btn|
@game_settings.set_value(:show_fps, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Show Velocity", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Show Velocity", fill: true, text_wrap: :none
toggle_button tip: "Show Velocity", checked: @game_settings.get_value(:show_velocity), **BUTTON_STYLE do |btn|
@game_settings.set_value(:show_velocity, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Show Damage Numbers", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Show Damage Numbers", fill: true, text_wrap: :none
toggle_button tip: "Show Damage Numbers", checked: @game_settings.get_value(:show_damage_numbers), **BUTTON_STYLE do |btn|
@game_settings.set_value(:show_damage_numbers, btn.value)
end
@@ -80,15 +84,15 @@ class W3DHub
stack(width: 0.5, height: 1.0, margin_left: 8) do
tagline "Video"
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
res_options = @game_settings.get(:resolution_width).options.each_with_index.map do |w, i|
"#{w[0]}x#{@game_settings.get(:resolution_height).options[i][0]}"
end
current_res = "#{@game_settings.get_value(:resolution_width)}x#{@game_settings.get_value(:resolution_height)}"
para "Resolution", fill: true
list_box items: res_options, choose: current_res, width: 172, **BUTTON_STYLE do |value|
para "Resolution", fill: true, text_wrap: :none
list_box items: res_options, choose: current_res, width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
w, h = value.split("x", 2)
@game_settings.set_value(:resolution_width, w.to_i)
@@ -96,23 +100,23 @@ class W3DHub
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Windowed Mode", fill: true
list_box items: @game_settings.get(:windowed_mode).options.map { |v| v[0] }, choose: @game_settings.get_value(:windowed_mode), width: 172, **BUTTON_STYLE do |value|
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Windowed Mode", fill: true, text_wrap: :none
list_box items: @game_settings.get(:windowed_mode).options.map { |v| v[0] }, choose: @game_settings.get_value(:windowed_mode), width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
@game_settings.set_value(:windowed_mode, value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Enable VSync", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Enable VSync", fill: true, text_wrap: :none
toggle_button tip: "Enable VSync", checked: @game_settings.get_value(:vsync), **BUTTON_STYLE do |btn|
@game_settings.set_value(:vsync, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Anti-aliasing", fill: true
list_box items: @game_settings.get(:anti_aliasing).options.map { |v| v[0] }, choose: @game_settings.get_value(:anti_aliasing), width: 72, **BUTTON_STYLE do |value|
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Anti-aliasing", fill: true, text_wrap: :none
list_box items: @game_settings.get(:anti_aliasing).options.map { |v| v[0] }, choose: @game_settings.get_value(:anti_aliasing), width: 72, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
@game_settings.set_value(:anti_aliasing, value)
end
end
@@ -123,8 +127,8 @@ class W3DHub
stack(width: 0.5, height: 1.0, margin_right: 8) do
tagline "Audio"
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Master Volume", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Master Volume", fill: true, text_wrap: :none
slider(height: 1.0, width: 172, value: @game_settings.get_value(:master_volume), margin_right: 8).subscribe(:changed) do |slider|
@game_settings.set_value(:master_volume, slider.value)
end
@@ -134,8 +138,8 @@ class W3DHub
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Sound Effects", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Sound Effects", fill: true, text_wrap: :none
slider(height: 1.0, width: 172, value: @game_settings.get_value(:sound_effects_volume), margin_right: 8).subscribe(:changed) do |slider|
@game_settings.set_value(:sound_effects_volume, slider.value)
end
@@ -145,8 +149,8 @@ class W3DHub
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Dialogue", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Dialogue", fill: true, text_wrap: :none
slider(height: 1.0, width: 172, value: @game_settings.get_value(:sound_dialog_volume), margin_right: 8).subscribe(:changed) do |slider|
@game_settings.set_value(:sound_dialog_volume, slider.value)
end
@@ -156,8 +160,8 @@ class W3DHub
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Music", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Music", fill: true, text_wrap: :none
slider(height: 1.0, width: 172, value: @game_settings.get_value(:sound_music_volume), margin_right: 8).subscribe(:changed) do |slider|
@game_settings.set_value(:sound_music_volume, slider.value)
end
@@ -167,8 +171,8 @@ class W3DHub
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Cinematic", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Cinematic", fill: true, text_wrap: :none
slider(height: 1.0, width: 172, value: @game_settings.get_value(:sound_cinematic_volume), margin_right: 8).subscribe(:changed) do |slider|
@game_settings.set_value(:sound_cinematic_volume, slider.value)
end
@@ -178,8 +182,8 @@ class W3DHub
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Play Sound with Game in Background", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Play Sound with Game in Background", fill: true, text_wrap: :none
toggle_button tip: "Play Sound with Game in Background", checked: @game_settings.get_value(:sound_in_background), **BUTTON_STYLE do |btn|
@game_settings.set_value(:sound_in_background, btn.value)
end
@@ -189,51 +193,51 @@ class W3DHub
stack(width: 0.5, height: 1.0, margin_left: 8) do
tagline "Performance"
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Texture Detail", fill: true
list_box items: @game_settings.get(:texture_detail).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_detail), width: 172, **BUTTON_STYLE do |value|
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Texture Detail", fill: true, text_wrap: :none
list_box items: @game_settings.get(:texture_detail).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_detail), width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
@game_settings.set_value(:texture_detail, value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Texture Filtering", fill: true
list_box items: @game_settings.get(:texture_filtering).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_filtering), width: 172, **BUTTON_STYLE do |value|
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Texture Filtering", fill: true, text_wrap: :none
list_box items: @game_settings.get(:texture_filtering).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_filtering), width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
@game_settings.set_value(:texture_filtering, value)
end
end
# flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
# flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
# para "Shader Detail", fill: true
# list_box items: @game_settings.get(:texture_filtering).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_filtering), width: 172, **BUTTON_STYLE do |value|
# list_box items: @game_settings.get(:texture_filtering).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_filtering), width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
# @game_settings.set_value(:texture_filtering, value)
# end
# end
# flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
# flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
# para "Post Processing Detail", fill: true
# list_box items: @game_settings.get(:texture_filtering).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_filtering), width: 172, **BUTTON_STYLE do |value|
# list_box items: @game_settings.get(:texture_filtering).options.map { |v| v[0] }, choose: @game_settings.get_value(:texture_filtering), width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
# @game_settings.set_value(:texture_filtering, value)
# end
# end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "Shadow Resolution", fill: true
list_box items: @game_settings.get(:shadow_resolution).options.map { |v| v[0] }, choose: @game_settings.get_value(:shadow_resolution), width: 172, **BUTTON_STYLE do |value|
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "Shadow Resolution", fill: true, text_wrap: :none
list_box items: @game_settings.get(:shadow_resolution).options.map { |v| v[0] }, choose: @game_settings.get_value(:shadow_resolution), width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
@game_settings.set_value(:shadow_resolution, value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "High Quality Shadows", fill: true
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "High Quality Shadows", fill: true, text_wrap: :none
toggle_button tip: "High Quality Shadows", checked: @game_settings.get_value(:background_downloads), **BUTTON_STYLE do |btn|
@game_settings.set_value(:background_downloads, btn.value)
end
end
flow(width: 1.0, height: 24, margin: 4, margin_left: 10) do
para "FPS Limit", fill: true
list_box items: @game_settings.get(:fps).options.map { |v| v[0] }, choose: @game_settings.get_value(:fps), width: 172, **BUTTON_STYLE do |value|
flow(width: 1.0, height: 28, margin: 4, margin_left: 10) do
para "FPS Limit", fill: true, text_wrap: :none
list_box items: @game_settings.get(:fps).options.map { |v| v[0] }, choose: @game_settings.get_value(:fps), width: 172, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
@game_settings.set_value(:fps, value.to_i)
end
end
@@ -241,7 +245,7 @@ class W3DHub
end
end
flow(width: 1.0, height: 0.1, padding: 8) do
flow(width: 1.0, height: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call

View File

@@ -0,0 +1,85 @@
class W3DHub
class States
class ImportGameDialog < CyberarmEngine::GuiState
def setup
@application = Store.applications.games.find { |g| g.id == @options[:app_id] }
@channel = @application.channels.find { |c| c.id == @options[:channel] }
theme W3DHub::THEME
background 0x88_525252
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 268, v_align: :center, h_align: :center, background: 0xee_222222) do
# Title bar
flow(width: 1.0, height: 36, padding: 8) do
background 0x88_000000
# image "#{GAME_ROOT_PATH}/media/ui_icons/export.png", width: 32, align: :center, color: 0xaa_ffffff
# tagline "<b>#{I18n.t(:"server_browser.direct_connect")}</b>", fill: true, text_align: :center
title "Import #{@application.name} (#{@channel.name})", width: 1.0, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding_left: 8, padding_right: 8) do
stack(width: 1.0, height: 72) do
para "Path to Executable:"
flow(width: 1.0, fill: true) do
@game_path = edit_line "", fill: true, height: 1.0
button "Browse...", width: 128, height: 1.0, tip: "Browse for game executable" do
path = W3DHub.ask_file
@game_path.value = path if !path.empty? && File.exist?(path)
end
end
end
flow(fill: true)
flow(width: 1.0, margin_top: 8, height: 46, padding_bottom: 8) do
button "Cancel", fill: true, margin_right: 4 do
pop_state
end
flow(fill: true)
@save_button = button "Save", fill: true, margin_left: 4, enabled: false do
pop_state
Store.application_manager.imported!(@application, @channel, @game_path.value)
end
end
end
end
end
def draw
previous_state&.draw
Gosu.flush
super
end
def update
super
@save_button.enabled = valid?
end
def button_down(id)
super
case id
when Gosu::KB_ESCAPE
pop_state
end
end
def valid?
path = @game_path.value
File.exist?(path) && !File.directory?(path) && File.extname(path) == ".exe"
end
end
end
end

View File

@@ -0,0 +1,62 @@
class W3DHub
class States
class LauncherUpdaterDialog < Dialog
BUTTON_STYLE = { text_size: 18, padding_top: 3, padding_bottom: 3, padding_left: 3, padding_right: 3, height: 18 }
LIST_ITEM_THEME = Marshal.load(Marshal.dump(THEME))
BUTTON_STYLE.each do |key, value|
LIST_ITEM_THEME[:Button][key] = value
end
def setup
window.show_cursor = true
theme(THEME)
background 0xaa_525252
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 640, v_align: :center, h_align: :center, background: 0xee_222222, border_thickness: 2, border_color: 0xee_222222, padding: 16) do
flow(width: 1.0, height: 36, padding: 8) do
background 0xff_0052c0
title @options[:title] || "Launcher Update Available", fill: true, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, margin_top: 14) do
subtitle "Release Notes - #{@options[:available_version]}"
# case launcher_release_type
# when :git
# when :tebako
# end
pp @options[:release_data]
stack(width: 1.0, fill: true, scroll: true, padding: 8, border_thickness: 1, border_color: 0x44_ffffff) do
# para @options[:release_data][:body], width: 1.0
# FIXME: Finish this bit
@options[:release_data][:body].lines.each do |line|
line.strip
end
end
end
flow(width: 1.0, height: 46, margin_top: 16) do
background 0xff_ffffff
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call
end
flow(fill: true)
button "Update", width: 0.25 do
pop_state
@options[:accept_callback]&.call
end
end
end
end
end
end
end

View File

@@ -6,24 +6,25 @@ class W3DHub
theme(W3DHub::THEME)
background 0xee_444444
background 0xaa_525252
stack(width: 1.0, max_width: 720, height: 1.0, max_height: 480, v_align: :center, h_align: :center, background: 0xee_222222) do
flow(width: 1.0, height: 32, padding: 8) do
flow(width: 1.0, height: 36, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/warning.png", width: 32, align: :center, color: 0xff_ff8800
image "#{GAME_ROOT_PATH}/media/ui_icons/warning.png", height: 1.0, align: :center, color: 0xff_ff8800
tagline "<b>#{@options[:title]}</b>", width: 0.9, text_align: :center
title "<b>#{@options[:title]}</b>", width: 0.9, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding: 16) do
para @options[:message], width: 1.0
end
stack(width: 1.0, height: 40, padding: 8) do
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

@@ -6,15 +6,15 @@ class W3DHub
theme(W3DHub::THEME)
background 0xee_444444
background 0xaa_525252
stack(width: 1.0, max_width: 720, height: 1.0, max_height: 256, v_align: :center, h_align: :center, background: 0xee_222222) do
flow(width: 1.0, height: 32, padding: 8) do
flow(width: 1.0, height: 36, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", width: 32, align: :center, color: 0xff_ff8800
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", height: 1.0, align: :center, color: 0xff_ff8800
tagline "<b>#{@options[:title]}</b>", fill: true, text_align: :center
title "<b>#{@options[:title]}</b>", fill: true, text_align: :center, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding: 16) do
@@ -22,7 +22,7 @@ class W3DHub
@prompt_entry = edit_line @options[:prefill].to_s, margin_top: 24, width: 1.0, autofocus: true, focus: true, type: @options[:input_type] == :password ? :password : :text
end
flow(width: 1.0, height: 40, padding: 8) do
flow(width: 1.0, height: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call(@prompt_entry.value)

View File

@@ -1,6 +1,11 @@
class W3DHub
class States
class Interface < CyberarmEngine::GuiState
APPLICATIONS_UPDATE_INTERVAL = 10 * 60 * 1000 # ten minutes
SERVER_LIST_UPDATE_INTERVAL = 5 * 60 * 1000 # five minutes
DEFAULT_BACKGROUND_IMAGE = "#{GAME_ROOT_PATH}/media/banners/background.png".freeze
attr_accessor :interface_task_update_pending
@@instance = nil
@@ -18,6 +23,9 @@ class W3DHub
@service_status = @options[:service_status]
@applications = @options[:applications]
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # 5 minutes
@interface_task_update_pending = nil
@page = nil
@@ -27,10 +35,12 @@ class W3DHub
theme(W3DHub::THEME)
@interface_container = stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR, background_image: "#{GAME_ROOT_PATH}/media/banners/background.png", background_image_color: 0xff_525252, background_image_mode: :fill) do
@interface_container = stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: W3DHub::BORDER_COLOR, background_image: DEFAULT_BACKGROUND_IMAGE, background_image_mode: :fill) do
background 0xff_252525
@header_container = flow(width: 1.0, height: 84, padding: 4, border_thickness_bottom: 1, border_color_bottom: W3DHub::BORDER_COLOR) do
background 0xaa_151515
flow(width: 148, height: 1.0) do
flow(fill: true)
image "#{GAME_ROOT_PATH}/media/icons/app.png", height: 84
@@ -38,36 +48,48 @@ class W3DHub
end
@navigation_container = stack(fill: true, height: 1.0) do
flow(width: 1.0, fill: true) do
@nav_padding_top_container = flow(fill: true)
flow(width: 1.0, height: 36) do
# background 0xff_666666
link I18n.t(:"interface.games").upcase, text_size: 34 do
link I18n.t(:"interface.games").upcase, text_size: 34, font: BOLD_FONT do
page(W3DHub::Pages::Games)
end
link I18n.t(:"interface.servers").upcase, text_size: 34, margin_left: 12 do
link I18n.t(:"interface.servers").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::ServerBrowser)
end
link I18n.t(:"interface.community").upcase, text_size: 34, margin_left: 12 do
link I18n.t(:"interface.community").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Community)
end
link I18n.t(:"interface.downloads").upcase, text_size: 34, margin_left: 12 do
link I18n.t(:"interface.downloads").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::DownloadManager)
end
link I18n.t(:"interface.settings").upcase, text_size: 34, margin_left: 12 do
link I18n.t(:"interface.settings").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Settings)
end
end
@nav_padding_bottom_container = flow(fill: true)
# Installer task display
flow(width: 1.0, height: 0.5) do
@application_taskbar_container = stack(width: 1.0, height: 1.0, margin_left: 16, margin_right: 16) do
@application_taskbar_container = flow(width: 1.0, height: 0.5) do
stack(width: 1.0, height: 1.0, margin_left: 16, margin_right: 16) do
flow(width: 1.0, height: 0.65) do
@application_taskbar_label = inscription "", width: 0.60, text_wrap: :none
@application_taskbar_status_label = inscription "", width: 0.40, text_align: :right, text_wrap: :none
@application_taskbar_label = para "", fill: true, text_wrap: :none
@application_taskbar_status_label = para "", width: 0.4, min_width: 256, text_align: :right, text_wrap: :none
end
@application_taskbar_progressbar = progress fraction: 0.0, height: 2, width: 1.0
@@ -89,8 +111,8 @@ class W3DHub
tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none
flow(width: 1.0) do
link(I18n.t(:"interface.log_in"), text_size: 16, width: 0.5) { page(W3DHub::Pages::Login) }
link I18n.t(:"interface.register"), text_size: 16, width: 0.49 do
link(I18n.t(:"interface.log_in"), text_size: 22, width: 0.5) { page(W3DHub::Pages::Login) }
link I18n.t(:"interface.register"), text_size: 22, width: 0.49 do
W3DHub.url("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
end
end
@@ -124,6 +146,36 @@ class W3DHub
@page&.update
update_interface_task_status(@interface_task_update_pending) if @interface_task_update_pending
if Gosu.milliseconds >= @applications_expire
@applications_expire = Gosu.milliseconds + 30_000
Api.on_thread(:_applications) do |applications|
if applications
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
Store.applications = applications
# TODO: Signal Games and ServerBrowser that applications have been updated
end
end
end
if Gosu.milliseconds >= @server_list_expire
@server_list_expire = Gosu.milliseconds + 30_000
Api.on_thread(:server_list, 2) do |result|
if result.okay?
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # five minutes
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance.refresh_server_list(result.data)
Store.main_thread_queue << -> { States::Interface.instance&.update_server_browser(nil, :refresh_all) }
end
end
end
end
def button_down(id)
@@ -159,10 +211,10 @@ class W3DHub
@page
end
def update_server_browser(server)
def update_server_browser(server, mode = :update)
return unless @page.is_a?(Pages::ServerBrowser)
@page.refresh_server_list(server)
@page.refresh_server_list(server, mode)
end
def update_server_ping(server)
@@ -172,11 +224,15 @@ class W3DHub
end
def show_application_taskbar
@nav_padding_top_container.hide
@nav_padding_bottom_container.hide
@application_taskbar_container.show
end
def hide_application_taskbar
@application_taskbar_container.hide
@nav_padding_top_container.show
@nav_padding_bottom_container.show
end
def update_interface_task_status(task)

View File

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

View File

@@ -7,12 +7,28 @@ class W3DHub
MAX_PAGE_WIDTH = 1200
TESTING_BUTTON = {
background: 0xff_ff8800,
background: 0xff_ff8800..0xff_dd6600,
border_color: Gosu::Color::NONE,
hover: {
background: 0xff_ffaa00
background: 0xff_dd6600..0xff_bb4400,
border_color: 0xff_ff8800,
},
active: {
background: 0xff_ffec00
background: 0xff_bb4400..0xff_dd6600,
border_color: 0xff_ff8800
}
}
DANGEROUS_BUTTON = {
background: 0xff_800000..0xff_600000,
border_color: Gosu::Color::NONE,
hover: {
background: 0xff_600000..0xff_400000,
border_color: 0xff_800000,
},
active: {
background: 0xff_400000..0xff_600000,
border_color: 0xff_800000
}
}
@@ -20,46 +36,93 @@ class W3DHub
THEME = {
ToolTip: {
background: 0xff_dedede,
color: 0xaa_000000,
text_size: 18,
background: 0xff_222222,
color: 0xff_f2f2f2,
text_size: 22,
text_static: true,
text_border: false,
text_shadow: false
},
TextBlock: {
font: BOLD_FONT,
font: REGULAR_FONT,
color: 0xff_f2f2f2,
text_static: true,
text_border: false,
text_shadow: true,
text_shadow_size: 1,
text_shadow_color: 0x88_000000
},
EditLine: {
border_thickness: 2,
border_color: Gosu::Color::WHITE,
hover: { color: Gosu::Color::WHITE }
Banner: { # < TextBlock
text_size: 48,
font: BOLD_FONT
},
Title: { # < TextBlock
text_size: 34,
font: BOLD_FONT
},
Subtitle: { # < TextBlock
text_size: 28,
font: BOLD_FONT
},
Tagline: { # < TextBlock
text_size: 26,
font: BOLD_FONT
},
Caption: { # < TextBlock
text_size: 24
},
Para: { # < TextBlock
text_size: 22
},
Inscription: { # < TextBlock
text_size: 18
},
Link: {
color: 0xff_cdcdcd,
hover: {
color: Gosu::Color::WHITE
color: 0xff_f2f2f2
},
active: {
color: 0xff_eeeeee
}
},
Button: {
text_size: 18,
font: BOLD_FONT,
color: 0xff_f2f2f2,
text_size: 22,
padding_top: 8,
padding_left: 16,
padding_right: 16,
padding_bottom: 8,
border_thickness: 2,
border_color: Gosu::Color::NONE,
background: 0xff_00acff,
background: 0xff_0074e0..0xff_0052c0,
hover: {
background: 0xff_bee6fd
color: 0xff_f2f2f2,
background: 0xff_0052c0..0xff_0030a0,
border_color: 0xff_0074e0
},
active: {
background: 0xff_add5ec
color: 0xff_aaaaaa,
background: 0xff_0030a0..0xff_0052c0,
border_color: 0xff_0074e0
}
},
EditLine: {
font: REGULAR_FONT,
color: 0xff_f2f2f2,
background: 0xff_383838,
border_thickness: 2,
border_color: 0xff_0074e0,
hover: {
color: 0xff_f2f2f2,
background: 0xff_323232,
border_color: 0xff_0074e0
},
active: {
color: 0xff_f2f2f2,
background: 0xff_4b4b4b,
border_color: 0xff_0074e0
}
},
ToggleButton: {
@@ -70,7 +133,8 @@ class W3DHub
checkmark_image: "#{GAME_ROOT_PATH}/media/ui_icons/checkmark.png"
},
Progress: {
fraction_background: 0xff_00acff,
background: 0xff_353535,
fraction_background: 0xff_0074e0,
border_thickness: 0
},
ListBox: {
@@ -78,21 +142,42 @@ class W3DHub
padding_right: 8
},
Slider: {
border_color: 0xff_00acff
border_color: 0xff_0074e0
},
Handle: {
text_size: 18,
text_size: 22,
padding_top: 8,
padding_left: 2,
padding_right: 2,
padding_bottom: 8,
border_color: Gosu::Color::NONE,
background: 0xff_00acff,
background: 0xff_0074e0,
hover: {
background: 0xff_bee6fd
background: 0xff_004c94
},
active: {
background: 0xff_add5ec
background: 0xff_005aad
}
},
Menu: {
width: 200,
border_color: 0xaa_efefef,
border_thickness: 1
},
MenuItem: {
width: 1.0,
text_left: :left,
margin: 0,
border_color: Gosu::Color::NONE,
background: 0xff_0074e0,
hover: {
color: 0xff_f2f2f2,
background: 0xff_0052c0,
border_color: Gosu::Color::NONE
},
active: {
background: 0xff_0030a0,
border_color: Gosu::Color::NONE
}
}
}

View File

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

75
lib/websocket_client.rb Normal file
View File

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

View File

@@ -1,19 +1,20 @@
class W3DHub
class Window < CyberarmEngine::Window
def setup
self.caption = I18n.t(:app_name)
self.show_stats_plotter = false
self.caption = "#{I18n.t(:app_name)} v#{VERSION}"
Store[:server_list] = []
Store[:settings] = Settings.new
Store[:network_manager] = NetworkManager.new
Store[:application_manager] = ApplicationManager.new
Store[:ping_manager] = PingManager.new
# FIXME
# BackgroundWorker.parallel_job(-> { Async { |task| Store.ping_manager.monitor(task) } }, nil)
Store[:main_thread_queue] = []
# Repair/Upgrade schema
Store.settings[:favorites] ||= {}
Store.settings.save_settings
begin
I18n.locale = Store.settings[:language]
rescue I18n::InvalidLocale
@@ -21,8 +22,8 @@ class W3DHub
end
# push_state(W3DHub::States::DemoInputDelay)
# push_state(W3DHub::States::Welcome)
push_state(W3DHub::States::Boot)
push_state(W3DHub::States::Welcome) unless File.exist?(SETTINGS_FILE_PATH)
# push_state(W3DHub::States::DirectConnectDialog)
# push_state(W3DHub::Asterisk::States::IRCProfileForm)
end
@@ -36,8 +37,7 @@ class W3DHub
block&.call
end
# Manually sleep main thread so that the BackgroundWorker thread can be scheduled
sleep(update_interval / 1000.0) if W3DHub::BackgroundWorker.busy? || Store.application_manager.busy?
sleep(update_interval / 1000.0) if Store.application_manager.busy? || Store.network_manager.busy?
end
def needs_redraw?

287
lib/ww_mix.rb Normal file
View File

@@ -0,0 +1,287 @@
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:, replace: false)
return false unless File.exist?(path)
return false if File.directory?(path)
entry = Entry.new(name: File.basename(path), path: path, info: EntryInfoHeader.new(0, 0, File.size(path)))
add_entry(entry: entry, replace: replace)
end
def add_blob(path:, blob:, replace: false)
info = EntryInfoHeader.new(0, 0, blob.size)
entry = Entry.new(name: File.basename(path), path: path, info: info, blob: blob)
into.crc32 = @entries.last.calculate_crc32
add_entry(entry: entry, replace: replace)
end
def add_entry(entry:, replace: false)
duplicate = @entries.find { |e| e.name.upcase == entry.name.upcase }
if duplicate
if replace
@entries.delete(duplicate)
else
return false
end
end
@entries << entry
true
end
def sort_entries
return false if @entries.any? { |e| e.info.crc32 == 0 }
@entries.sort! { |a, b| a.info.crc32 <=> b.info.crc32 }
true
end
def read_i32(f) = f.read(4).unpack1("l")
def read_u32(f) = f.read(4).unpack1("L")
def read_u8(f) = f.read(1).unpack1("c")
def read_string(f)
f.read(read_u8(f)).strip
end
def write_i32(f, value) = f.write([value].pack("l"))
def write_u32(f, value) = f.write([value].pack("L"))
def write_u8(f, value) = f.write([value].pack("c"))
def write_string(f, string)
length = string.size + 1 # include null byte
write_u8(f, length)
f.write(string)
write_u8(f, 0) # null byte
end
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -14,16 +14,28 @@ require "logger"
require "time"
require "base64"
require "zip"
require "async"
require "async/http/endpoint"
require "async/websocket/client"
require "async/http/internet/instance"
class W3DHub
W3DHUB_DEBUG = ARGV.join.include?("--debug")
W3DHUB_DEVELOPER = ARGV.join.include?("--developer")
GAME_ROOT_PATH = File.expand_path(".", __dir__)
CACHE_PATH = "#{GAME_ROOT_PATH}/data/cache"
SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.json"
# Use the real working directory as the root for runtime data/logs
GAME_ROOT_PATH = Dir.pwd
LOGGER = Logger.new("#{GAME_ROOT_PATH}/data/logs/w3d_hub_linux_launcher.log", "daily")
CACHE_PATH = "#{GAME_ROOT_PATH}/data/cache"
LOGS_PATH = "#{GAME_ROOT_PATH}/data/logs"
SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.json"
APPLICATIONS_CACHE_FILE_PATH = "#{GAME_ROOT_PATH}/data/applications_cache.json"
# Ensure data/cache and data/logs exist
FileUtils.mkdir_p(CACHE_PATH) unless Dir.exist?(CACHE_PATH)
FileUtils.mkdir_p(LOGS_PATH) unless Dir.exist?(LOGS_PATH)
LOGGER = W3DHUB_DEBUG ? Logger.new(STDOUT) : Logger.new("#{LOGS_PATH}/w3d_hub_linux_launcher.log", "daily")
LOGGER.level = Logger::Severity::DEBUG # W3DHUB_DEBUG ? Logger::Severity::DEBUG : Logger::Severity::WARN
LOG_TAG = "W3DHubLinuxLauncher"
@@ -75,17 +87,14 @@ 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
# GUI_DEBUG = true
require_relative "lib/gui_state_ext"
require_relative "lib/win32_stub" unless Gem.win_platform?
require_relative "lib/version"
@@ -95,12 +104,16 @@ 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/ping"
require_relative "lib/ping_manager"
require_relative "lib/broadcast_server"
require_relative "lib/hardware_survey"
require_relative "lib/game_settings"
require_relative "lib/background_worker"
require_relative "lib/websocket_client"
require_relative "lib/network_manager"
require_relative "lib/network_manager/http_client"
require_relative "lib/application_manager"
require_relative "lib/application_manager/manifest"
require_relative "lib/application_manager/status"
@@ -110,7 +123,6 @@ require_relative "lib/application_manager/tasks/installer"
require_relative "lib/application_manager/tasks/updater"
require_relative "lib/application_manager/tasks/uninstaller"
require_relative "lib/application_manager/tasks/repairer"
require_relative "lib/application_manager/tasks/importer"
require_relative "lib/states/demo_input_delay"
require_relative "lib/states/boot"
require_relative "lib/states/interface"
@@ -121,6 +133,8 @@ require_relative "lib/states/dialogs/prompt_dialog"
require_relative "lib/states/dialogs/confirm_dialog"
require_relative "lib/states/dialogs/direct_connect_dialog"
require_relative "lib/states/dialogs/game_settings_dialog"
require_relative "lib/states/dialogs/import_game_dialog"
require_relative "lib/states/dialogs/launcher_updater_dialog"
require_relative "lib/api"
require_relative "lib/api/service_status"
@@ -150,31 +164,33 @@ require_relative "lib/asterisk/states/game_form"
require_relative "lib/asterisk/states/irc_profile_form"
require_relative "lib/asterisk/states/server_profile_form"
require "win32/process" if W3DHub.windows?
if W3DHub.windows?
require "libui"
require "win32/process"
# Using a WHOLE ui library for: native file/folder open dialogs...
LibUI.init
LIBUI_WINDOW = LibUI.new_window("", 100, 100, 0)
at_exit do
LibUI.control_destroy(LIBUI_WINDOW)
LibUI.uninit
end
end
logger.info(W3DHub::LOG_TAG) { "W3D Hub Linux Launcher v#{W3DHub::VERSION}" }
Thread.new do
W3DHub::BackgroundWorker.create
end
until W3DHub::BackgroundWorker.alive?
sleep 0.1
end
logger.info(W3DHub::LOG_TAG) { "Launching window..." }
# W3DHub::Window.new(width: 980, height: 720, borderless: false, resizable: true).show unless defined?(Ocra)
W3DHub::Window.new(width: 1280, height: 800, borderless: false, resizable: true).show unless defined?(Ocra)
# W3DHub::Window.new(width: 1920, height: 1080, borderless: false, resizable: true).show unless defined?(Ocra)
W3DHub::BackgroundWorker.shutdown!
worker_soft_halt = Gosu.milliseconds
# worker_soft_halt = Gosu.milliseconds
# Wait for BackgroundWorker to return
while W3DHub::BackgroundWorker.alive?
W3DHub::BackgroundWorker.kill! if Gosu.milliseconds - worker_soft_halt >= 1_000
# # Wait for BackgroundWorker to return
# while W3DHub::BackgroundWorker.alive?
# W3DHub::BackgroundWorker.kill! if Gosu.milliseconds - worker_soft_halt >= 1_000
sleep 0.1
end
# sleep 0.1
# end
W3DHub::LOGGER&.close