166 Commits

Author SHA1 Message Date
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
c2528f7e12 Added background image for whole Interface, made user avatar rounded, redid news layout, misc. tweaks for better contrast with background image 2024-02-27 22:17:17 -06:00
6281dae1a5 Added logos for all current games, added UI background image, added circle mask image 2024-02-27 22:15:53 -06:00
46bef091fd Correct data and screenshots folder for Renegade 2024-02-27 11:15:06 -06:00
d30cbfefc6 Fixed not always passing '-launcher' option when launching apps 2024-02-07 10:28:49 -06:00
2fe15cb511 Fixes for Ruby 3.3.0 2023-12-29 17:58:30 -06:00
e3a33b784a Use fill: true instead of width: 1.0 for event container- supports displaying 2 to 3 events before things visually break 2023-11-28 11:57:25 -06:00
f29330cd08 Fixed display of events- events that have ended will not be displayed, and the event container now takes the full width 2023-11-28 11:54:27 -06:00
47a93bfb60 Handle inaccessible GSH differently 2023-11-25 12:05:38 -06:00
fbf5153cc8 Game settings no longer crashes on linux (still need to sort out how to load/save wine registry from outside of wine enviroment), updated gems. 2023-11-21 09:19:36 -06:00
6988702db2 Moved dialogs into subfolder, refactored dialogs to use new Dialog class, removed pre-redesign Interface and Game states/pages 2023-11-19 13:03:05 -06:00
d83a439ad1 Updated gems, implemented game settings (works best with scripts 5.1 games) 2023-11-19 12:42:54 -06:00
8972561f5f Improvements to server list updater, iirc. 2023-05-27 09:35:40 -05:00
51aaf12971 Update gems 2023-05-27 09:35:08 -05:00
d07395c7f0 Style fix 2023-05-27 09:34:54 -05:00
9b8d13929d Fixed always repainting due to Boot state not popping 2023-05-27 09:34:25 -05:00
19abe06f89 If server list fails put in offline mode 2023-03-21 16:49:25 -05:00
b6d5f4135a Pruned gem usage, replaced Launchy since it will cause a command prompt to momentarily appear. 2023-02-05 17:59:37 -06:00
ff8387be6d Update to use needs_repaint? to reduce cpu/gpu load of launcher 2023-02-05 16:43:24 -06:00
c73bd2d88b Updated gems, fixed error on Ruby 3.2 (Thread block no longer passes self as argument), fixed API calls not timing out properly, fixed BackgroundWorker not halting properly. 2023-02-03 12:19:18 -06:00
db12e56623 Fixed 'Play Now' button doesn't work if nickname is set 2022-12-04 12:33:31 -06:00
55c0f363e0 Removed dependence on the bsdtar command, fixed Play Now button not doing anything if a server nickname wasn't set, refactor a bit so that the Server List's 'Join Server' button functionality can be reused for the Play Now button, installer now always unpacks into 'data/*' instead of 'Data/*' 2022-12-02 14:12:45 -06:00
19a15e937e Fixed DummyResponse missing the status method 2022-11-19 17:03:14 -06:00
185dfb50eb Fix BackgroundWorker not using a foreground job for server list updater 2022-11-07 17:14:21 -06:00
04d40fe8fc Don't use 'IO.popen' when no block is given, use 'system' instead. 2022-11-05 22:36:21 -05:00
aa65433b14 Enabled Direct Connect button, bump version. 2022-10-31 19:46:38 -05:00
a6beae0899 Asterisk direct connect seems functional 😂 2022-10-31 10:10:53 -05:00
4b230eb535 Hacky fix to reliably get exit status from W3DHub.command on windows 2022-10-30 22:29:03 -05:00
ab73b62c4b Renamed W3DHub.captured_command to simply W3DHub.command, replace usages of system with W3DHub.command, resolves command prompt's popping up on Windows under rubyw. 2022-10-30 22:02:20 -05:00
388c3a2606 Fixed win32 version of captured_command erroring on exit status, fixed auto import overwriting application details when the stored version is newer then the registry, added unused registry writer for updating application version- requires UAC, fixed ServerBrowser#find_element_by_tag erroring sometimes, added application manager to sleeper party. 2022-10-30 21:47:06 -05:00
deaa6ee9d9 shelling out to 'ping' no longer spawns command prompts on windows 2022-10-30 20:58:51 -05:00
340c083a43 Removed Async[websocket/http] due to excessive require times and reliablity issues on Windows 2022-10-30 18:10:47 -05:00
7359d73027 added win32-security to allow net-ping to work on windows 2022-09-11 18:45:49 -05:00
ae3720d119 'finished' implementing ping support for server browser, not tested on windows 2022-09-11 15:55:57 -05:00
50ec9fc1da Added support for pinging servers, server list now reorders containers instead of recreating all of them for every refresh, server list updater should restart on crash 2022-09-10 08:29:55 -05:00
af28013a7f Fix TSR not patching properly 2022-08-24 12:37:44 -05:00
6055e8f65c Added support for favoriting games like Battle.net 2022-07-31 10:31:17 -05:00
af82432348 Prototyped Game Settings dialog and an All Games view, both disabled atm. 2022-07-26 12:19:17 -05:00
5fc42a3ce9 Fixed win32 auto importer game name FIXME, fix games install_path/exe_path stored with backwards slashes- \\ instead of / 2022-06-19 21:54:59 -05:00
8d0c27d6fc Initial support for offline mode 2022-06-19 18:23:45 -05:00
7e305cdec1 Fix typo 2022-06-17 15:17:21 -05:00
d7533a18a9 Actually add IRC Client 2022-06-14 12:21:25 -05:00
014de7c6aa Tweaks for Asterisk dialogs, IRC profile editor dialog functional. 2022-06-14 09:07:40 -05:00
3ca8ab656f WIP: Adding IRC support to Direct Connect system, using new v/h_align option for centering dialogs 2022-06-13 21:43:13 -05:00
7e59c984ff Fixed #7 repairer task no longer forces an update when repairing 2022-06-11 18:44:27 -05:00
9f4ca51af8 Updated gems, added server events to games page 2022-06-11 18:18:04 -05:00
33d53cb57b Translation changes 2022-06-11 12:40:19 -05:00
2531a20bab Show server name with player count to be joined in tooltip for 'Play Now' button 2022-06-04 21:26:24 -05:00
c0eac0104b Inital Import of asterisk_gosu into launcher, non-functional. 2022-06-03 22:58:26 -05:00
fc968ffe32 Removed usage of File.dirname in win32 auto importer since switching the InstallDir reg entry is already a directory and File.dirname chops off the everything after the last slash 2022-06-02 08:47:08 -05:00
e0acdc90a7 Improved win32 auto importer by switching to using InstallDir key instead of InstallPath since InstallPath isn't updated when importing moved games 2022-05-24 09:34:07 -05:00
e233fe448b Fixed saving package cache dir setting 2022-05-24 09:11:00 -05:00
0a800d1a31 Tweaks 2022-05-03 20:00:32 -05:00
a90bb30fc0 Unify border color 2022-05-03 19:06:13 -05:00
c20f34de41 Made game options container have a darkened color and edge border to stand out more 2022-05-03 11:40:47 -05:00
c735ffc5f4 Updated Community news to use new format, updated Game page redesign to use 300x300 instead of 346x346 so that the image is integer scaled by 2x 2022-05-03 11:34:34 -05:00
ef477cfdd5 Improvements 2022-05-02 19:09:53 -05:00
ed2c1929e7 Reenabled update and download icons for games list, fixed game banner clipped on right side, channel selector is now hidden if there is less than 2 available 2022-04-26 12:48:07 -05:00
3271f20b97 Improve appearance of Install Update, Install, and Import buttons 2022-04-26 09:47:33 -05:00
50fc8ab6ff More Game page UI reworking 2022-04-25 20:10:35 -05:00
26a3d98d67 Tweaked layout for Interface and Games page, initial work on offline mode (will crash atm if offline mode is triggered) 2022-04-25 16:24:22 -05:00
4216a2d580 Testing OWA's design out, stubbed VK_HUD and MANGO_HUD methods in ApplicationManager which need to be connected to a setting 2022-04-23 13:48:41 -05:00
b230ba88c8 Updated dialogs and welcome screen 2022-04-04 10:55:10 -05:00
095edbbe36 Numerous tweaks to layouts to make them format nicer for a resizable window 2022-04-04 09:55:17 -05:00
d057dc96ec Fixed crash when fetching user avatar on login 2022-04-04 08:02:56 -05:00
b73826ed1f Progress towards a resizable launcher window 2022-04-03 13:08:28 -05:00
73 changed files with 5678 additions and 1371 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

33
Gemfile
View File

@@ -1,17 +1,26 @@
source "https://rubygems.org"
gem "base64"
gem "excon"
gem "cyberarm_engine"
gem "launchy"
gem "i18n"
gem "rexml"
gem "sdl2-bindings"
gem "libui", platforms: [:windows]
gem "digest-crc"
gem "ffi"
gem "async", "~>1.30.1"
gem "async-http"
gem "async-websocket"
gem "thread-local"
gem "ircparser"
gem "rexml"
gem "rubyzip"
gem "websocket-client-simple"
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,82 +1,73 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
async (1.30.1)
console (~> 1.10)
nio4r (~> 2.3)
timers (~> 4.1)
async-http (0.56.5)
async (>= 1.25)
async-io (>= 1.28)
async-pool (>= 0.2)
protocol-http (~> 0.22.0)
protocol-http1 (~> 0.14.0)
protocol-http2 (~> 0.14.0)
async-io (1.33.0)
async
async-pool (0.3.9)
async (>= 1.25)
async-websocket (0.19.0)
async-http (~> 0.54)
async-io (~> 1.23)
protocol-websocket (~> 0.7.0)
clipboard (1.3.6)
concurrent-ruby (1.1.9)
console (1.14.0)
fiber-local
cyberarm_engine (0.20.0)
clipboard (~> 1.3)
excon (~> 0.88)
base64 (0.3.0)
concurrent-ruby (1.3.5)
cri (2.15.12)
cyberarm_engine (0.24.5)
gosu (~> 1.1)
gosu_more_drawables (~> 0.3)
digest-crc (0.6.4)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
excon (0.92.0)
ffi (1.15.5)
ffi (1.15.5-x64-mingw-ucrt)
ffi (1.15.5-x64-mingw32)
fiber-local (1.0.0)
gosu (1.4.1)
gosu_more_drawables (0.3.1)
i18n (1.10.0)
event_emitter (0.2.6)
excon (1.3.2)
logger
ffi (1.17.0)
ffi-win32-extensions (1.1.0)
ffi (>= 1.15.5, <= 1.17.0)
fiddle (1.1.8)
gosu (1.4.6)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
launchy (2.5.0)
addressable (~> 2.7)
nio4r (2.5.8)
protocol-hpack (1.4.2)
protocol-http (0.22.5)
protocol-http1 (0.14.2)
protocol-http (~> 0.22)
protocol-http2 (0.14.2)
protocol-hpack (~> 1.4)
protocol-http (~> 0.18)
protocol-websocket (0.7.5)
protocol-http (~> 0.2)
protocol-http1 (~> 0.2)
public_suffix (4.0.6)
rake (13.0.6)
rexml (3.2.5)
thread-local (1.1.0)
timers (4.3.3)
ircparser (1.0.0)
libui (0.2.0-x64-mingw-ucrt)
fiddle
logger (1.7.0)
mutex_m (0.3.0)
ocran (1.3.17)
fiddle (~> 1.0)
rake (13.3.1)
releasy (0.2.4)
bundler (>= 1.2.1)
cri (~> 2.15.0)
ocran (~> 1.3.0)
rake (>= 0.9.2.2)
rexml (3.4.4)
rubyzip (3.2.2)
sdl2-bindings (0.2.3)
ffi (~> 1.15)
websocket (1.2.11)
websocket-client-simple (0.9.0)
base64
event_emitter
mutex_m
websocket
win32-process (0.10.0)
ffi (>= 1.0.0)
win32-security (0.5.0)
ffi
ffi-win32-extensions
PLATFORMS
x64-mingw-ucrt
x64-mingw32
x86_64-linux
DEPENDENCIES
async (~> 1.30.1)
async-http
async-websocket
base64
bundler (~> 2.4.3)
cyberarm_engine
digest-crc
ffi
excon
i18n
launchy
ircparser
libui
ocran
rake
releasy
rexml
thread-local
rubyzip
sdl2-bindings
websocket-client-simple
win32-process
win32-security
BUNDLED WITH
2.3.3
2.4.22

View File

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

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

View File

@@ -1,56 +1,149 @@
class W3DHub
class Api
# Set Excon default CA file if found
if (ca_file = W3DHub.ca_bundle_path)
Excon.defaults[:ssl_ca_file] = ca_file
end
LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 10 # seconds
API_TIMEOUT = 30 # seconds
USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze
DEFAULT_HEADERS = [
["User-Agent", USER_AGENT],
["Accept", "application/json"]
].freeze
FORM_ENCODED_HEADERS = (
DEFAULT_HEADERS + [["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_fiber(method, *args, &callback)
BackgroundWorker.job(-> { Api.send(method, *args) }, callback)
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
end
#! === W3D Hub API === !#
W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
W3DHUB_API_CONNECTION = Excon.new(W3DHUB_API_ENDPOINT, persistent: true)
ENDPOINT = "https://secure.w3dhub.com".freeze
ALT_W3DHUB_API_ENDPOINT = "https://w3dhub-api.w3d.cyberarm.dev".freeze # "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
ALT_W3DHUB_API_API_CONNECTION = Excon.new(ALT_W3DHUB_API_ENDPOINT, persistent: true)
def self.post(url, headers = DEFAULT_HEADERS, body = nil)
@client ||= Async::HTTP::Client.new(Async::HTTP::Endpoint.parse(ENDPOINT, protocol: Async::HTTP::Protocol::HTTP10))
def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
case backend
when :w3dhub
connection = W3DHUB_API_CONNECTION
endpoint = W3DHUB_API_ENDPOINT
when :alt_w3dhub
connection = ALT_W3DHUB_API_API_CONNECTION
endpoint = ALT_W3DHUB_API_ENDPOINT
when :gsh
connection = GSH_CONNECTION
endpoint = SERVER_LIST_ENDPOINT
end
# TODO: Check if session has expired and attempt to refresh session before submitting request
logger.debug(LOG_TAG) { "Fetching POST \"#{url}\"..." }
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{endpoint}#{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
Async::Task.current.with_timeout(API_TIMEOUT) do
@client.post(url, headers, body)
end
rescue Async::TimeoutError
connection.send(
method,
path: url.sub(endpoint, ""),
headers: headers,
body: body,
nonblock: true,
tcp_nodelay: true,
write_timeout: API_TIMEOUT,
read_timeout: API_TIMEOUT,
connect_timeout: API_TIMEOUT,
idempotent: true,
retry_limit: 3,
retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
)
rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new
rescue EOFError
DummyResponse.new(e)
rescue Excon::Error => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
DummyResponse.new
DummyResponse.new(e)
end
end
def self.post(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
excon(:post, url, headers, body, backend)
end
def self.get(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
excon(:get, url, headers, body, backend)
end
# Api.get but handles any URL instead of known hosts
def self.fetch(url, headers = DEFAULT_HEADERS, body = nil, backend = nil)
uri = URI(url)
# Use Api.get for `W3DHUB_API_ENDPOINT` URL's to exploit keep alive and connection reuse (faster responses)
return excon(:get, url, headers, body, backend) if "#{uri.scheme}://#{uri.host}" == W3DHUB_API_ENDPOINT
logger.debug(LOG_TAG) { "Fetching GET \"#{url}\"..." }
begin
Excon.get(
url,
headers: headers,
body: body,
nonblock: true,
tcp_nodelay: true,
write_timeout: API_TIMEOUT,
read_timeout: API_TIMEOUT,
connect_timeout: API_TIMEOUT,
idempotent: true,
retry_limit: 3,
retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
)
rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e)
rescue Excon::Error => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
DummyResponse.new(e)
end
end
@@ -69,24 +162,16 @@ class W3DHub
#
# On a failed login the service responds with:
# {"error":"login-failed"}
def self.refresh_user_login(refresh_token)
def self.refresh_user_login(refresh_token, backend = :w3dhub)
body = "data=#{JSON.dump({refreshToken: refresh_token})}"
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.success?
user_data = JSON.parse(response.read, symbolize_names: true)
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error]
body = "data=#{JSON.dump({ id: user_data[:userid] })}"
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
if user_details.success?
user_details_data = JSON.parse(user_details.read, symbolize_names: true)
else
logger.error(LOG_TAG) { "Failed to fetch refresh user details:" }
logger.error(LOG_TAG) { user_details }
end
user_details_data = user_details(user_data[:userid]) || {}
Account.new(user_data, user_details_data)
else
@@ -97,24 +182,16 @@ class W3DHub
end
# See #user_refresh_token
def self.user_login(username, password)
def self.user_login(username, password, backend = :w3dhub)
body = "data=#{JSON.dump({username: username, password: password})}"
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.success?
user_data = JSON.parse(response.read, symbolize_names: true)
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error]
body = "data=#{JSON.dump({ id: user_data[:userid] })}"
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
if user_details.success?
user_details_data = JSON.parse(user_details.read, symbolize_names: true)
else
logger.error(LOG_TAG) { "Failed to fetch user details:" }
logger.error(LOG_TAG) { user_details }
end
user_details_data = user_details(user_data[:userid]) || {}
Account.new(user_data, user_details_data)
else
@@ -124,21 +201,30 @@ class W3DHub
end
end
# /apis/launcher/1/user-login
# Client sends an Authorization header bearer token which is received from logging in (Required?)
# /apis/w3dhub/1/get-user-details
#
# Response: avatar-uri (Image download uri), id, username
def self.user_details(id)
def self.user_details(id, backend = :w3dhub)
body = "data=#{JSON.dump({ id: id })}"
user_details = post("/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body, backend)
if user_details.status == 200
JSON.parse(user_details.body, symbolize_names: true)
else
logger.error(LOG_TAG) { "Failed to fetch user details:" }
logger.error(LOG_TAG) { user_details }
false
end
end
# /apis/w3dhub/1/get-service-status
# Service response:
# {"services":{"authentication":true,"packageDownload":true}}
def self.service_status
response = post("#{ENDPOINT}/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS)
def self.service_status(backend = :w3dhub)
response = post("/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS, nil, backend)
if response.success?
ServiceStatus.new(response.read)
if response.status == 200
ServiceStatus.new(response.body)
else
logger.error(LOG_TAG) { "Failed to fetch service status:" }
logger.error(LOG_TAG) { response }
@@ -150,11 +236,11 @@ class W3DHub
# Client sends an Authorization header bearer token which is received from logging in (Optional)
# Launcher sends an empty data request: data={}
# Response is a list of applications/games
def self.applications
response = post("#{ENDPOINT}/apis/launcher/1/get-applications")
def self.applications(backend = :w3dhub)
response = post("/apis/launcher/1/get-applications", DEFAULT_HEADERS, nil, backend)
if response.success?
Applications.new(response.read)
if response.status == 200
Applications.new(response.body, backend)
else
logger.error(LOG_TAG) { "Failed to fetch applications list:" }
logger.error(LOG_TAG) { response }
@@ -162,16 +248,85 @@ class W3DHub
end
end
# Populate applications list from primary and alternate backends
# (alternate only has latest public builds of _most_ games)
def self._applications
applications_primary = Store.account ? Api.applications(:w3dhub) : false
applications_alternate = Api.applications(:alt_w3dhub)
# Fail if we fail to fetch applications list from either backend
return false unless applications_primary || applications_alternate
return applications_alternate unless applications_primary
# Merge the two app lists together
apps = applications_alternate
if applications_primary
applications_primary.games.each do |game|
# Check if game exists in alternate list
_game = apps.games.find { |g| g.id == game.id }
unless _game
apps.games << game
# App didn't exist in alternates list
# comparing channels isn't useful
next
end
# If it does, check that all of its channels also exist in alternate list
# and that the primary versions are the same as the alternates list
game.channels.each do |channel|
_channel = _game.channels.find { |c| c.id == channel.id }
unless _channel
_game.channels << channel
# App didn't have channel in alternates list
# comparing channel isn't useful
next
end
# If channel versions and access levels match then all's well
if channel.current_version == _channel.current_version &&
channel.user_level == _channel.user_level
# All's Well!
next
end
# If the access levels don't match then overwrite alternate's channel with primary's channel
if channel.user_level != _channel.user_level
# Replace alternate's channel with primary's channel
_game.channels[_game.channels.index(_channel)] = channel
# Replaced, continue.
next
end
# If versions don't match then pick whichever one is higher
if Gem::Version.new(channel.current_version) > Gem::Version.new(_channel.current_version)
# Replace alternate's channel with primary's channel
_game.channels[_game.channels.index(_channel)] = channel
else
# Do nothing, alternate backend version is greater.
end
end
end
end
apps
end
# /apis/w3dhub/1/get-news
# Client sends an Authorization header bearer token which is received from logging in (Optional)
# Client requests news for a specific application/game e.g.: data={"category":"ia"} ("launcher-home" retrieves the weekly hub updates)
# Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one
def self.news(category)
def self.news(category, backend = :w3dhub)
body = "data=#{JSON.dump({category: category})}"
response = post("#{ENDPOINT}/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body)
response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend)
if response.success?
News.new(response.read)
if response.status == 200
News.new(response.body)
else
logger.error(LOG_TAG) { "Failed to fetch news for:" }
logger.error(LOG_TAG) { category }
@@ -184,12 +339,13 @@ class W3DHub
# /apis/launcher/1/get-package-details
# client requests package details: data={"packages":[{"category":"games","name":"apb.ico","subcategory":"apb","version":""}]}
def self.package_details(packages)
def self.package_details(packages, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
response = post("#{ENDPOINT}/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body)
response = post("/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
if response.success?
hash = JSON.parse(response.read, symbolize_names: true)
hash[:packages].map { |pkg| Package.new(pkg) }
else
logger.error(LOG_TAG) { "Failed to fetch package details for:" }
@@ -207,17 +363,27 @@ class W3DHub
Cache.fetch_package(package, block)
end
# /apis/w3dhub/1/get-events
#
# clients requests events: data={"serverPath":"apb"}
def self.events(app_id, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
response = post("/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200
array = JSON.parse(response.body, symbolize_names: true)
array.map { |e| Event.new(e) }
else
false
end
end
#! === Server List API === !#
SERVER_LIST_ENDPOINT = "https://gsh.w3dhub.com".freeze
def self.get(url, headers = DEFAULT_HEADERS, body = nil)
@client ||= Async::HTTP::Client.new(Async::HTTP::Endpoint.parse(SERVER_LIST_ENDPOINT, protocol: Async::HTTP::Protocol::HTTP10))
logger.debug(LOG_TAG) { "Fetching GET \"#{url}\"..." }
@client.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
GSH_CONNECTION = Excon.new(SERVER_LIST_ENDPOINT, persistent: true)
# Method: GET
# FORMAT: JSON
@@ -236,11 +402,11 @@ 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}")
def self.server_list(level = 1, backend = :gsh)
response = get("/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
if response.success?
data = JSON.parse(response.read, symbolize_names: true)
if response.status == 200
data = JSON.parse(response.body, symbolize_names: true)
return data.map { |hash| ServerListServer.new(hash) }
end
@@ -258,11 +424,13 @@ 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)
return false unless id && level
if response.success?
hash = JSON.parse(response.read, symbolize_names: true)
response = get("/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
return hash
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,15 +1,17 @@
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.sort_by! { |a| a.name }.reverse
games.each { |hash| @games << Game.new(hash, source) }
@games.sort_by!(&:name).reverse
end
def games
@@ -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

33
lib/api/event.rb Normal file
View File

@@ -0,0 +1,33 @@
class W3DHub
class Api
class Event
def initialize(data)
@data = data
end
def server
@data[:server]
end
def title
@data[:title]
end
def start_time
@start_time ||= Time.parse(@data[:starttime]).localtime
end
def end_time
@end_time ||= Time.parse(@data[:endtime]).localtime
end
def date_time
@data[:dateTime]
end
def image
@data[:image]
end
end
end
end

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,9 @@
class W3DHub
class Api
class ServerListServer
attr_reader :id, :game, :address, :port, :region, :channel, :status
NO_OR_BAD_PING = 1_000_000
attr_reader :id, :game, :address, :port, :region, :channel, :ping, :status
def initialize(hash)
@data = hash
@@ -12,50 +14,81 @@ class W3DHub
@port = @data[:port]
@region = @data[:region]
@channel = @data[:channel] || "release"
@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_000
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]
return true
send_ping
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
W3DHub::BackgroundWorker.foreground_parallel_job(
lambda do
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
elsif W3DHub.unix? && line.start_with?("rtt min/avg/max/mdev")
@ping = line.strip.split("=").last.split("/")[1].to_i
end
end
@ping = NO_OR_BAD_PING if @ping.zero?
@ping
end,
lambda do |_|
States::Interface.instance&.update_server_ping(self)
end
)
end
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
@@ -69,7 +102,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

@@ -3,65 +3,6 @@ class W3DHub
class ServerListUpdater
LOG_TAG = "W3DHub::Api::ServerListUpdater".freeze
include CyberarmEngine::Common
##!!! When this breaks update from: https://github.com/socketry/async-websocket/blob/master/lib/async/websocket/connection.rb
# refinements preserves super... 😢
class PatchedConnection < ::Protocol::WebSocket::Connection
include ::Protocol::WebSocket::Headers
def self.call(framer, protocol = [], **options)
instance = self.new(framer, Array(protocol).first, **options)
return instance unless block_given?
begin
yield instance
ensure
instance.close
end
end
def initialize(framer, protocol = nil, response: nil, **options)
super(framer, **options)
@protocol = protocol
@response = response
end
def close
super
if @response
@response.finish
@response = nil
end
end
attr :protocol
def read
if (buffer = super)
buffer.split("\x1e").map { |json| parse(json) }
end
end
def write(object)
super("#{dump(object)}\x1e")
end
def parse(buffer)
JSON.parse(buffer, symbolize_names: true)
end
def dump(object)
JSON.dump(object)
end
def call
self.close
end
end
@@instance = nil
def self.instance
@@ -70,61 +11,190 @@ class W3DHub
@@instance = ServerListUpdater.new
end
attr_accessor :auto_reconnect, :invocation_id
def initialize
@auto_reconnect = false
@invocation_id = 0
logger.info(LOG_TAG) { "Starting emulated SignalR Server List Updater..." }
run
end
def run
Thread.new do
Async do |task|
internet = Async::HTTP::Internet.instance
begin
connect
logger.debug(LOG_TAG) { "Requesting connection token..." }
response = internet.post("https://gsh.w3dhub.com/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, [""])
data = JSON.parse(response.read, symbolize_names: true)
while W3DHub::BackgroundWorker.alive?
connect if @auto_reconnect
sleep 1
end
rescue => e
puts e
puts e.backtrace
id = data[:connectionToken]
endpoint = Async::HTTP::Endpoint.parse("https://gsh.w3dhub.com/listings/push/v2?id=#{id}", alpn_protocols: Async::HTTP::Protocol::HTTP11.names)
sleep 30
retry
end
end
logger.debug(LOG_TAG) { "Connecting to websocket..." }
Async::WebSocket::Client.connect(endpoint, headers: Api::DEFAULT_HEADERS, handler: PatchedConnection) do |connection|
logger.debug(LOG_TAG) { "Requesting json protocol, v1..." }
connection.write({ protocol: "json", version: 1 })
connection.flush
logger.debug(LOG_TAG) { "Received: #{connection.read}" }
logger.debug(LOG_TAG) { "Sending \"PING\"(?)" }
connection.write({ "type": 6 })
logger.debug(LOG_TAG) { "Cleaning up..." }
@@instance = nil
end
logger.debug(LOG_TAG) { "Subscribing to server changes..." }
Store.server_list.each_with_index do |server, i|
i += 1
mode = 1 # 2 full details, 1 basic details
out = { "type": 1, "invocationId": "#{i}", "target": "SubscribeToServerStatusUpdates", "arguments": [server.id, mode] }
connection.write(out)
end
def connect
@auto_reconnect = false
logger.debug(LOG_TAG) { "Waiting for data..." }
while (message = connection.read)
connection.write({ type: 6 }) if message.first[:type] == 6
logger.debug(LOG_TAG) { "Requesting connection token..." }
response = Api.post("/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh)
if message&.first&.fetch(:type) == 1
message.each do |rpc|
next unless rpc[:target] == "ServerStatusChanged"
if response.status != 200
@auto_reconnect = true
return
end
id, data = rpc[:arguments]
server = Store.server_list.find { |s| s.id == id }
server_updated = server&.update(data)
States::Interface.instance&.update_server_browser(server) if server_updated
data = JSON.parse(response.body, symbolize_names: true)
@invocation_id = 0 if @invocation_id > 9095
id = data[:connectionToken]
endpoint = "#{Api::SERVER_LIST_ENDPOINT}/listings/push/v2?id=#{id}"
logger.debug(LOG_TAG) { "Connecting to websocket..." }
this = self
@ws = WebSocket::Client::Simple.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 do |server|
this.invocation_id += 1
mode = 1 # 2 full details, 1 basic details
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
hash = JSON.parse(msg, symbolize_names: true)
# pp hash if hash[:target] != "ServerStatusChanged" && hash[:type] != 6 && hash[:type] != 3
# Send PING(?)
if hash.empty? || hash[:type] == 6
ws.send({ type: 6 }.to_json + "\x1e")
else
case hash[:type]
when 1
case hash[:target]
when "ServerRegistered"
data = hash[:arguments].first
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
ensure
logger.debug(LOG_TAG) { "Cleaning up..." }
@@instance = nil
end
ws.on(:close) do |e|
logger.error(LOG_TAG) { e }
this.auto_reconnect = true
ws.close
end
ws.on(:error) do |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

@@ -44,12 +44,23 @@ 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)
logger.info(LOG_TAG) { "Settings Request: #{app_id}-#{channel}" }
if (app_data = installed?(app_id, channel))
_application = Store.applications.games.find { |g| g.id == app_id }
_channel = _application.channels.find { |c| c.id == channel }
push_state(W3DHub::States::GameSettingsDialog, title: "#{_application.name} (#{_channel.name}) Settings", app_id: app_id, channel: channel)
end
end
def wwconfig(app_id, channel)
logger.info(LOG_TAG) { "WWConfig Request: #{app_id}-#{channel}" }
# open wwconfig.exe or config.exe for ecw
if (app_data = installed?(app_id, channel))
@@ -133,13 +144,19 @@ class W3DHub
"open"
end
# TODO: Change if this correct on Linux
user_data_path = "#{Dir.home}/Documents/W3D Hub/games/#{app_id}-#{channel}"
user_data_path = "#{Dir.home}/Documents/Renegade" if app_id == "ren"
path = case type
when :installation
app_data[:install_directory]
when :user_data
app_data[:install_directory]
user_data_path
when :screenshots
app_data[:install_directory]
screenshots_path = "#{user_data_path}/Screenshots"
screenshots_path = "#{user_data_path}/Client/Screenshots" if app_id == "ren"
Dir.exist?(screenshots_path) ? screenshots_path : user_data_path
else
raise "Unknown folder type: #{type.inspect}"
end
@@ -159,32 +176,139 @@ class W3DHub
end
end
def run(app_id, channel, *args)
if (app_data = installed?(app_id, channel))
pid = Process.spawn("#{wine_command(app_id, channel)}\"#{app_data[:install_path]}\" #{args.join(' ')}")
Process.detach(pid)
def mangohud_command(app_id, channel)
return "" if W3DHub.windows?
# TODO: Add game specific options
# OPENGL?
if false && system("which mangohud")
"MANGOHUD=1 MANGOHUD_DLSYM=1 DXVK_HUD=1 mangohud "
else
""
end
end
def join_server(app_id, channel, server, password = nil)
if installed?(app_id, channel) && Store.settings[:server_list_username].to_s.length.positive?
def dxvk_command(app_id, channel)
return "" if W3DHub.windows?
# Vulkan
# SETTING && WINE WILL USE DXVK?
if false && true#system()
_setting = "full"
"DXVK_HUD=#{_setting} "
else
""
end
end
def start_command(path, exe)
if W3DHub.windows?
"start /D \"#{path}\" /B #{exe}"
else
"#{path}/#{exe}"
end
end
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" : "#{install_directory}/game.exe"
exe_path.gsub!("/", "\\") if W3DHub.windows?
exe_path.gsub!("\\", "/") if W3DHub.unix?
exe = File.basename(exe_path)
path = File.dirname(exe_path)
attempted = false
begin
pid = Process.spawn("#{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)
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 join_server(app_id, channel, server, username = Store.settings[:server_list_username], password = nil, multi = false)
if installed?(app_id, channel) && username.to_s.length.positive?
run(
app_id, channel,
"-launcher +connect #{server.address}:#{server.port} +netplayername #{Store.settings[:server_list_username]}#{password ? " +password \"#{password}\"" : ""}"
"+connect #{server.address}:#{server.port} +netplayername #{username}#{password ? " +password \"#{password}\"" : ""}#{multi ? " +multi" : ""}"
)
end
end
def play_now(app_id, channel)
def play_now_server(app_id, channel)
app_data = installed?(app_id, channel)
return false unless app_data
return nil unless app_data
server = Store.server_list.select { |server| server.game == app_id && !server.status.password }&.first
found_server = Store.server_list.select do |server|
server.game == app_id && server.channel == channel && !server.status.password && server.status.player_count < server.status.max_players
end&.first
found_server ? found_server : nil
end
def play_now(app_id, channel)
server = play_now_server(app_id, channel)
return false unless server
join_server(app_id, channel, server)
if Store.settings[:server_list_username].to_s.length.zero?
W3DHub.prompt_for_nickname(
accept_callback: proc do |entry|
Store.settings[:server_list_username] = entry
Store.settings.save_settings
if server.status.password
W3DHub.prompt_for_password(
accept_callback: proc do |password|
join_server(app_id, channel, server)
end
)
else
join_server(app_id, channel, server)
end
end
)
else
join_server(app_id, channel, server)
end
end
def favorive(app_id, bool)
Store.settings[:favorites] ||= {}
if bool
Store.settings[:favorites][app_id.to_sym] = true
else
Store.settings[:favorites].delete(app_id.to_sym)
end
end
def favorite?(app_id)
Store.settings[:favorites] ||= {}
Store.settings[:favorites][app_id.to_sym]
end
def app_order(app_id, int)
Store.settings[:app_order] ||= {}
Store.settings[:app_order][app_id.to_sym] = int
end
def app_order_index(app_id)
Store.settings[:app_order] ||= {}
Store.settings[:app_order][app_id.to_sym]
end
def auto_import
@@ -193,17 +317,19 @@ class W3DHub
Store.applications.games.each do |game|
game.channels.each do |channel|
if game.id == "ren" && channel.id == "release"
auto_import_win32_registry(game.id, channel.id, 'SOFTWARE\Westwood\Renegade')
auto_import_win32_registry(game, channel.id, 'SOFTWARE\Westwood\Renegade')
else
auto_import_win32_registry(game.id, channel.id)
auto_import_win32_registry(game, channel.id)
end
end
end
end
def auto_import_win32_registry(app_id, channel_id, registry_path = nil)
def auto_import_win32_registry(game, channel_id, registry_path = nil)
return unless W3DHub.windows?
app_id = game.id
logger.info(LOG_TAG) { "Importing: #{app_id}-#{channel_id}" }
require "win32/registry"
@@ -216,14 +342,26 @@ class W3DHub
begin
reg_constant.open(registry_path, reg_type) do |reg|
if (install_path = reg["InstallPath"])
if File.exist?(install_path) || (app_id == "ecw" && File.exist?("#{File.dirname(install_path)}/game750.exe"))
install_path.gsub!("\\", "/")
installed_version = reg["InstalledVersion"] unless app_id == "ren"
install_path = File.dirname(install_path)
install_path.gsub!("\\", "/")
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"]
if (installed_app = installed?(app_id, channel_id))
current_version = Gem::Version.new(installed_app[:installed_version])
listed_version = installed_version
next if current_version >= listed_version
end
application_data = {
install_directory: File.dirname(install_path),
installed_version: app_id == "ren" ? "1.0.0.0" : installed_version,
install_path: app_id == "ecw" ? "#{File.dirname(install_path)}/game750.exe" : install_path,
name: game.name,
install_directory: install_path,
installed_version: installed_version,
install_path: exe_path,
wine_prefix: nil
}
@@ -246,16 +384,46 @@ class W3DHub
end
end
def imported!(task, exe_path)
def write_application_version_to_win32_registry(app_id, channel_id, version)
# TODO: Figure out how to trigger UAC, but only for this so games DO NOT spawn with admin privileges.
return
return unless W3DHub.windows?
return if app_id == "ren"
require "win32/registry"
registry_path ||= "SOFTWARE\\W3D Hub\\games\\#{app_id}-#{channel_id}"
reg_type = Win32::Registry::KEY_ALL_ACCESS
Win32::Registry::HKEY_LOCAL_MACHINE.open(registry_path, reg_type) do |reg|
reg.write_s("InstalledVersion", version)
end
rescue => e
puts e.class, e.message, e.backtrace
if Win32::Registry::Error
logger.warn(LOG_TAG) { " Failed to update #{app_id}-#{channel_id} version in the registry" }
else
logger.warn(LOG_TAG) { " An error occurred while tying to update #{app_id}-#{channel_id} version in the registry" }
logger.warn(LOG_TAG) { e }
end
false
end
def imported!(application, channel, exe_path)
exe_path.gsub!("\\", "/")
application_data = {
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
@@ -266,9 +434,12 @@ 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,
installed_version: task.channel.current_version,
installed_version: task.target_version,
install_path: "#{install_directory}/game.exe",
wine_prefix: task.wine_prefix
}
@@ -276,6 +447,8 @@ class W3DHub
Store.settings[:games] ||= {}
Store.settings[:games][:"#{task.app_id}_#{task.release_channel}"] = application_data
Store.settings.save_settings
write_application_version_to_win32_registry(task.app_id, task.release_channel, task.target_version)
end
def installed?(app_id, channel)
@@ -310,6 +483,32 @@ class W3DHub
Store.settings.save_settings
end
def color(app_id)
Store.applications.games.detect { |g| g.id == app_id }&.color
end
def name(app_id)
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
# No application tasks are being done
def idle?
!busy?

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

@@ -8,7 +8,7 @@ class W3DHub
include CyberarmEngine::Common
attr_reader :app_id, :release_channel, :application, :channel,
attr_reader :app_id, :release_channel, :application, :channel, :target_version,
:manifests, :packages, :files, :wine_prefix, :status
def initialize(app_id, release_channel)
@@ -20,13 +20,16 @@ class W3DHub
@application = Store.applications.games.find { |g| g.id == app_id }
@channel = @application.channels.find { |c| c.id == release_channel }
@target_version = type == :repairer ? Store.settings[:games][:"#{app_id}_#{@channel.id}"][:installed_version] : @channel.current_version
@packages_to_download = []
@total_bytes_to_download = -1
@bytes_downloaded = -1
@manifests = []
@files = {}
@files = []
@packages = []
@deleted_files = [] # TODO: remove removed files
@wine_prefix = nil
@@ -54,12 +57,12 @@ class W3DHub
@task_state = :running
Thread.new do
Sync do
# Sync do
begin
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]
@@ -75,8 +78,8 @@ 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
end
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
@@ -109,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
@@ -142,15 +177,12 @@ class W3DHub
fail!("FAIL FAST: Cannot write to #{path}")
end
# Have enough disk space
# tar present?
bsdtar_present = system("#{W3DHub.tar_command} --help")
fail!("FAIL FAST: `#{W3DHub.tar_command} --help` command failed, #{W3DHub.tar_command} is not installed. Will be unable to unpack packages.") unless bsdtar_present
# FIXME: Check that there is enough disk space
# TODO: Is missing wine/proton really a failure condition?
# Wine present?
if W3DHub.unix?
wine_present = system("which #{Store.settings[:wine_command]}")
wine_present = W3DHub.command("which #{Store.settings[:wine_command]}")
fail!("FAIL FAST: `which #{Store.settings[:wine_command]}` command failed, wine is not installed. Will be unable to create prefixes or launch games.") unless wine_present
end
end
@@ -203,21 +235,28 @@ class W3DHub
@status.step = :fetching_manifests
if fetch_manifest("games", app_id, "manifest.xml", @channel.current_version)
manifest = load_manifest("games", app_id, "manifest.xml", @channel.current_version)
if fetch_manifest("games", app_id, "manifest.xml", @target_version)
manifest = load_manifest("games", app_id, "manifest.xml", @target_version)
@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..."
@@ -225,44 +264,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..."
@@ -279,46 +322,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/") unless File.exist?("#{path}/#{safe_file_name}")
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
@@ -330,7 +369,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
@@ -341,13 +380,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,
@@ -356,98 +397,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)
@@ -457,7 +503,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,
@@ -469,7 +515,7 @@ class W3DHub
@status.step = :unpacking
i = -1
packages.each do |package|
@packages.each do |package|
i += 1
status = if package.custom_is_patch
@@ -488,8 +534,6 @@ class W3DHub
unpack_package(package, path)
end
repair_windows_case_insensitive(package, path)
if status
@status.operations[:"#{package.checksum}"].value = package.custom_is_patch ? "Patched" : "Unpacked"
@status.operations[:"#{package.checksum}"].progress = 1.0
@@ -504,6 +548,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
@@ -516,7 +577,7 @@ class W3DHub
end
end
def install_dependencies(packages)
def install_dependencies
# TODO: install dependencies
@status.operations.clear
@status.label = "Installing #{@application.name}..."
@@ -526,6 +587,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)
@@ -547,12 +624,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))
@@ -570,9 +652,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)
@@ -584,7 +670,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}" }
@@ -633,8 +719,9 @@ class W3DHub
logger.info(LOG_TAG) { " #{package.name}:#{package.version}" }
package_path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
logger.info(LOG_TAG) { " Running #{W3DHub.tar_command} command: #{W3DHub.tar_command} -xf \"#{package_path}\" -C \"#{path}\"" }
return system("#{W3DHub.tar_command} -xf \"#{package_path}\" -C \"#{path}\"")
logger.info(LOG_TAG) { " Unpacking package \"#{package_path}\" in \"#{path}\"" }
return unzip(package_path, path)
end
def apply_patch(package, path)
@@ -645,32 +732,31 @@ class W3DHub
Cache.create_directories(temp_path, true)
logger.info(LOG_TAG) { " Running #{W3DHub.tar_command} command: #{W3DHub.tar_command} -xf \"#{package_path}\" -C \"#{temp_path}\"" }
system("#{W3DHub.tar_command} -xf \"#{package_path}\" -C \"#{temp_path}\"")
logger.info(LOG_TAG) { " Unpacking patch \"#{package_path}\" in \"#{temp_path}\"" }
unzip(package_path, temp_path)
logger.info(LOG_TAG) { " Loading #{temp_path}/#{manifest_file.name}.patch..." }
patch_mix = W3DHub::Mixer::Reader.new(file_path: "#{temp_path}/#{manifest_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)
file_path = normalize_path(manifest_file.name, path)
temp_file_path = normalize_path(manifest_file.name, temp_path)
repaired_path = "#{path}/#{manifest_file.name}"
# Fix borked data -> Data 'cause Windows don't care about capitalization
repaired_path = "#{path}/#{manifest_file.name.sub('data', 'Data')}" unless File.exist?(repaired_path) && path
logger.info(LOG_TAG) { " Loading #{temp_file_path}.patch..." }
patch_mix = W3DHub::Mixer::Reader.new(file_path: "#{temp_file_path}.patch", ignore_crc_mismatches: false)
patch_info = JSON.parse(patch_mix.package.files.find { |f| f.name.casecmp?(".w3dhub.patch") || f.name.casecmp?(".bhppatch") }.data, symbolize_names: true)
logger.info(LOG_TAG) { " Loading #{repaired_path}..." }
target_mix = W3DHub::Mixer::Reader.new(file_path: repaired_path, ignore_crc_mismatches: false)
logger.info(LOG_TAG) { " Loading #{file_path}..." }
target_mix = W3DHub::Mixer::Reader.new(file_path: "#{file_path}", ignore_crc_mismatches: false)
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.package.files.delete_if { |f| f.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 }
patch = patch_mix.package.files.find { |f| f.name.casecmp?(file) }
target = target_mix.package.files.find { |f| f.name.casecmp?(file) }
if target
target_mix.package.files[target_mix.package.files.index(target)] = patch
@@ -679,30 +765,36 @@ class W3DHub
end
end
logger.info(LOG_TAG) { " Writing updated #{repaired_path}..." } if patch_info[:updatedFiles].size.positive?
W3DHub::Mixer::Writer.new(file_path: repaired_path, package: target_mix.package, memory_buffer: true)
logger.info(LOG_TAG) { " Writing updated #{file_path}..." } if patch_info[:updatedFiles].size.positive?
W3DHub::Mixer::Writer.new(file_path: "#{file_path}", package: target_mix.package, memory_buffer: true, encrypted: target_mix.encrypted?)
FileUtils.remove_dir(temp_path)
true
end
def repair_windows_case_insensitive(package, path)
return true if @app_id == "apb"
def unzip(package_path, path)
stream = Zip::InputStream.new(File.open(package_path))
# Windows is just confused
return true if W3DHub.windows?
while (entry = stream.get_next_entry)
# Normalize the path to handle case-insensitivity consistently
file_path = normalize_path(entry.name, path)
# Force data/ to Data/
return true unless File.exist?("#{path}/data") && File.directory?("#{path}/data")
dir_path = File.dirname(file_path)
unless dir_path.end_with?("/.") || Dir.exist?(dir_path)
FileUtils.mkdir_p(dir_path)
end
logger.info(LOG_TAG) { " Moving #{path}/data/ to #{path}/Data/" }
File.open(file_path, "wb") do |f|
i = entry.get_input_stream
FileUtils.mv(Dir.glob("#{path}/data/**"), "#{path}/Data", force: true)
FileUtils.remove_dir("#{path}/data", force: true)
while (chunk = i.read(32_000_000)) # Read up to ~32 MB per chunk
f.write chunk
end
end
end
true
return true
end
end
end

View File

@@ -1,50 +0,0 @@
class W3DHub
class ApplicationManager
class Importer < Task
LOG_TAG = "W3DHub::ApplicationManager::Importer".freeze
def type
:importer
end
def execute_task
path = 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
def ask_file(title: "Open File", filter: "*game*.exe")
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 --title "#{title}" --file-filter "#{filter}"`
when "kdialog"
`#{command} --title "#{title}" --getopenfilename . "#{filter}"`
else
raise "No known command found for system file selection dialog!"
end
path.strip
else
raise NotImplementedError
end
end
end
end
end

View File

@@ -13,28 +13,34 @@ class W3DHub
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
@@ -47,4 +53,4 @@ class W3DHub
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

@@ -39,7 +39,7 @@ class W3DHub
path = Cache.install_path(@application, @channel)
log path
logger.info(LOG_TAG) { path }
# TODO: Do some sanity checking, i.e. DO NOT start launcher if `whoami` returns root, path makes sense,
# we're not on Windows trying to uninstall a game likely installed by the official launcher
FileUtils.remove_dir(path)
@@ -55,7 +55,7 @@ class W3DHub
@status.step = :mark_application_uninstalled
log "#{@app_id} has been uninstalled."
logger.info(LOG_TAG) { "#{@app_id} has been uninstalled." }
end
end
end

83
lib/asterisk/config.rb Normal file
View File

@@ -0,0 +1,83 @@
class W3DHub
class Asterisk
class Config
CONFIG_PATH = "#{GAME_ROOT_PATH}/data/asterisk.json"
attr_reader :settings, :server_profiles, :games, :irc_profiles
def initialize
@config = nil
save_new_config unless load_config
load_config unless @config
end
def save_new_config
hash = {
settings: {
theme: :default,
server_profile: "",
game: "",
launch_arguments: "",
irc_profile: "None",
nickname: "",
password: "",
server_hostname: "",
server_port: "",
preload_app: "",
enable_preload_app: false,
post_launch_app: "",
enable_post_launch_app: false,
},
server_profiles: [],
games: [],
irc_profiles: []
}
save_config(hash)
end
def load_config
return false unless File.exist?(CONFIG_PATH)
begin
hash = JSON.parse(File.read(CONFIG_PATH), symbolize_names: true)
@config ||= {}
@config[:settings] = @settings = Settings.new(hash[:settings])
@config[:server_profiles] = @server_profiles = []
hash[:server_profiles].each { |profile| @server_profiles << ServerProfile.new(profile) }
@config[:games] = @games = []
hash[:games].each { |game| @games << Game.new(game) }
@config[:irc_profiles] = @irc_profiles = []
hash[:irc_profiles].each { |profile| @irc_profiles << IRCProfile.new(profile) }
rescue JSON::ParserError
puts "Config corrupted"
false
end
end
def hard_reset!
save_new_config
load_config
end
def save_config(config = @config)
File.write(CONFIG_PATH, config.to_json)
end
end
end
end

21
lib/asterisk/game.rb Normal file
View File

@@ -0,0 +1,21 @@
class W3DHub
class Asterisk
class Game
attr_accessor :title, :path
def initialize(hash = nil)
return unless hash
@title = hash[:title]
@path = hash[:path]
end
def to_json(options)
{
title: @title,
path: @path
}.to_json(options)
end
end
end
end

139
lib/asterisk/irc_client.rb Normal file
View File

@@ -0,0 +1,139 @@
require "socket"
require "openssl"
require "ircparser"
require_relative "irc_profile"
class W3DHub
class Asterisk
class IRCClient
TAG = "IRCClient"
class SSL
def self.default_context
verify_peer_and_hostname
end
def self.verify_peer_and_hostname
verify_peer.tap do |context|
context.verify_hostname = true
end
end
def self.verify_peer
no_verify.tap do |context|
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
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
def self.verify_hostname_only
no_verify.tap do |context|
context.verify_hostname = true
end
end
def self.no_verify
OpenSSL::SSL::SSLContext.new
end
end
attr_reader :status
def initialize(irc_profile)
@irc_profile = irc_profile
ssl_context = false
if irc_profile.server_ssl
ssl_context = irc_profile.server_verify_ssl ? SSL.default_context : SSL.no_verify
end
socket = dial(
@irc_profile.server_hostname,
@irc_profile.server_port,
ssl_context: ssl_context
)
authenticate_with_brenbot!(socket)
ensure
close_socket(socket)
end
def dial(hostname, port = 6697, local_host: nil, local_port: nil, ssl_context: SSL.default_context)
Socket.tcp(hostname, port, local_host, local_port).then do |socket|
if ssl_context
@ssl_socket = true
ssl_context = SSL.send(ssl_context) if ssl_context.is_a?(Symbol)
OpenSSL::SSL::SSLSocket.new(socket, ssl_context).tap do |ssl_socket|
ssl_socket.hostname = hostname
ssl_socket.connect
end
else
socket
end
end
rescue StandardError => e
logger.error(TAG) { e }
logger.error(TAG) { e.backtrace }
end
def authenticate_with_brenbot!(socket)
username = @irc_profile.username.empty? ? @irc_profile.nickname : @irc_profile.username
pass = IRCParser::Message.new(command: "PASS", parameters: [Base64.strict_decode64(@irc_profile.password)]) unless @irc_profile.password.empty?
user = IRCParser::Message.new(command: "USER", parameters: [username, "0", "*", ":#{@irc_profile.nickname}"])
nick = IRCParser::Message.new(command: "NICK", parameters: [@irc_profile.nickname])
socket.puts(pass)
socket.puts(user)
socket.puts(nick)
socket.flush
until socket.closed?
raw = socket.gets
next if raw.to_s.empty?
msg = IRCParser::Message.parse(raw)
if msg.command == "PING"
pong = IRCParser::Message.new(command: "PONG", parameters: [msg.parameters.first.sub("\r\n", "")])
socket.puts("#{pong}")
socket.flush
elsif msg.command == "001" && msg.parameters.join.include?("#{@irc_profile.nickname}!#{@irc_profile.username.split("/").first}")
pm = IRCParser::Message.new(command: "PRIVMSG", parameters: [@irc_profile.bot_username, "!auth #{@irc_profile.bot_auth_username} #{Base64.strict_decode64(@irc_profile.bot_auth_password)}"])
socket.puts(pm)
quit = IRCParser::Message.new(command: "QUIT", parameters: ["Quiting from an Asterisk"])
socket.puts(quit)
socket.flush
sleep 15
close_socket(socket)
elsif msg.command == "ERROR"
close_socket(socket)
end
end
end
def close_socket(socket)
return unless socket
if @ssl_socket
socket.sync_close = true
socket.sysclose
else
socket.close
end
end
end
end
end

View File

@@ -0,0 +1,42 @@
class W3DHub
class Asterisk
class IRCProfile
attr_accessor :name,
:username, :nickname, :password,
:server_hostname, :server_port, :server_ssl, :server_verify_ssl,
:bot_username, :bot_auth_username, :bot_auth_password
def initialize(hash = nil)
return unless hash
@name = hash[:name]
@username = hash[:username] || hash[:nickname]
@nickname = hash[:nickname]
@password = hash[:password]
@server_hostname = hash[:server_hostname]
@server_port = hash[:server_port]
@server_ssl = hash[:server_ssl]
@server_verify_ssl = hash[:server_verify_ssl]
@bot_username = hash[:bot_username]
@bot_auth_username = hash[:bot_auth_username]
@bot_auth_password = hash[:bot_auth_password]
end
def to_json(options)
{
name: @name,
username: @username,
nickname: @nickname,
password: @password,
server_hostname: @server_hostname,
server_port: @server_port,
server_ssl: @server_ssl,
server_verify_ssl: @server_verify_ssl,
bot_username: @bot_username,
bot_auth_username: @bot_auth_username,
bot_auth_password: @bot_auth_password
}.to_json(options)
end
end
end
end

View File

@@ -0,0 +1,38 @@
class W3DHub
class Asterisk
class ServerProfile
attr_accessor :name, :nickname, :password,
:game_title, :launch_arguments,
:server_profile, :server_hostname, :server_port,
:irc_profile
def initialize(hash = nil)
return unless hash
@name = hash[:name]
@nickname = hash[:nickname]
@password = hash[:password]
@server_profile = hash[:server_profile]
@server_hostname = hash[:server_hostname]
@server_port = hash[:server_port]
@game_title = hash[:game_title]
@launch_arguments = hash[:launch_arguments]
@irc_profile = hash[:irc_profile]
end
def to_json(options)
{
name: @name,
nickname: @nickname,
password: @password,
server_profile: @server_profile,
server_hostname: @server_hostname,
server_port: @server_port,
game_title: @game_title,
launch_arguments: @launch_arguments,
irc_profile: @irc_profile
}.to_json(options)
end
end
end
end

26
lib/asterisk/settings.rb Normal file
View File

@@ -0,0 +1,26 @@
class W3DHub
class Asterisk
class Settings
attr_accessor :theme, :preload_app, :enable_preload_app, :post_launch_app, :enable_post_launch_app
def initialize(hash)
@theme = hash[:theme].to_sym
@preload_app = hash[:preload_app]
@enable_preload_app = hash[:enable_preload_app]
@post_launch_app = hash[:post_launch_app]
@enable_post_launch_app = hash[:enable_post_launch_app]
end
def to_json(options)
{
theme: @theme,
preload_app: @preload_app,
enable_preload_app: @enable_preload_app,
post_launch_app: @post_launch_app,
enable_post_launch_app: @enable_post_launch_app
}.to_json(options)
end
end
end
end

View File

@@ -0,0 +1,98 @@
class W3DHub
class Asterisk
class States
class GameForm < CyberarmEngine::GuiState
def setup
@game = @options[:editing]
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 @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: 72) do
para "Game or Mod Title:"
@game_title = edit_line "#{@game&.title}", width: 1.0, fill: true
end
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, 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
@options[:save_callback].call(
@game,
@game_path.value,
@game_title.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?
existing_game = W3DHub::Store[:asterisk_config].games.find { |g| g.title == @game_title.value }
existing_game = nil if existing_game == @game
@game_title.value.length.positive? &&
@game_path.value.length.positive? &&
File.exist?(@game_path.value) &&
!existing_game
end
end
end
end
end

View File

@@ -0,0 +1,160 @@
class W3DHub
class Asterisk
class States
class IRCProfileForm < CyberarmEngine::GuiState
def setup
@profile = @options[:editing]
theme W3DHub::THEME
background 0x88_525252
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: 36, padding: 8) do
background 0x88_000000
# tagline "<b>#{I18n.t(:"server_browser.direct_connect")}</b>", 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: 72) do
para "IRC Nickname:"
@nickname = edit_line "#{@profile&.nickname}", width: 1.0, fill: true
end
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):"
@username = edit_line "#{@profile&.username}", width: 1.0, fill: true
end
stack(width: 0.5, height: 1.0) do
para "IRC Server Password (Optional):"
@password = edit_line @profile ? Base64.strict_decode64(@profile.password) : "", width: 1.0, fill: true, type: :password
end
end
end
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:"
@server_hostname = edit_line "#{@profile&.server_hostname}", width: 1.0, fill: true
end
stack(width: 0.249, height: 1.0) do
para "IRC Server Port:"
@server_port = edit_line "#{@profile&.server_port || '6667'}", width: 1.0, fill: true
end
end
end
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: 72) do
para "Brenbot Bot Name:"
@bot_username = edit_line "#{@profile&.bot_username}", width: 1.0, fill: true
end
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: 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
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
@options[:save_callback].call(
@profile,
@nickname.value,
@username.value,
@password.value,
@server_hostname.value,
@server_port.value,
@server_ssl.value,
@server_verify_ssl.value,
@bot_username.value,
@bot_auth_username.value,
@bot_auth_password.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?
generated_name = IRCProfileForm.generate_profile_name(
@nickname.value,
@server_hostname.value,
@server_port.value,
@bot_username.value
)
existing_profile = W3DHub::Store[:asterisk_config].irc_profiles.find { |profile| profile.name == generated_name }
@nickname.value.length.positive? &&
@server_hostname.value.length.positive? &&
@server_port.value.length.positive? &&
@bot_username.value.length.positive? &&
@bot_auth_username.value.length.positive? &&
@bot_auth_password.value.length.positive?
end
def self.generate_profile_name(nickname, hostname, port, bot)
"#{bot}@#{hostname}:#{port} as #{nickname}"
end
end
end
end
end

View File

@@ -0,0 +1,72 @@
class W3DHub
class Asterisk
class States
class ServerProfileForm < CyberarmEngine::GuiState
def setup
@server_profile = @options[:editing]
theme W3DHub::THEME
background 0x88_525252
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: 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 @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: 72) do
para "Server Profile Name:"
@server_name = edit_line "#{@server_profile&.name}", width: 1.0, fill: true
@server_name.subscribe(:changed) do |label|
@save_button.enabled = label.value.length.positive?
end
end
flow(fill: true)
flow(width: 1.0, 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 do
pop_state
@options[:save_callback].call(
@server_profile,
@server_name.value
)
end
end
end
end
end
def draw
previous_state&.draw
Gosu.flush
super
end
def button_down(id)
super
case id
when Gosu::KB_ESCAPE
pop_state
end
end
end
end
end
end

View File

@@ -9,13 +9,12 @@ class W3DHub
logger.info(LOG_TAG) { "Starting background job worker..." }
@@thread = Thread.current
@@alive = true
@@run = true
@@instance = self.new
Async do
@@instance.handle_jobs
end
@@instance.handle_jobs
end
def self.instance
@@ -30,54 +29,123 @@ class W3DHub
@@alive
end
def self.busy?
instance&.busy?
end
def self.shutdown!
@@run = false
end
def self.job(job, callback, error_handler = nil)
@@instance.add_job(Job.new(job: job, callback: callback, error_handler: error_handler))
def self.kill!
@@thread.kill
@@instance.kill!
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))
def self.job(job, callback, error_handler = nil, data = nil)
@@instance.add_job(Job.new(job: job, callback: callback, error_handler: error_handler, data: data))
end
def self.parallel_job(job, callback, error_handler = nil, data = nil)
@@instance.add_parallel_job(Job.new(job: job, callback: callback, error_handler: error_handler, data: data))
end
def self.foreground_job(job, callback, error_handler = nil, data = nil)
@@instance.add_job(Job.new(job: job, callback: callback, error_handler: error_handler, deliver_to_queue: true, data: data))
end
def self.foreground_parallel_job(job, callback, error_handler = nil, data = nil)
@@instance.add_parallel_job(Job.new(job: job, callback: callback, error_handler: error_handler, deliver_to_queue: true, data: data))
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
while BackgroundWorker.run?
job = @jobs.shift
8.times do |i|
Thread.new do
@thread_pool << Thread.current
begin
job&.do
rescue => error
job&.raise_error(error)
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
sleep 0.1
end
logger.info(LOG_TAG) { "Stopped background job worker." }
@@alive = false
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)
def initialize(job:, callback:, error_handler: nil, deliver_to_queue: false, data: nil)
@job = job
@callback = callback
@error_handler = error_handler
@deliver_to_queue = deliver_to_queue
@data = data
end
def do
result = @job.call
result = @data ? @job.call(@data) : @job.call
deliver(result)
end

View File

@@ -9,19 +9,19 @@ class W3DHub
end
# Fetch a generic uri
def self.fetch(uri:, force_fetch: false, async: true)
def self.fetch(uri:, force_fetch: false, async: true, backend: :w3dhub)
path = path(uri)
if !force_fetch && File.exist?(path)
path
elsif async
BackgroundWorker.job(
-> { Async::HTTP::Internet.instance.get(uri, W3DHub::Api::DEFAULT_HEADERS) },
->(response) { response.save(path, "wb") if response.success? }
-> { Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend) },
->(response) { File.open(path, "wb") { |f| f.write response.body } if response.status == 200 }
)
else
response = Async::HTTP::Internet.instance.get(uri, W3DHub::Api::DEFAULT_HEADERS)
response.save(path, "wb") if response.success?
response = Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend)
File.open(path, "wb") { |f| f.write response.body } if response.status == 200
end
end
@@ -69,7 +69,7 @@ class W3DHub
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
response = Api.post("#{Api::ENDPOINT}/apis/launcher/1/get-package", headers, body)
response = Api.post("/apis/launcher/1/get-package", headers, body)
total_bytes = package.size
remaining_bytes = total_bytes - start_from_bytes
@@ -82,16 +82,22 @@ class W3DHub
block.call(chunk, remaining_bytes, total_bytes)
end
response.success?
response.status == 200
ensure
file&.close
end
# Download a W3D Hub package
def self.fetch_package(package, block)
endpoint_download_url = package.download_url || "#{Api::W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package"
if package.download_url
uri_path = package.download_url.split("/").last
endpoint_download_url = package.download_url.sub(uri_path, URI.encode_uri_component(uri_path))
end
path = package_path(package.category, package.subcategory, package.name, package.version)
headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT }
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account && !package.download_url
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
start_from_bytes = package.custom_partially_valid_at_bytes
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
@@ -112,16 +118,39 @@ class W3DHub
end
# Create a new connection due to some weirdness somewhere in Excon
response = Excon.post(
"#{Api::ENDPOINT}/apis/launcher/1/get-package",
response = Excon.send(
package.download_url ? :get : :post,
endpoint_download_url,
tcp_nodelay: true,
headers: headers,
body: "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}",
body: package.download_url ? "" : body,
chunk_size: 50_000,
response_block: streamer
response_block: streamer,
middlewares: Excon.defaults[:middlewares] + [Excon::Middleware::RedirectFollower]
)
response.status == 200 || response.status == 206
if response.status == 200 || response.status == 206
return true
else
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false
end
rescue Excon::Error::Timeout => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" timed out after: #{W3DHub::Api::API_TIMEOUT} seconds" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false
rescue Excon::Error => e
logger.error(LOG_TAG) { " Connection to \"#{endpoint_download_url}\" errored:" }
logger.error(LOG_TAG) { e }
logger.debug(LOG_TAG) { " Failed to retrieve package: (#{package.category}:#{package.subcategory}:#{package.name}:#{package.version})" }
logger.debug(LOG_TAG) { " Download URL: #{endpoint_download_url}, response: #{response&.status || -1}" }
return false
ensure
file&.close
end

View File

@@ -32,15 +32,184 @@ class W3DHub
linux? || mac?
end
def self.tar_command
# 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?(";")
if windows?
"tar"
system("start #{path}")
elsif linux?
system("xdg-open #{path}")
elsif mac?
system("open #{path}")
end
end
def self.prompt_for_nickname(accept_callback: nil, cancel_callback: nil)
CyberarmEngine::Window.instance.push_state(
W3DHub::States::PromptDialog,
title: I18n.t(:"server_browser.set_nickname"),
message: I18n.t(:"server_browser.set_nickname_message"),
prefill: Store.settings[:server_list_username],
accept_callback: accept_callback,
cancel_callback: cancel_callback,
# See: https://gitlab.com/danpaul88/brenbot/-/blob/master/Source/renlog.pm#L136-175
valid_callback: proc do |entry|
entry.length > 1 && entry.length < 30 && (entry =~ /(:|!|&|%| )/i).nil? &&
(entry =~ /[\001\002\037]/).nil? && (entry =~ /\\/).nil?
end
)
end
def self.prompt_for_password(accept_callback: nil, cancel_callback: nil)
CyberarmEngine::Window.instance.push_state(
W3DHub::States::PromptDialog,
title: I18n.t(:"server_browser.enter_password"),
message: I18n.t(:"server_browser.enter_password_message"),
input_type: :password,
accept_callback: accept_callback,
cancel_callback: cancel_callback,
valid_callback: proc { |entry| entry.length.positive? }
)
end
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) &&
username.to_s.length.positive?
Store.application_manager.join_server(
server.game,
server.channel,
server,
username,
password,
multi
)
else
CyberarmEngine::Window.instance.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?")
end
end
def self.command(command, &block)
if windows?
stdout_read, stdout_write = IO.pipe if block
hash = {
command_line: command,
creation_flags: Process::DETACHED_PROCESS,
process_inherit: true,
thread_inherit: true,
close_handles: false,
inherit: true
}
if block
hash[:startup_info] = {
stdout: stdout_write,
stderr: stdout_write
}
end
process_info = Process.create(**hash)
pid = process_info.process_id
status = -1
until (status = Process.get_exitcode(pid))
if block
readable, _writable, _errorable = IO.select([stdout_read], [], [], 1)
readable&.each do |io|
line = io.readpartial(1024)
block&.call(line)
end
else
sleep 0.1
end
end
status.zero?
else
"bsdtar"
if block
IO.popen(command, "r") do |io|
io.each_line do |line|
block&.call(line)
end
end
$CHILD_STATUS.success?
else
system(command)
end
end
end
def self.home_directory
File.expand_path("~")
end
def self.ask_file(title: "Open File", filter: "*game*.exe")
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 --title "#{title}" --file-filter "#{filter}"`
when "kdialog"
`#{command} --title "#{title}" --getopenfilename . "#{filter}"`
else
raise "No known command found for system file selection dialog!"
end
path.strip
else
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

271
lib/game_settings.rb Normal file
View File

@@ -0,0 +1,271 @@
class W3DHub
class GameSettings
TYPE_LIBCONFIG = 0
TYPE_REGISTRY = 1
Setting = Struct.new(:group, :name, :label, :type, :key, :value, :options, :indexed)
def initialize(app_id, channel)
@app_id = app_id
@channel = channel
# Minimium width/height to show in options
@min_width = 1280
@min_height = 720
@win32_registry_base = "SOFTWARE\\W3D Hub\\games\\#{app_id}-#{channel}".freeze
@engine_cfg_path = "#{Dir.home}/Documents/W3D Hub/games/#{app_id}-#{channel}/engine.cfg".freeze
@hardware_data = HardwareSurvey.new.data
@cfg = File.exist?(@engine_cfg_path) ? File.read(@engine_cfg_path) : nil
@cfg_hash = {}
resolutions = @hardware_data[:displays].map { |display| display[:resolutions] }.flatten.each_slice(2).select do |pair|
width = pair[0]
height = pair[1]
width >= @min_width && height >= @min_height && width > height
end
refresh_rates = ([300, 240, 165, 144, 120, 75, 60, 59, 50, 40] + @hardware_data[:displays].map do |display|
display[:refresh_rates]
end).flatten.uniq.sort.reverse.map { |r| [r, r] }
@settings = {}
# General
@settings[:default_to_first_person] = Setting.new(:general, :default_to_first_person, "Default to First Person", TYPE_REGISTRY, "Options\\DefaultToFirstPerson", true)
@settings[:background_downloads] = Setting.new(:general, :background_downloads, "Background Downloads", TYPE_REGISTRY, "BackgroundDownloadingEnabled", true)
@settings[:hints_enabled] = Setting.new(:general, :hints_enabled, "Enable Hints", TYPE_REGISTRY, "HintsEnabled", true)
@settings[:chat_log] = Setting.new(:general, :chat_log, "Enable Chat Log", TYPE_REGISTRY, "ClientChatLog", true)
@settings[:show_fps] = Setting.new(:general, :show_fps, "Show FPS", TYPE_REGISTRY, "Networking\\Debug\\ShowFps", true)
@settings[:show_velocity] = Setting.new(:general, :show_velocity, "Show Velocity", TYPE_REGISTRY, "ShowVelocity", true)
@settings[:show_damage_numbers] = Setting.new(:general, :show_damage_numbers, "Show Damage Numbers", TYPE_REGISTRY, "Options\\HitDamageOnScreen", true)
# Audio
@settings[:master_volume] = Setting.new(:audio, :master_volume, "Master Volume", TYPE_REGISTRY, "Sound\\master volume", 1.0)
@settings[:master_enabled] = Setting.new(:audio, :master_enabled, "Master Volume Enabled", TYPE_REGISTRY, "Sound\\master enabled", true)
@settings[:sound_effects_volume] = Setting.new(:audio, :sound_effects_volume, "Sound Effects", TYPE_REGISTRY, "Sound\\sound volume", 0.40)
@settings[:sound_effects_enabled] = Setting.new(:audio, :sound_effects_enabled, "Sound Effects Enabled", TYPE_REGISTRY, "Sound\\sound enabled", true)
@settings[:sound_dialog_volume] = Setting.new(:audio, :sound_dialog_volume, "Dialog", TYPE_REGISTRY, "Sound\\dialog volume", 0.75)
@settings[:sound_dialog_enabled] = Setting.new(:audio, :sound_dialog_enabled, "Dialog Enabled", TYPE_REGISTRY, "Sound\\dialog enabled", true)
@settings[:sound_music_volume] = Setting.new(:audio, :sound_music_volume, "Music", TYPE_REGISTRY, "Sound\\music volume", 0.75)
@settings[:sound_music_enabled] = Setting.new(:audio, :sound_music_enabled, "Music Enabled", TYPE_REGISTRY, "Sound\\music enabled", true)
@settings[:sound_cinematic_volume] = Setting.new(:audio, :sound_cinematic_volume, "Cinematic", TYPE_REGISTRY, "Sound\\cinematic volume", 0.75)
@settings[:sound_cinematic_enabled] = Setting.new(:audio, :sound_cinematic_enabled, "Cinematic Enabled", TYPE_REGISTRY, "Sound\\cinematic enabled", true)
@settings[:sound_in_background] = Setting.new(:audio, :sound_in_background, "Play Sound with Game in Background", TYPE_REGISTRY, "Sound\\mute in background", false)
# Video
@settings[:resolution_width] = Setting.new(:video, :resolution_width, "Resolution", TYPE_LIBCONFIG, "Render:Width", resolutions.first[0], resolutions.map { |a| [a[0], a[0]] })
@settings[:resolution_height] = Setting.new(:video, :resolution_height, "Resolution", TYPE_LIBCONFIG, "Render:Height", resolutions.first[1], resolutions.map { |a| [a[1], a[1]] })
@settings[:windowed_mode] = Setting.new(:video, :windowed_mode, "Windowed Mode", TYPE_LIBCONFIG, "Render:FullscreenMode", 2, [["Windowed", 0], ["Fullscreen", 1], ["Borderless", 2]], true)
@settings[:vsync] = Setting.new(:video, :vsync, "Enable VSync", TYPE_LIBCONFIG, "Render:DisableVSync", true)
@settings[:fps] = Setting.new(:video, :fps, "FPS Limit", TYPE_LIBCONFIG, "Render:MaxFPS", refresh_rates.first[1], refresh_rates)
@settings[:anti_aliasing] = Setting.new(:video, :anti_aliasing, "Anti-aliasing", TYPE_REGISTRY, "System Settings\\Antialiasing_Mode", 0x80000001, [["None", 0], ["2x", 0x80000000], ["4x", 0x80000001], ["8x", 0x80000002]], true)
# Performance
@settings[:texture_detail] = Setting.new(:performance, :texture_detail, "Texture Detail", TYPE_REGISTRY, "System Settings\\Texture_Resolution", 0, [["High",0], ["Medium", 1], ["Low", 2]], true)
@settings[:texture_filtering] = Setting.new(:performance, :texture_filtering, "Texture Filtering", TYPE_REGISTRY, "System Settings\\Texture_Filter_Mode", 3, [["Bilinear", 0], ["Trilinear", 1], ["Anisotropic 2x", 2], ["Anisotropic 4x", 3], ["Anisotropic 8x", 4], ["Anisotropic 16x", 5]], true)
@settings[:shadow_resolution] = Setting.new(:performance, :shadow_resolution, "Shadow Resolution", TYPE_REGISTRY, "System Settings\\Dynamic_Shadow_Resolution", 512, [["128", 128], ["256", 256], ["512", 512], ["1024", 1024], ["2048*", 2048], ["4096*", 4096]], true)
@settings[:high_quality_shadows] = Setting.new(:general, :high_quality_shadows, "High Quality Shadows", TYPE_REGISTRY, "HighQualityShadows", true)
load_settings
end
def get(key)
@settings[key]
end
def get_value(key)
setting = get(key)
if setting.options.is_a?(Array) && setting.indexed
setting.options[setting.options.map(&:last).index(setting.value)][0]
else
setting.value
end
end
def set_value(key, value)
setting = get(key)
if setting.options.is_a?(Array)
setting.value = setting.options.find { |v| v[0] == value }[1]
elsif setting.options.is_a?(Hash)
setting.value = value.clamp(setting.options[:min], setting.options[:max])
else
setting.value = value
end
end
def load_settings
load_from_registry
load_from_cfg
end
def load_from_registry
@settings.each do |_key, setting|
next unless setting.type == TYPE_REGISTRY
data = nil
begin
data = read_reg(setting.key)
rescue Win32::Registry::Error
end
next unless data
if setting.value.is_a?(TrueClass) || setting.value.is_a?(FalseClass)
setting.value = data == 1
elsif setting.value.is_a?(Float)
if setting.group == :audio
setting.value = data.to_f / 100.0
else
setting.value = data
end
elsif setting.value.is_a?(Integer)
setting.value = data
else
raise "UNKNOWN VALUE TYPE: #{setting.value.class}"
end
end
end
def load_from_cfg
@cfg_hash = {}
if @cfg
in_hash = false
@cfg.lines.each do |line|
line = line.strip
break if line.start_with?("}")
if line.start_with?("{")
in_hash = true
next
end
next unless in_hash
parts = line.split("=").map { |l| l.strip.sub(";", "")}
@cfg_hash[parts.first] = parts.last
end
end
@cfg_hash.each do |key, value|
next if value.start_with?("\"")
begin
@cfg_hash[key] = Integer(value)
rescue ArgumentError # Not an int
@cfg_hash[key] = value == "true" ? true : false if value == "true" || value == "false"
@cfg_hash[key] = !@cfg_hash[key] if key == "DisableVSync" # UI shows enable vsync, cfg stores disable vsync
end
end
@settings.each do |key, setting|
next unless setting.type == TYPE_LIBCONFIG
cfg_key = setting.key.split(":").last
v = @cfg_hash[cfg_key]
if v != nil
if v.is_a?(TrueClass) || v.is_a?(FalseClass)
setting.value = v
elsif v.is_a?(Integer)
i = setting.options.map(&:last).index(v) || 0
if ["Width", "Height"].include?(cfg_key)
set_value(key, setting.options[i][0])
elsif cfg_key == "MaxFPS"
setting.value = v
end
end
else
@cfg_hash[cfg_key] = setting.value
end
end
end
def save_settings!
save_to_registry!
save_to_cfg!
end
def save_to_registry!
@settings.each do |_key, setting|
next unless setting.type == TYPE_REGISTRY
if setting.value.is_a?(TrueClass) || setting.value.is_a?(FalseClass)
write_reg(setting.key, setting.value ? 1 : 0)
elsif setting.value.is_a?(Float)
if setting.group == :audio
write_reg(setting.key, (setting.value * 100.0).round.clamp(0, 100))
else
write_reg(setting.key, setting.value)
end
elsif setting.value.is_a?(Integer)
write_reg(setting.key, setting.value)
else
raise "UNKNOWN VALUE TYPE: #{setting.value.class}"
end
end
end
def save_to_cfg!
@settings.each do |key, setting|
next unless setting.type == TYPE_LIBCONFIG
cfg_key = setting.key.split(":").last
v = @cfg_hash[cfg_key]
if v
# UI shows enable vsync, cfg stores disable vsync
@cfg_hash[cfg_key] = cfg_key == "DisableVSync" ? !setting.value : setting.value
end
end
string = "Render : \n{\n"
@cfg_hash.each do |key, value|
string += " #{key} = #{value.to_s};\n"
end
string += "};\n"
FileUtils.mkdir_p(File.dirname(@engine_cfg_path)) unless Dir.exist?(File.dirname(@engine_cfg_path))
File.write(@engine_cfg_path, string)
end
def read_reg(key)
keys = key.split("\\")
sub_key = keys.size > 1 ? keys[0..(keys.size - 2)].join("\\") : ""
target_key = keys.last
reg_key = "#{@win32_registry_base}\\#{sub_key}".freeze
value = nil
Win32::Registry::HKEY_CURRENT_USER.open(reg_key) do |reg|
value = reg[target_key]
end
value
end
def write_reg(key, value)
keys = key.split("\\")
sub_key = keys.size > 1 ? keys[0..(keys.size - 2)].join("\\") : ""
target_key = keys.last
reg_key = "#{@win32_registry_base}#{sub_key.empty? ? '' : "\\#{sub_key}"}".freeze
begin
Win32::Registry::HKEY_CURRENT_USER.open(reg_key, Win32::Registry::KEY_WRITE) do |reg|
reg[target_key] = value
end
rescue Win32::Registry::Error
result = Win32::Registry::HKEY_CURRENT_USER.create(reg_key)
result.write_i(target_key, value)
end
end
end
end

189
lib/hardware_survey.rb Normal file
View File

@@ -0,0 +1,189 @@
class W3DHub
class HardwareSurvey
attr_reader :data
def initialize
@data = {
displays: [],
system: {
motherboard: {
manufacturer: "Unknown",
model: "Unknown",
bios_vendor: "Unknown",
bios_release_date: "Unknown",
bios_version: "Unknown"
},
operating_system: {
name: "Unknown",
build: "Unknown",
version: "Unknown",
edition: "Unknown"
},
cpus: [],
cpu_instruction_sets: {},
ram: 0,
gpus: []
}
}
# Hardware survey only works on Windows atm
if Gem::win_platform?
lib_dir = File.dirname($LOADED_FEATURES.find { |file| file.include?("gosu.so") })
SDL.load_lib("#{lib_dir}64/SDL2.dll")
else
SDL.load_lib("libSDL2")
end
query_displays
query_motherboard
query_operating_system
query_cpus
query_ram
query_gpus
@data.freeze
end
def query_displays
SDL.GetNumVideoDisplays.times do |d|
modes = []
refresh_rates = []
SDL.GetNumDisplayModes(d).times do |m|
mode = SDL::DisplayMode.new
SDL.GetDisplayMode(d, m, mode)
refresh_rates << mode[:refresh_rate]
modes << [mode[:w], mode[:h]]
end
@data[:displays] << {
name: SDL.GetDisplayName(d).read_string,
refresh_rates: refresh_rates.uniq.sort.reverse,
resolutions: modes.uniq.sort.reverse
}
end
end
def query_motherboard
return unless Gem::win_platform?
Win32::Registry::HKEY_LOCAL_MACHINE.open("HARDWARE\\DESCRIPTION\\System\\BIOS", Win32::Registry::KEY_READ) do |reg|
@data[:system][:motherboard][:manufacturer] = safe_reg(reg, "SystemManufacturer")
@data[:system][:motherboard][:model] = safe_reg(reg, "SystemProductName")
@data[:system][:motherboard][:bios_vendor] = safe_reg(reg, "BIOSVendor")
@data[:system][:motherboard][:bios_release_date] = safe_reg(reg, "BIOSReleaseDate")
@data[:system][:motherboard][:bios_version] = safe_reg(reg, "BIOSVersion")
end
rescue Win32::Registry::Error
@data[:system][:motherboard][:manufacturer] = "Unknown"
@data[:system][:motherboard][:model] = "Unknown"
@data[:system][:motherboard][:bios_vendor] = "Unknown"
@data[:system][:motherboard][:bios_release_date] = "Unknown"
@data[:system][:motherboard][:bios_version] = "Unknown"
end
def query_operating_system
return unless Gem::win_platform?
Win32::Registry::HKEY_LOCAL_MACHINE.open("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", Win32::Registry::KEY_READ) do |reg|
@data[:system][:operating_system][:name] = safe_reg(reg, "ProductName")
@data[:system][:operating_system][:build] = safe_reg(reg, "CurrentBuild")
@data[:system][:operating_system][:version] = safe_reg(reg, "DisplayVersion")
@data[:system][:operating_system][:edition] = safe_reg(reg, "EditionID")
end
rescue Win32::Registry::Error
@data[:system][:operating_system][:name] = "Unknown"
@data[:system][:operating_system][:build] = "Unknown"
@data[:system][:operating_system][:version] = "Unknown"
@data[:system][:operating_system][:edition] = "Unknown"
end
def query_cpus
if Gem::win_platform?
begin
Win32::Registry::HKEY_LOCAL_MACHINE.open("HARDWARE\\DESCRIPTION\\System\\CentralProcessor", Win32::Registry::KEY_READ) do |reg|
i = 0
reg.each_key do |key|
reg.open(key) do |cpu|
@data[:system][:cpus] << {
manufacturer: safe_reg(cpu, "VendorIdentifier", "Unknown"),
model: safe_reg(cpu, "ProcessorNameString").strip,
mhz: safe_reg(cpu, "~MHz"),
family: safe_reg(cpu, "Identifier")
}
i += 1
end
end
end
rescue Win32::Registry::Error
end
end
instruction_sets = %w[ HasRDTSC HasAltiVec HasMMX Has3DNow HasSSE HasSSE2 HasSSE3 HasSSE41 HasSSE42 HasAVX HasAVX2 HasAVX512F HasARMSIMD HasNEON ] # HasLSX HasLASX # These cause a crash atm
list = []
instruction_sets.each do |i|
if SDL.send(i).positive?
list << i.sub("Has", "")
end
@data[:system][:cpu_instruction_sets][:"#{i.sub("Has", "").downcase}"] = SDL.send(i).positive?
end
end
def query_ram
@data[:system][:ram] = SDL.GetSystemRAM
end
def query_gpus
return unless Gem::win_platform?
Win32::Registry::HKEY_LOCAL_MACHINE.open("SYSTEM\\ControlSet001\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}", Win32::Registry::KEY_READ) do |reg|
i = 0
reg.each_key do |key, _|
next unless key.start_with?("0")
reg.open(key) do |device|
vram = -1
begin
vram = device["HardwareInformation.qwMemorySize"].to_i
rescue Win32::Registry::Error, TypeError
begin
vram = device["HardwareInformation.MemorySize"].to_i
rescue Win32::Registry::Error, TypeError
vram = -1
end
end
next if vram.negative?
vram = vram / 1024.0 / 1024.0
@data[:system][:gpus] << {
manufacturer: safe_reg(device, "ProviderName"),
model: safe_reg(device, "DriverDesc"),
vram: vram.round,
driver_date: safe_reg(device, "DriverDate"),
driver_version: safe_reg(device, "DriverVersion")
}
i += 1
end
end
end
rescue Win32::Registry::Error
end
def safe_reg(reg, key, default_value = "Unknown")
reg[key]
rescue Win32::Registry::Error
default_value
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

@@ -6,6 +6,8 @@ class W3DHub
# https://github.com/TheUnstoppable/MixLibrary used for reference
class Mixer
DEFAULT_BUFFER_SIZE = 32_000_000
MIX1_HEADER = 0x3158494D
MIX2_HEADER = 0x3258494D
class MixParserException < RuntimeError; end
class MixFormatException < RuntimeError; end
@@ -203,8 +205,12 @@ class W3DHub
@buffer.pos = 0
@encrypted = false
# Valid header
if read_i32 == 0x3158494D
if (mime = read_i32) && (mime == MIX1_HEADER || mime == MIX2_HEADER)
@encrypted = mime == MIX2_HEADER
file_data_offset = read_i32
file_names_offset = read_i32
@@ -237,7 +243,7 @@ class W3DHub
@buffer.pos = pos
end
else
raise MixParserException, "Invalid MIX file"
raise MixParserException, "Invalid MIX file: Expected \"#{MIX1_HEADER}\" or \"#{MIX2_HEADER}\", got \"0x#{mime.to_s(16).upcase}\"\n(#{file_path})"
end
ensure
@@ -264,18 +270,24 @@ class W3DHub
buffer.strip
end
def encrypted?
@encrypted
end
end
class Writer
attr_reader :package
def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE)
def initialize(file_path:, package:, memory_buffer: false, buffer_size: DEFAULT_BUFFER_SIZE, encrypted: false)
@package = package
@buffer = MemoryBuffer.new(file_path: file_path, mode: :write, buffer_size: buffer_size)
@buffer.pos = 0
@buffer.write("MIX1")
@encrypted = encrypted
@buffer.write(encrypted? ? "MIX2" : "MIX1")
files = @package.files.sort { |a, b| a.file_crc <=> b.file_crc }
@@ -322,6 +334,10 @@ class W3DHub
def write_byte(byte)
@buffer.write([byte].pack("c"))
end
def encrypted?
@encrypted
end
end
# Eager loads patch file and streams target file metadata (doen't load target file data or generate CRCs)

View File

@@ -3,50 +3,53 @@ 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
stack(width: 1.0, height: 0.15) do
background 0xaa_252525
stack(width: 1.0) do
tagline "<b>Welcome to #{I18n.t(:app_name)}</b>"
para "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."
end
flow(width: 1.0, height: 0.15, margin_bottom: 24) do
icon_container_width = 0.37
flow(width: (1.0 - icon_container_width) / 2, height: 1.0) do
end
flow(width: 1.0, height: 64, margin_bottom: 24) do
flow(fill: true, height: 1.0)
flow(width: icon_container_width, height: 1.0) do
flow(width: 64 * 4 + (3 * 32), height: 1.0) do
image "#{GAME_ROOT_PATH}/media/icons/app.png", hover: { color: 0xaa_ffffff }, height: 1.0, tip: "#{I18n.t(:app_name)} Github Repository" do
Launchy.open("https://github.com/cyberarm/w3d_hub_linux_launcher")
W3DHub.url("https://github.com/cyberarm/w3d_hub_linux_launcher")
end
image "#{GAME_ROOT_PATH}/media/icons/w3dhub.png", hover: { color: 0xaa_ffffff }, height: 1.0, margin_left: 32, tip: "W3D Hub Forums" do
Launchy.open("https://w3dhub.com/forum/")
W3DHub.url("https://w3dhub.com/forum/")
end
image "#{GAME_ROOT_PATH}/media/social_media_icons/discord.png", hover: { color: 0xaa_ffffff }, height: 1.0, margin_left: 32, tip: "W3D Hub Discord Server" do
Launchy.open("https://discord.com/invite/GYhW7eV")
W3DHub.url("https://discord.com/invite/GYhW7eV")
end
image "#{GAME_ROOT_PATH}/media/social_media_icons/facebook.png", hover: { color: 0xaa_ffffff }, height: 1.0, margin_left: 32, tip: "W3D Hub Facebook Page" do
Launchy.open("https://www.facebook.com/w3dhub")
W3DHub.url("https://www.facebook.com/w3dhub")
end
end
flow(fill: true, height: 1.0)
end
stack(width: 1.0, height: 0.55) do
tagline "<b>Latest Updates</b>", height: 0.1
stack(width: 1.0, fill: true) do
tagline "<b>Latest Updates</b>"
@wd3hub_news_container = flow(width: 1.0, height: 0.9, padding: 8, scroll: true) do
@wd3hub_news_container = flow(width: 1.0, fill: true, padding: 8, scroll: true) do
end
end
stack(width: 1.0, height: 0.15, margin_top: 16) do
stack(width: 1.0, height: 72, margin_top: 16) do
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/") { Launchy.open("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") { Launchy.open("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
@@ -74,6 +77,30 @@ class W3DHub
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
BackgroundWorker.foreground_job(
-> { fetch_w3dhub_news },
lambda do |result|
if result
populate_w3dhub_news
Cache.release_net_lock(result)
end
end
)
end
end
def fetch_w3dhub_news
lock = Cache.acquire_net_lock("w3dhub_news")
return false unless lock
@@ -83,11 +110,12 @@ class W3DHub
return unless news
news.items[0..9].each do |item|
Cache.fetch(uri: item.image, async: false)
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
end
@w3dhub_news = news
@w3dhub_news_expires = Gosu.milliseconds + (60 * 60 * 1000) # 1 hour (in ms)
"w3dhub_news"
end
@@ -97,30 +125,62 @@ 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: 22 do
# W3DHub.url(item.uri)
# end
# para item.blurb.gsub(/\n+/, "\n").strip[0..180]
# end
# flow(width: 1.0) do
# para item.timestamp.strftime("%Y-%m-%d"), width: 0.499
# link I18n.t(:"games.read_more"), width: 0.5, text_align: :right, text_size: 22 do
# W3DHub.url(item.uri)
# end
# end
# end
# end
# end
feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item|
flow(width: 0.5, height: 128, margin: 4) do
# background 0x88_000000
image_path = Cache.path(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 0x44_000000
if File.exist?(path)
image path, height: 1.0, padding: 4
# Ensure the image file exists before trying to load it
if File.exist?(image_path)
image image_path, height: 1.0
else
image BLACK_IMAGE, height: 1.0, padding: 4
logger.warn("W3DHub::Community") { "Image not found in cache: #{image_path}" }
image BLACK_IMAGE, height: 1.0
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
Launchy.open(item.uri)
end
inscription item.blurb.gsub(/\n+/, "\n").strip[0..180]
end
stack(fill: true, height: 1.0, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(0xff_252525))) do
tagline "<b>#{item.title}</b>", width: 1.0
para item.blurb.gsub(/\n+/, "\n").strip[0..1024], fill: true
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
Launchy.open(item.uri)
flow(width: 1.0, height: 36, margin_top: 8) do
stack(fill: true, height: 1.0) do
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
W3DHub.url(item.uri)
end
end
end

View File

@@ -9,7 +9,11 @@ class W3DHub
unless task
body.clear do
tagline "No operations pending.", width: 1.0, text_align: :center, margin: 128
stack(width: 1.0, height: 1.0) do
background 0xaa_252525
tagline "No operations pending.", width: 1.0, text_align: :center, margin: 128
end
end
return
@@ -23,29 +27,33 @@ class W3DHub
body.clear do
stack(width: 1.0, height: 1.0) do
background 0xaa_252525
# 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.channel.current_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
@@ -58,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

@@ -3,48 +3,96 @@ class W3DHub
class Games < Page
def setup
@game_news ||= {}
@game_events ||= {}
@focused_game ||= Store.applications.games.find { |g| g.id == Store.settings[:last_selected_app] }
@focused_game ||= Store.applications.games.find { |g| g.id == "ren" }
@focused_channel ||= @focused_game.channels.find { |c| c.id == Store.settings[:last_selected_channel] }
@focused_channel ||= @focused_game.channels.first
body.clear do
# Games List
@games_list_container = stack(width: 0.15, height: 1.0, scroll: true) do
end
stack(width: 1.0, height: 1.0) do
# Games List
@games_list_container = flow(width: 1.0, height: 64, scroll: true, border_thickness_bottom: 1, border_color_bottom: W3DHub::BORDER_COLOR, padding_left: 32, padding_right: 32) do
end
# Game Menu
@game_page_container = stack(width: 0.85, height: 1.0) do
# Game Menu
@game_page_container = stack(width: 1.0, fill: true) do
end
end
end
# return if Store.offline_mode
populate_game_page(@focused_game, @focused_channel)
populate_games_list
end
def update
super
@game_news.each do |key, value|
next if key.end_with?("_expires")
if Gosu.milliseconds >= @game_news["#{key}_expires"]
@game_news.delete(key)
@game_news["#{key}_expires"] = Gosu.milliseconds + 30_000 # seconds
if @focused_game && @focused_game.id == key
@game_news_container.clear do
title I18n.t(:"games.fetching_news"), padding: 8
end
BackgroundWorker.foreground_job(
-> { fetch_game_news(@focused_game) },
lambda do |result|
if result
populate_game_news(@focused_game)
Cache.release_net_lock(result)
end
end
)
end
end
end
end
def populate_games_list
@games_list_container.clear do
background 0xff_121920
background 0xaa_121920
stack(width: 128, height: 1.0) do
flow(fill: true)
button "All Games" do
populate_all_games_view
end
flow(fill: true)
end
has_favorites = Store.settings[:favorites].size.positive?
Store.applications.games.each do |game|
next if has_favorites && !Store.application_manager.favorite?(game.id)
selected = game == @focused_game
game_button = stack(width: 1.0, border_thickness_left: 4,
border_color_left: selected ? 0xff_00acff : 0x00_000000,
game_button = stack(width: 64, height: 1.0, border_thickness_bottom: 4,
border_color_bottom: selected ? 0xff_0074e0 : 0x00_000000,
hover: { background: selected ? game.color : 0xff_444444 },
padding_top: 4, padding_bottom: 4) do
padding_left: 4, padding_right: 4, tip: game.name) do
background game.color if selected
flow(width: 1.0, height: 48) do
stack(width: 0.3) do
image "#{GAME_ROOT_PATH}/media/ui_icons/return.png", width: 1.0, color: Gosu::Color::GRAY if Store.application_manager.updateable?(game.id, game.channels.first.id)
image "#{GAME_ROOT_PATH}/media/ui_icons/import.png", width: 0.5, color: 0x88_ffffff unless Store.application_manager.installed?(game.id, game.channels.first.id)
end
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
image image_path, height: 48, color: Store.application_manager.installed?(game.id, game.channels.first.id) ? 0xff_ffffff : 0x88_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 game.channels.any? { |channel| Store.application_manager.updateable?(game.id, channel.id) }
end
inscription game.name, width: 1.0, text_align: :center
# para game.name, width: 1.0, text_align: :center
end
def game_button.hit_element?(x, y)
@@ -52,7 +100,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
@@ -67,121 +117,293 @@ class W3DHub
Store.settings[:last_selected_channel] = channel.id
@game_page_container.clear do
background game.color
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0xaa
# Release channel
flow(width: 1.0, height: 0.03) do
# background 0xff_444411
inscription I18n.t(:"games.channel")
list_box(items: game.channels.map(&:name), choose: channel.name, enabled: game.channels.count > 1,
margin_top: 0, margin_bottom: 0, width: 128,
padding_left: 1, padding_top: 1, padding_right: 1, padding_bottom: 1, text_size: 14) do |value|
populate_game_page(game, game.channels.find { |c| c.name == value })
end
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, height: 0.88) do
# background 0xff_9999ff
flow(width: 1.0, fill: true) do
# Game options
stack(width: 0.25, height: 1.0, padding: 8, scroll: true) do
# background 0xff_550055
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", "")
if Store.application_manager.installed?(game.id, channel.id)
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?
hash[I18n.t(:"games.game_modifications")] = { icon: "gear", enabled: true, block: proc { populate_game_modifications(game, channel) } }
if game.id != "ren"
hash[I18n.t(:"games.repair_installation")] = { icon: "wrench", block: proc { Store.application_manager.repair(game.id, channel.id) } }
hash[I18n.t(:"games.uninstall_game")] = { icon: "trashCan", block: proc { Store.application_manager.uninstall(game.id, channel.id) } }
if File.exist?(logo_image_path)
image logo_image_path, width: 1.0
else
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)
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?
# hash[I18n.t(:"games.game_modifications")] = { icon: "gear", enabled: true, block: proc { populate_game_modifications(game, channel) } }
# if game.id != "ren"
# hash[I18n.t(:"games.repair_installation")] = { icon: "wrench", block: proc { Store.application_manager.repair(game.id, channel.id) } }
# hash[I18n.t(:"games.uninstall_game")] = { icon: "trashCan", block: proc { Store.application_manager.uninstall(game.id, channel.id) } }
# end
hash[I18n.t(:"games.install_folder")] = { icon: nil, block: proc { Store.application_manager.show_folder(game.id, channel.id, :installation) } }
hash[I18n.t(:"games.user_data_folder")] = { icon: nil, block: proc { Store.application_manager.show_folder(game.id, channel.id, :user_data) } }
hash[I18n.t(:"games.view_screenshots")] = { icon: nil, block: proc { Store.application_manager.show_folder(game.id, channel.id, :screenshots) } }
}.each do |key, hash|
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: 22, enabled: hash.key?(:enabled) ? hash[:enabled] : true do
hash[:block]&.call
end
end
end
hash[I18n.t(:"games.install_folder")] = { icon: nil, block: proc { Store.application_manager.show_folder(game.id, channel.id, :installation) } }
hash[I18n.t(:"games.user_data_folder")] = { icon: nil, block: proc { Store.application_manager.show_folder(game.id, channel.id, :user_data) } }
hash[I18n.t(:"games.view_screenshots")] = { icon: nil, block: proc { Store.application_manager.show_folder(game.id, channel.id, :screenshots) } }
}.each do |key, hash|
end
game.web_links.each do |item|
flow(width: 1.0, height: 22, margin_bottom: 8) do
image "#{GAME_ROOT_PATH}/media/ui_icons/#{hash[:icon]}.png", width: 0.11 if hash[:icon]
image EMPTY_IMAGE, width: 0.11 unless hash[:icon]
link key, text_size: 18, enabled: hash.key?(:enabled) ? hash[:enabled] : true do
hash[:block]&.call
image "#{GAME_ROOT_PATH}/media/ui_icons/share1.png", width: 24
link item.name, text_size: 22 do
W3DHub.url(item.uri)
end
end
end
end
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: 0.11
link item.name, text_size: 18 do
Launchy.open(item.uri)
if game.channels.count > 1
# Release channel
para I18n.t(:"games.game_version"), width: 1.0, text_align: :center
flow(width: 1.0, height: 48) do
# background 0xff_444411
list_box(width: 1.0, items: game.channels.map(&:name), choose: channel.name, enabled: game.channels.count > 1) do |value|
populate_game_page(game, game.channels.find { |c| c.name == value })
end
end
end
# Play buttons
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: 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: 30, enabled: !play_now_server.nil? do
Store.application_manager.play_now(game.id, channel.id)
end
play_now_button.subscribe(:enter) do |btn|
server = Store.application_manager.play_now_server(game.id, channel.id)
btn.enabled = !server.nil?
btn.instance_variable_set(:"@tip", server ? "#{server.status.name} [#{server.status.player_count}/#{server.status.max_players}]" : "")
end
end
button get_image("#{GAME_ROOT_PATH}/media/ui_icons/singleplayer.png"), tip: I18n.t(:"interface.single_player"), image_height: 32, margin_left: 0 do
Store.application_manager.run(game.id, channel.id)
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|
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
if W3DHub.unix?
menu_item(I18n.t(:"games.wine_configuration")) do
Store.application_manager.wine_configuration(game.id, channel.id)
end
end
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: 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: 30, enabled: !installing do
Store.application_manager.import(game.id, channel.id)
end
end
end
end
# Game News
@game_news_container = flow(width: 0.75, height: 1.0, padding: 8, scroll: true) do
# background 0xff_005500
end
end
# Play buttons
flow(width: 1.0, height: 0.09, 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>", margin_left: 24, **UPDATE_BUTTON do
Store.application_manager.update(game.id, channel.id)
end
else
button "<b>#{I18n.t(:"interface.play_now")}</b>", margin_left: 24 do
Store.application_manager.play_now(game.id, channel.id)
stack(fill: true, height: 1.0) do
# Game Description
if false # description
# 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
end
end
button "<b>#{I18n.t(:"interface.single_player")}</b>", margin_left: 24 do
Store.application_manager.run(game.id, channel.id)
end
else
installing = Store.application_manager.task?(:installer, game.id, channel.id)
unless game.id == "ren"
button "<b>#{I18n.t(:"interface.install")}</b>", margin_left: 24, enabled: !installing do |button|
button.enabled = false
@import_button.enabled = false
Store.application_manager.install(game.id, channel.id)
end
# Game Events
@game_events_container = flow(width: 1.0, height: 128, padding: 8, visible: false) do
end
@import_button = button "<b>#{I18n.t(:"interface.import")}</b>", margin_left: 24, enabled: !installing do
Store.application_manager.import(game.id, channel.id)
# Game News
@game_news_container = flow(width: 1.0, fill: true, padding: 8, scroll: true) do
# background 0xff_005500
end
end
end
end
return if Cache.net_lock?("game_news_#{game.id}")
return if Store.offline_mode
if @game_news[game.id]
populate_game_news(game)
else
@game_news_container.clear do
title I18n.t(:"games.fetching_news"), padding: 8
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
BackgroundWorker.foreground_job(
-> { fetch_game_news(game) },
lambda do |result|
if result
populate_game_news(game)
Cache.release_net_lock(result)
unless Cache.net_lock?("game_events_#{game.id}")
if @game_news[game.id]
populate_game_news(game)
else
@game_news_container.clear do
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
)
end
end
end
def populate_all_games_view
@game_page_container.clear do
@focused_game = nil
@focused_channel = nil
populate_games_list
flow(width: 1.0, height: 1.0) do
games_view_container = nil
# 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
flow(width: 1.0, height: 48) do
button "All Games", width: 280 do
# games_view_container.clear
end
tagline Store.applications.games.count.to_s, fill: true, text_align: :right
end
flow(width: 1.0, height: 48, margin_top: 8) do
button "Installed", enabled: false, width: 280
tagline "0", fill: true, text_align: :right
end
flow(width: 1.0, height: 48, margin_top: 8) do
button "Favorites", enabled: false, width: 280
tagline Store.settings[:favorites].count, fill: true, text_align: :right
end
end
)
# Games list
games_view_container = stack(fill: true, height: 1.0, padding: 8, margin: 8) do
title "All Games"
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: 28, padding: 8) do
para "Favorite", fill: true
toggle_button checked: Store.application_manager.favorite?(game.id), height: 18, padding_top: 3, padding_right: 3, padding_bottom: 3, padding_left: 3 do |btn|
Store.application_manager.favorive(game.id, btn.value)
Store.settings.save_settings
populate_games_list
end
end
container = stack(fill: true, width: 1.0, padding: 8) do
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
flow(fill: true)
end
caption game.name, margin_top: 8
end
def container.hit_element?(x, y)
return unless hit?(x, y)
self
end
container.subscribe(:clicked_left_mouse_button) do |element|
populate_game_page(game, game.channels.first)
populate_games_list
end
container.subscribe(:enter) do |element|
element.background = 0x88_454545
end
end
end
end
end
end
end
end
@@ -194,11 +416,12 @@ class W3DHub
return false unless news
news.items[0..9].each do |item|
Cache.fetch(uri: item.image, async: false)
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
end
@game_news[game.id] = news
@game_news["#{game.id}_expires"] = Gosu.milliseconds + (60 * 60 * 1000) # 1 hour (in ms)
"game_news_#{game.id}"
end
@@ -207,31 +430,55 @@ class W3DHub
return unless @focused_game == game
if (feed = @game_news[game.id])
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0xaa
@game_news_container.clear do
# Patch Notes
if false # Patch notes
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|
flow(width: 0.5, height: 128, margin: 4) do
# background 0x88_000000
image_path = Cache.path(item.image)
path = Cache.path(item.image)
if File.exist?(path)
image path, width: 0.4, padding: 4
else
image BLACK_IMAGE, width: 0.4, padding: 4
flow(width: 1.0, max_width: 869, height: 200, margin: 8, background: game_color, border_thickness: 1, border_color: lighten(Gosu::Color.new(game.color))) do
if File.file?(image_path)
image image_path, height: 1.0
end
stack(width: 0.6, height: 1.0) do
stack(width: 1.0, height: 112) do
link "<b>#{item.title}</b>", text_size: 18 do
Launchy.open(item.uri)
end
inscription item.blurb.gsub(/\n+/, "\n").strip[0..180]
end
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
para item.blurb.gsub(/\n+/, "\n").strip[0..1024], fill: true
flow(width: 1.0) do
inscription item.timestamp.strftime("%Y-%m-%d"), width: 0.5
link I18n.t(:"games.read_more"), width: 0.5, text_align: :right, text_size: 14 do
Launchy.open(item.uri)
flow(width: 1.0, height: 36, margin_top: 8) do
stack(fill: true, height: 1.0) do
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, margin_left: 0, margin_top: 0, margin_bottom: 0, margin_right: 0 do
W3DHub.url(item.uri)
end
end
end
@@ -241,6 +488,43 @@ class W3DHub
end
end
def fetch_game_events(game)
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
return false unless events
@game_events[game.id] = events
"game_events_#{game.id}"
end
def populate_game_events(game)
return unless @focused_game == game
if (events = @game_events[game.id])
events = events.select { |e| e.end_time > Time.now.utc }
@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 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
end
end
def populate_game_modifications(application, channel)
@game_news_container.clear do
([
@@ -265,13 +549,13 @@ class W3DHub
stack(width: 0.75, height: 1.0) do
stack(width: 1.0, height: 128 - 28) do
link(mod[:name]) { Launchy.open(mod[:url]) }
inscription "Author: #{mod[:author]} | #{mod[:type]} | #{mod[:subtype]}"
link(mod[:name]) { W3DHub.url(mod[:url]) }
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

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,19 +18,19 @@ 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
# Todo lock whole UI until response or timeout
# TODO: lock whole UI until response or timeout
# Do network stuff
@@ -46,8 +44,10 @@ class W3DHub
Store.settings[:account][:data] = account
Store.settings.save_settings
Cache.fetch(account.avatar_uri, force_fetch: true, async: false) if account
applications = Api.applications if account
if account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
applications = Api._applications
end
end
[account, applications]
@@ -81,7 +81,7 @@ class W3DHub
if Store.account
BackgroundWorker.foreground_job(
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false) },
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false, backend: :w3dhub) },
->(result) {
populate_account_info
page(W3DHub::Pages::Games)
@@ -90,21 +90,61 @@ class W3DHub
end
end
def populate_account_info
@host.instance_variable_get(:"@account_container").clear do
stack(width: 0.7, height: 1.0) do
# background 0xff_222222
tagline "<b>#{Store.account.username}</b>"
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
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
Launchy.open("https://secure.w3dhub.com/forum/index.php?showuser=#{Store.account.id}")
end
def populate_account_info
return if Store.offline_mode
@host.instance_variable_get(:"@account_container").clear do
flow(fill: true, height: 1.0) do
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
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
image Cache.path(Store.account.avatar_uri), height: 1.0
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
@@ -114,7 +154,7 @@ class W3DHub
Store.account = nil
BackgroundWorker.foreground_job(
-> { Api.applications },
-> { Api._applications },
lambda do |applications|
if applications
Store.applications = applications
@@ -123,14 +163,13 @@ class W3DHub
end
@host.instance_variable_get(:"@account_container").clear do
stack(width: 0.7, height: 1.0) do
# background 0xff_222222
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
Launchy.open("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
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

View File

@@ -8,30 +8,46 @@ class W3DHub
@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"
Store.applications.games.each { |game| @filters[game.id.to_sym] = true if @filters[game.id.to_sym].nil? }
@ping_icons = {}
generate_ping_icons
body.clear do
stack(width: 1.0, height: 1.0, padding: 8) do
stack(width: 1.0, height: 0.04) do
inscription "<b>#{I18n.t(:"server_browser.filters")}</b>"
background 0xaa_252525
stack(width: 1.0, height: 22) do
para "<b>#{I18n.t(:"server_browser.filters")}</b>", font: BOLD_FONT
end
flow(width: 1.0, height: 0.06) do
flow(width: 0.75, height: 1.0) 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", "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
populate_server_list
end
end
flow(fill: true, height: 1.0) do
@filters.each do |app_id, enabled|
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,
color: enabled ? 0xff_ffffff : 0xff_444444, hover: { border_color_bottom: 0xff_aaaaaa }, margin_right: 16 do |img|
color: enabled ? 0xff_ffffff : 0xff_444444, hover: { border_color_bottom: 0xff_aaaaaa }, margin_left: 16 do |img|
@filters[app_id] = !@filters[app_id]
Store.settings[:server_list_filters] = @filters
Store.settings.save_settings
@@ -47,71 +63,68 @@ class W3DHub
populate_server_list
end
end
para I18n.t(:"server_browser.region")
list_box items: ["Any", "North America", "Europe"], choose: Store.settings[:server_list_region], width: 0.2, height: 1.0 do |value|
@filter_region = value
Store.settings[:server_list_region] = @filter_region
Store.settings.save_settings
populate_server_list
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
end
flow(width: 0.249, height: 1.0) do
inscription "#{I18n.t(:"server_browser.nickname")}:", width: 0.32
@nickname_label = inscription "#{Store.settings[:server_list_username]}", width: 0.6
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)
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
prompt_for_nickname(
W3DHub.prompt_for_nickname(
accept_callback: proc do |entry|
@nickname_label.value = entry
Store.settings[:server_list_username] = entry
Store.settings.save_settings
container.recalculate
container.recalculate
container.recalculate
end
)
end
end
end
flow(width: 1.0, height: 0.9, margin_top: 16) do
stack(width: 0.62, height: 1.0) do
flow(width: 1.0, fill: true, margin_top: 16) do
stack(fill: true, height: 1.0) do
# Icon
# Hostname
# Current Map
# Players
# Ping
flow(width: 1.0, height: 0.05) do
stack(width: 0.08) do
flow(width: 1.0, height: 24) do
stack(width: 56, padding: 4) do
end
stack(width: 0.50, height: 1.0) do
para "<b>#{I18n.t(:"server_browser.hostname")}</b>", text_wrap: :none, width: 1.0
stack(width: 0.45, height: 1.0) do
para "<b>#{I18n.t(:"server_browser.hostname")}</b>", text_wrap: :none, width: 1.0, font: BOLD_FONT
end
flow(width: 0.24, height: 1.0) do
para "<b>#{I18n.t(:"server_browser.current_map")}</b>", text_wrap: :none, width: 1.0
flow(fill: true, height: 1.0) do
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: 0.06) 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
@server_list_container = stack(width: 1.0, height: 0.95, scroll: true) do
@server_list_container = stack(width: 1.0, fill: true, scroll: true) do
para I18n.t(:"server_browser.fetching_server_list")
end
end
@game_server_info_container = stack(width: 0.38, height: 1.0) do
@game_server_info_container = stack(min_width: 372, width: 0.38, max_width: 512, height: 1.0) do
para I18n.t(:"server_browser.no_server_selected"), width: 1.0, text_align: :center
end
end
@@ -128,7 +141,8 @@ class W3DHub
if @refresh_server_list && Gosu.milliseconds >= @refresh_server_list
@refresh_server_list = nil
populate_server_list
# populate_server_list
reorder_server_list
if @selected_server&.id == @refresh_server&.id
if @refresh_server
@@ -144,16 +158,129 @@ class W3DHub
end
end
def refresh_server_list(server)
def generate_ping_icons
signal3 = get_image("#{GAME_ROOT_PATH}/media/ui_icons/signal3.png")
signal2 = get_image("#{GAME_ROOT_PATH}/media/ui_icons/signal2.png")
signal1 = get_image("#{GAME_ROOT_PATH}/media/ui_icons/signal1.png")
question = get_image("#{GAME_ROOT_PATH}/media/ui_icons/question.png")
good = Gosu.render(signal3.width, signal3.height) do
signal3.draw(0, 0, 0, 1, 1, 0xff_008000)
end
fair = Gosu.render(signal3.width, signal3.height) do
signal3.draw(0, 0, 0, 1, 1, 0xff_444444)
signal2.draw(0, 0, 0, 1, 1, 0xff_804000)
end
poor = Gosu.render(signal3.width, signal3.height) do
signal3.draw(0, 0, 0, 1, 1, 0xff_444444)
signal1.draw(0, 0, 0, 1, 1, 0xff_800000)
end
bad = Gosu.render(signal3.width, signal3.height) do
signal3.draw(0, 0, 0, 1, 1, 0xff_444444)
end
unknown = Gosu.render(signal3.width, signal3.height) do
signal3.draw(0, 0, 0, 1, 1, 0xff_222222)
question.draw(0, 0, 0, 1, 1, 0xff_888888)
end
@ping_icons[:good] = good
@ping_icons[:fair] = fair
@ping_icons[:poor] = poor
@ping_icons[:bad] = bad
@ping_icons[:unknown] = unknown
end
def ping_icon(server)
case server.ping
when 0..150
@ping_icons[:good]
when 151..200
@ping_icons[:fair]
when 201..1_000
@ping_icons[:poor]
when 1_001..5_000
@ping_icons[:bad]
else
@ping_icons[:unknown]
end
end
def ping_tip(server)
server.ping == W3DHub::Api::ServerListServer::NO_OR_BAD_PING ? "Ping failed" : "Ping #{server.ping}ms"
end
def find_element_by_tag(container, tag, list = [])
return unless container
container.children.each do |child|
list << child if child.style.tag == tag
find_element_by_tag(child, tag, list) if child.is_a?(CyberarmEngine::Element::Container)
end
return list.first
end
def refresh_server_list(server, mode = :update) # :remove, :refresh_all
if mode == :refresh_all
populate_server_list
return
end
@refresh_server_list = Gosu.milliseconds + 3_000
@refresh_server = 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)
server_name = find_element_by_tag(server_container, :server_name)
server_channel = find_element_by_tag(server_container, :server_channel)
server_region = find_element_by_tag(server_container, :server_region)
server_map = find_element_by_tag(server_container, :server_map)
player_count = find_element_by_tag(server_container, :player_count)
server_ping = find_element_by_tag(server_container, :ping)
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)
container = find_element_by_tag(@server_list_container, server.id)
if container
ping_image = find_element_by_tag(container, :ping)
if ping_image
ping_image.value = ping_icon(server)
ping_image.parent.parent.tip = ping_tip(server)
end
end
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
@@ -161,97 +288,112 @@ class W3DHub
server_container.style.active[:background] = @selected_color
end
def reorder_server_list
@server_list_container.children.sort_by! do |child|
s = Store.server_list.find { |s| s.id == child.style.tag }
[s.status.player_count, -s.ping]
end.reverse!.each_with_index do |child, i|
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 }) do
background 0xff_333333 if i.odd?
image game_icon(server), width: 0.08, padding: 4
stack(width: 0.45, height: 1.0) do
inscription "<b>#{server&.status&.name}</b>"
flow(width: 1.0, height: 1.0) do
inscription server.channel, margin_right: 64, text_size: 14
inscription server.region, text_size: 14
end
end
flow(width: 0.30, height: 1.0) do
inscription "#{server&.status&.map}"
end
flow(width: 0.1, height: 1.0) do
inscription "#{server&.status&.player_count}/#{server&.status&.max_players}"
end
# case rand(0..478)
# when 0..60
# image "#{GAME_ROOT_PATH}/media/ui_icons/signal3.png", width: 0.05, color: 0xff_008000
# when 61..160
# image "#{GAME_ROOT_PATH}/media/ui_icons/signal2.png", width: 0.05, color: 0xff_804000
# else
# image "#{GAME_ROOT_PATH}/media/ui_icons/signal1.png", width: 0.05, color: 0xff_800000
# end
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", width: 0.05, color: 0xff_444444
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 Store.application_manager.channel_name(server.game, server.channel).to_s, width: 172, 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
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
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: 0.3) do
flow(width: 1.0, height: 0.2) do
image game_icon(server), width: 0.05
tagline server.status.name, width: 0.949, text_wrap: :none
stack(width: 1.0, height: 208) do
flow(width: 1.0, height: 34) do
flow(fill: true)
image game_icon(server), height: 1.0
title server.status.name[0..30], text_wrap: :none
flow(fill: true)
end
stack(width: 1.0, height: 0.25) 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 : {}
channel = Store.application_manager.channel(server.game, server.channel)
style = ((channel && channel.user_level.downcase.strip == "public") || server.channel == "release") ? {} : TESTING_BUTTON
button "<b>#{I18n.t(:"server_browser.join_server")}</b>", margin_left: 96, enabled: (game_installed && !game_updatable), **style do
flow(fill: true)
button "<b>#{I18n.t(:"server_browser.join_server")}</b>", enabled: (game_installed && !game_updatable), **style do
# Check for nickname
# prompt for nickname
# !abort unless nickname set
@@ -259,98 +401,128 @@ class W3DHub
# prompt for password
# Launch game
if Store.settings[:server_list_username].to_s.length.zero?
prompt_for_nickname(
W3DHub.prompt_for_nickname(
accept_callback: proc do |entry|
@nickname_label.value = entry
Store.settings[:server_list_username] = entry
Store.settings.save_settings
if server.status.password
prompt_for_password(
W3DHub.prompt_for_password(
accept_callback: proc do |password|
join_server(server, password)
W3DHub.join_server(server: server, password: password)
end
)
else
join_server(server, nil)
W3DHub.join_server(server: server)
end
end
)
else
if server.status.password
prompt_for_password(
W3DHub.prompt_for_password(
accept_callback: proc do |password|
join_server(server, password)
W3DHub.join_server(server: server, password: password)
end
)
else
join_server(server, nil)
W3DHub.join_server(server: server)
end
end
end
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
stack(width: 1.0, height: 0.55, margin_top: 16) do
flow(width: 1.0, height: 0.33) 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
# Server Info
stack(width: 1.0, fill: true, margin_bottom: 16) do
flow(width: 1.0) do
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, height: 0.33) 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
flow(width: 1.0) do
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, height: 0.33) 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
flow(width: 1.0) do
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
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
game_balance = server_game_balance(server)
flow(width: 1.0, height: 0.1, border_thickness_bottom: 2, border_color_bottom: 0x44_ffffff) do
stack(width: 0.4, 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 game_balance[:team_0_score].to_i.to_s, width: 1.0, text_align: :center
# Game score and balance display
flow(width: 1.0, height: 52, border_thickness_bottom: 2, border_color_bottom: 0x44_ffffff) do
stack(fill: true, height: 1.0) do
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
stack(width: 0.2, height: 1.0) do
image game_balance[:icon], height: 0.5, margin_left: 20, tip: game_balance[:message], color: game_balance[:color]
flow(width: 1.0, height: 0.5) do
flow(fill: true)
image game_balance[:icon], height: 1.0, tip: game_balance[:message], color: game_balance[:color]
flow(fill: true)
end
para game_balance[:ratio].round(2).to_s, width: 1.0, text_align: :center
end
stack(width: 0.4, 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 game_balance[:team_1_score].to_i.to_s, width: 1.0, text_align: :center
stack(fill: true, height: 1.0) do
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
flow(width: 1.0, height: 0.60, scroll: true) do
# Team roster
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 do |player|
flow(width: 1.0, height: 18) 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: 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 "#{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
end
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 do |player|
flow(width: 1.0, height: 18) 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: 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 "#{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
@@ -368,7 +540,7 @@ class W3DHub
end
def game_icon(server)
image_path = File.exist?("#{GAME_ROOT_PATH}/media/icons/#{server.game.nil? ? 'ren' : server.game}.png") ? "#{GAME_ROOT_PATH}/media/icons/#{server.game.nil? ? 'ren' : server.game}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
image_path = File.exist?("#{CACHE_PATH}/#{server.game.nil? ? 'ren' : server.game}.png") ? "#{CACHE_PATH}/#{server.game.nil? ? 'ren' : server.game}.png" : "#{GAME_ROOT_PATH}/media/icons/default_icon.png"
if server.status.password
@server_locked_icons[server.game] ||= Gosu.render(96, 96) do
@@ -389,7 +561,6 @@ class W3DHub
end
def server_game_balance(server)
data = {
icon: BLACK_IMAGE,
color: 0xff_ffffff,
@@ -415,72 +586,43 @@ class W3DHub
data[:team_0_score] = team_0_score
data[:team_1_score] = team_1_score
data[:icon] = if server.status.players.size < 20 && server.game != "ren"
data[:color] = 0xff_600000
data[:message] = "Too few players for a balanced game"
"#{GAME_ROOT_PATH}/media/ui_icons/cross.png"
elsif team_0_score + team_1_score < 2_500
data[:message] = "Score to low to estimate game balance"
data[:color] = 0xff_444444
"#{GAME_ROOT_PATH}/media/ui_icons/question.png"
elsif ratio.between?(0.75, 1.25)
data[:message] = "Game seems balanced based on score"
data[:color] = 0xff_008000
"#{GAME_ROOT_PATH}/media/ui_icons/checkmark.png"
elsif ratio < 0.75
data[:color] = 0xff_dd8800
data[:message] = "#{server.status.teams[0].name} is winning significantly"
"#{GAME_ROOT_PATH}/media/ui_icons/arrowRight.png"
else
data[:color] = 0xff_dd8800
data[:message] = "#{server.status.teams[1].name} is winning significantly"
"#{GAME_ROOT_PATH}/media/ui_icons/arrowLeft.png"
end
data[:icon] = if server.status.players.size < 20 && server.game != "ren"
data[:color] = 0xff_600000
data[:message] = "Too few players for a balanced game"
"#{GAME_ROOT_PATH}/media/ui_icons/cross.png"
elsif team_0_score + team_1_score < 2_500
data[:message] = "Score to low to estimate game balance"
data[:color] = 0xff_444444
"#{GAME_ROOT_PATH}/media/ui_icons/question.png"
elsif ratio.between?(0.75, 1.25)
data[:message] = "Game seems balanced based on score"
data[:color] = 0xff_008000
"#{GAME_ROOT_PATH}/media/ui_icons/checkmark.png"
elsif ratio < 0.75
data[:color] = 0xff_dd8800
data[:message] = "#{server.status.teams[0].name} is winning significantly"
"#{GAME_ROOT_PATH}/media/ui_icons/arrowRight.png"
else
data[:color] = 0xff_dd8800
data[:message] = "#{server.status.teams[1].name} is winning significantly"
"#{GAME_ROOT_PATH}/media/ui_icons/arrowLeft.png"
end
data
end
def prompt_for_nickname(accept_callback: nil, cancel_callback: nil)
push_state(
W3DHub::States::PromptDialog,
title: I18n.t(:"server_browser.set_nickname"),
message: I18n.t(:"server_browser.set_nickname_message"),
prefill: Store.settings[:server_list_username],
accept_callback: accept_callback,
cancel_callback: cancel_callback,
# See: https://gitlab.com/danpaul88/brenbot/-/blob/master/Source/renlog.pm#L136-175
valid_callback: proc do |entry|
entry.length > 1 && entry.length < 30 && (entry =~ /(:|!|&|%| )/i).nil? &&
(entry =~ /[\001\002\037]/).nil? && (entry =~ /\\/).nil?
end
)
def formatted_score(int)
int.to_s.reverse.scan(/.{1,3}/).join(",").reverse
end
def prompt_for_password(accept_callback: nil, cancel_callback: nil)
push_state(
W3DHub::States::PromptDialog,
title: I18n.t(:"server_browser.enter_password"),
message: I18n.t(:"server_browser.enter_password_message"),
input_type: :password,
accept_callback: accept_callback,
cancel_callback: cancel_callback,
valid_callback: proc { |entry| entry.length.positive? }
)
end
def formatted_rentime(time)
range = Time.now - Time.parse(time)
def join_server(server, password)
if (
(server.status.password && password.length.positive?) ||
!server.status.password) &&
Store.settings[:server_list_username].to_s.length.positive?
hours = range / 60.0 / 60.0 / 24.0
minutes = (range / 60.0) % 59
seconds = range % 59
Store.application_manager.join_server(
server.game,
server.channel, server, password
)
else
window.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?")
end
format("%02d:%02d:%02d", hours, minutes, seconds)
end
end
end

View File

@@ -3,80 +3,64 @@ class W3DHub
class Settings < Page
def setup
body.clear do
stack(width: 1.0, height: 1.0, padding: 16, scroll: true) do
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, height: 1.0, padding: 16) do
background 0xaa_252525
stack(width: 1.0, fill: true, max_width: 720, h_align: :center, scroll: true) do
stack(width: 1.0, height: 112) do
tagline "Launcher Language"
@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
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"
stack(width: 1.0, height: 200, margin_top: 16) do
tagline "Launcher Directories"
caption "Applications Install Directory", margin_left: 16
flow(width: 1.0, fill: true, 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, fill: true, margin_left: 16) do
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
button "Browse...", width: 128, tip: "Browse for package cache directory" do
path = W3DHub.ask_folder
@package_cache_dir_input.value = path unless path.empty?
end
end
end
flow(width: 1.0, margin_top: 16) do
para "<b>Package Cache Folder</b>", width: 0.249, margin_left: 32, margin_top: 12
if W3DHub.unix?
stack(width: 1.0, height: 224, margin_top: 16) do
tagline "Wine - Windows compatibility layer"
caption "Wine Command", margin_left: 16
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0, margin_left: 16
para "Command to use to for Windows compatiblity layer.", margin_left: 16
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"
caption "Wine Prefix", margin_left: 16, margin_top: 16
flow(width: 1.0, height: 48, margin_left: 16) do
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix], enabled: false
para "Whether each game gets its own prefix. Uses global/default prefix by default."
end
end
end
end
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
flow(width: 256, height: 64, h_align: :center, margin_top: 16) do
button "Save", width: 1.0 do
save_settings!
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
flow(fill: true)
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_input] = @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)}", **DANGEROUS_BUTTON) do
# TODO.
end
end
end
@@ -84,11 +68,11 @@ class W3DHub
def language_code(string)
case string.downcase.strip
when "german"
when "deutsch"
"de"
when "french"
when "français"
"fr"
when "spanish"
when "español"
"es"
else
"en"
@@ -100,11 +84,41 @@ class W3DHub
when "en"
"English"
when "de"
"German"
"Deutsch"
when "fr"
"French"
"Français"
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_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
end
end

View File

@@ -2,7 +2,7 @@ class W3DHub
class Settings
def self.defaults
{
language: Gosu.user_languages.first.split("_").first,
language: Gosu.user_languages.first&.split("_")&.first || "en",
app_install_dir: default_app_install_dir,
package_cache_dir: default_package_cache_dir,
parallel_downloads: 4,
@@ -15,6 +15,8 @@ class W3DHub
account: {},
applications: {},
games: {},
favorites: {},
app_order: {},
last_selected_app: "ren",
last_selected_channel: "release"
}
@@ -69,5 +71,15 @@ class W3DHub
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,36 +8,40 @@ 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: 0xff_aaaaaa) do
stack(width: 1.0, height: 0.925) 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
@progressbar = progress height: 0.025, width: 1.0
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
flow(width: 1.0, height: 0.05, 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
@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
@@ -50,7 +54,39 @@ class W3DHub
@progressbar.value = @fraction
push_state(States::Interface) if @progressbar.value >= 1.0 && @task_index == @tasks.size
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
return if @offline_mode
task = @tasks[@tasks.keys[@task_index]]
@@ -62,6 +98,10 @@ class W3DHub
@task_index += 1 if @tasks.dig(@tasks.keys[@task_index], :complete)
end
def needs_repaint?
true
end
def refresh_user_token
if Store.settings[:account, :data]
account = Api::Account.new(Store.settings[:account, :data], {})
@@ -70,7 +110,7 @@ class W3DHub
logger.info(LOG_TAG) { "Refreshing user login..." }
# TODO: Check without network
Api.on_fiber(:refresh_user_login, account.refresh_token) do |refreshed_account|
Api.on_thread(:refresh_user_login, account.refresh_token) do |refreshed_account|
update_account_data(refreshed_account)
end
@@ -89,7 +129,7 @@ class W3DHub
Store.settings[:account][:data] = account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false)
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
else
Store.settings[:account] = {}
end
@@ -99,8 +139,43 @@ 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|
begin
Resolv.getaddress(key.to_s)
rescue => e
logger.error(LOG_TAG) {"Failed to resolve hostname: #{key.to_s}"}
logger.error(LOG_TAG) {e}
push_state(
ConfirmDialog,
title: "DNS Resolution Failure",
message: "Failed to resolve: #{key.to_s}\n\nTry disabling VPN or proxy if in use.\n\n\nContinue offline?",
cancel_callback: ->() { window.close },
accept_callback: ->() {
@offline_mode = true
Store.offline_mode = true
@tasks[:connectivity_check][:complete] = true
}
)
# Prevent task from being marked as completed
return false
end
end
@tasks[:connectivity_check][:complete] = true
end
def service_status
Api.on_fiber(:service_status) do |service_status|
Api.on_thread(:service_status) do |service_status|
@service_status = service_status
if @service_status
@@ -113,6 +188,36 @@ class W3DHub
@tasks[:service_status][:complete] = true
else
BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = I18n.t(:"boot.w3dhub_service_is_down") })
@tasks[:service_status][:complete] = true
@offline_mode = true
Store.offline_mode = true
end
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/Inq8/CAmod/releases/latest") do |response|
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
available_version = hash[:tag_name].downcase.sub("v", "")
pp Gem::Version.new(available_version) > Gem::Version.new(W3DHub::VERSION)
pp [Gem::Version.new(available_version), Gem::Version.new(W3DHub::VERSION)]
push_state(
LauncherUpdaterDialog,
release_data: hash,
available_version: available_version,
cancel_callback: -> { @tasks[:launcher_updater][:complete] = true },
accept_callback: -> { @tasks[:launcher_updater][:complete] = true }
)
else
# Failed to retrieve release data from github
log "Failed to retrieve release data from Github"
@tasks[:launcher_updater][:complete] = true
end
end
end
@@ -120,14 +225,17 @@ class W3DHub
def applications
@status_label.value = I18n.t(:"boot.checking_for_updates")
Api.on_fiber(:applications) do |applications|
Api.on_thread(:_applications) do |applications|
if applications
Store.applications = applications
Store.settings.save_application_cache(applications.data.to_json)
@tasks[:applications][:complete] = true
else
# FIXME: Failed to retreive!
BackgroundWorker.foreground_job(-> {}, ->(_){ @status_label.value = "FAILED TO RETREIVE APPS LIST" })
@offline_mode = true
Store.offline_mode = true
end
end
end
@@ -135,15 +243,21 @@ 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 = []
Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" }
end
Api.on_fiber(:package_details, packages) do |package_details|
Api.on_thread(:package_details, packages, :alt_w3dhub) do |package_details|
package_details ||= nil
package_details&.each do |package|
next if package.error?
path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
generated_icon_path = "#{GAME_ROOT_PATH}/media/icons/#{package.subcategory}.png"
generated_icon_path = "#{CACHE_PATH}/#{package.subcategory}.png"
regenerate = false
@@ -165,19 +279,101 @@ class W3DHub
end
end
def app_logos_and_backgrounds
return unless Store.applications
@status_label.value = "Retrieving application image assets, this might take a moment..." # I18n.t(:"boot.checking_for_updates")
packages = []
Store.applications.games.each do |app|
packages << { category: app.category, subcategory: app.id, name: "logo.png", version: "" }
packages << { category: app.category, subcategory: app.id, name: "background.png", version: "" }
end
Api.on_thread(:package_details, packages, :alt_w3dhub) do |package_details|
package_details ||= nil
package_details&.each do |package|
next if package.error?
package_cache_path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
missing_or_broken_image = File.exist?(package_cache_path) ? Digest::SHA256.new.hexdigest(File.binread(package_cache_path)).upcase != package.checksum.upcase : true
Cache.fetch_package(package, proc {}) if missing_or_broken_image
end
@tasks[:app_logos_and_backgrounds][:complete] = true
end
end
def server_list
@status_label.value = I18n.t(:"server_browser.fetching_server_list")
Api.on_fiber(:server_list, 2) do |list|
Store.server_list = list.sort_by! { |s| s&.status&.players&.size }.reverse if list
Api.on_thread(:server_list, 2) do |list|
if list
Store.server_list = list.sort_by! { |s| s&.status&.players&.size }.reverse
Store.server_list_last_fetch = Gosu.milliseconds
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance
Api::ServerListUpdater.instance
list.each do |server|
server.send_ping(true)
end
else
Store.server_list = []
Store.server_list_last_fetch = Gosu.milliseconds
end
@tasks[:server_list][:complete] = true
end
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: []
}
Store.settings[:games].each do |key, game|
app_id, channel_id = key.to_s.split("_")
app = hash[:applications].find { |a| a[:id] == app_id }
app_in_array = !app.nil?
app ||= {
id: app_id,
name: game[:name],
type: "",
category: "games",
"studio-id": "",
channels: [],
"web-links": [],
"extended-data": [
{ name: "colour", value: game[:colour] },
{ name: "usesEngineCfg", value: game[:uses_engine_cfg] },
]
}
channel = {
id: channel_id,
name: channel_id,
"user-level": "",
"current-version": game[:installed_version]
}
app[:channels] << channel
hash[:applications] << app unless app_in_array
end
Store.applications = Api::Applications.new(hash.to_json) unless hash[:applications].empty?
end
end
end
end

View File

@@ -1,49 +0,0 @@
class W3DHub
class States
class ConfirmDialog < CyberarmEngine::GuiState
def setup
window.show_cursor = true
theme(W3DHub::THEME)
background 0xee_444444
stack(width: 1.0, height: 1.0, margin: 128, background: 0xee_222222) do
flow(width: 1.0, height: 0.1, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", width: 0.04, align: :center, color: 0xaa_ff0000
tagline "<b>#{@options[:title]}</b>", width: 0.9, text_align: :center
end
stack(width: 1.0, height: 0.78, padding: 16) do
para @options[:message], width: 1.0, text_align: :center
end
flow(width: 1.0, height: 0.1, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call
end
stack(width: 0.5)
button "Confirm", width: 0.25, background: 0xff_800000, hover: { background: 0xff_d00000 }, active: { background: 0xff_600000, color: 0xff_ffffff } do
pop_state
@options[:accept_callback]&.call
end
end
end
end
def draw
previous_state&.draw
Gosu.flush
super
end
end
end
end

26
lib/states/dialog.rb Normal file
View File

@@ -0,0 +1,26 @@
class W3DHub
class States
class Dialog < CyberarmEngine::GuiState
def draw
previous_state&.draw
Gosu.flush
super
end
def update
super
return unless window.current_state == self
window.states.reverse.each do |state|
# Don't update ourselves, forever
next if state == self && state.is_a?(CyberarmEngine::GuiState)
state.update
end
end
end
end
end

View File

@@ -0,0 +1,41 @@
class W3DHub
class States
class ConfirmDialog < Dialog
def setup
window.show_cursor = true
theme(W3DHub::THEME)
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: 48, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", height: 1.0, align: :center, color: 0xaa_ff0000
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, text_align: :center
end
flow(width: 1.0, height: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call
end
stack(width: 0.5)
button "Confirm", width: 0.25, **DANGEROUS_BUTTON do
pop_state
@options[:accept_callback]&.call
end
end
end
end
end
end
end

View File

@@ -0,0 +1,453 @@
class W3DHub
class States
class DirectConnectDialog < Dialog
def setup
window.show_cursor = true
W3DHub::Store[:asterisk_config] ||= Asterisk::Config.new
theme(W3DHub::THEME)
background 0xaa_525252
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: 36, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/export.png", height: 1.0, align: :center, color: 0xaa_ffffff
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: 72, margin_left: 8, margin_right: 8) do
para "Server profiles", text_align: :center, width: 1.0
flow(width: 1.0, fill: true) do
list = W3DHub::Store[:asterisk_config].server_profiles.count.positive? ? W3DHub::Store[:asterisk_config].server_profiles.map { |pf| pf.name }.insert(0, "") : [""]
@server_profiles_list = list_box items: list, fill: true, height: 1.0
@server_profiles_list.subscribe(:changed) do |list|
list.items.delete("") if list.value != ""
profile = W3DHub::Store[:asterisk_config].server_profiles.find { |pf| pf.name == list.value }
populate_from_server_profile(profile ? profile : W3DHub::Store[:asterisk_config].settings)
valid_for_multiplayer?
end
button get_image("#{GAME_ROOT_PATH}/media/ui_icons/plus.png"), image_height: 1.0, tip: "Create new profile" do
push_state(Asterisk::States::ServerProfileForm, save_callback: method(:save_server_profile))
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, 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
push_state(Asterisk::States::ServerProfileForm, editing: W3DHub::Store[:asterisk_config].server_profiles.find { |pf| pf.name == @server_profiles_list.value }, save_callback: method(:save_server_profile))
end
end
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: 72) do
stack(width: 0.5, height: 1.0) do
para "Nickname:"
@server_nickname = edit_line "", width: 1.0, fill: true
@server_nickname.subscribe(:changed) do |e|
@changes_made = true if @server_profiles_list.value.length.positive?
valid_for_multiplayer?
end
end
stack(width: 0.5, height: 1.0) do
para "Server Password:"
@server_password = edit_line "", width: 1.0, fill: true, margin_left: 4, type: :password
@server_password.subscribe(:changed) do |e|
@changes_made = true if @server_profiles_list.value.length.positive?
valid_for_multiplayer?
end
end
end
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
@server_hostname.subscribe(:changed) do |e|
@changes_made = true if @server_profiles_list.value.length.positive?
valid_for_multiplayer?
end
end
stack(width: 0.5, height: 1.0) do
para "Server Port:"
@server_port = edit_line "", width: 1.0, fill: true, margin_left: 4
@server_port.subscribe(:changed) do |e|
@changes_made = true if @server_profiles_list.value.length.positive?
valid_for_multiplayer?
end
end
end
stack(width: 1.0, height: 72) do
para "Game or Mod:"
flow(width: 1.0, fill: true) do
list = W3DHub::Store[:asterisk_config].games.count.positive? ? W3DHub::Store[:asterisk_config].games.map { |g| g.title } : [""]
@games_list = list_box items: list, fill: true, height: 1.0
@games_list.subscribe(:changed) do |list|
list.items.delete("") if list.value != ""
@changes_made = true if @server_profiles_list.value.length.positive?
valid_for_multiplayer?
end
button get_image("#{GAME_ROOT_PATH}/media/ui_icons/plus.png"), image_height: 1.0, tip: "Add game" do
push_state(Asterisk::States::GameForm, save_callback: method(:save_game))
end
@game_delete_button = button get_image("#{GAME_ROOT_PATH}/media/ui_icons/minus.png"), image_height: 1.0, tip: "Remove selected game" do
push_state(ConfirmDialog, title: "Are you sure?", message: "Remove game: #{@games_list.value}?", accept_callback: -> { delete_game(game_from_title(@games_list.value)) })
end
@game_edit_button = button get_image("#{GAME_ROOT_PATH}/media/ui_icons/gear.png"), image_height: 1.0, tip: "Edit selected game" do
push_state(Asterisk::States::GameForm, editing: W3DHub::Store[:asterisk_config].games.find { |g| g.title == @games_list.value }, save_callback: method(:save_game))
end
end
end
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|
@changes_made = true if @server_profiles_list.value.length.positive?
valid_for_multiplayer?
end
end
stack(width: 1.0, height: 72) do
para "IRC Profile (Optional):"
flow(width: 1.0, fill: true) do
@irc_profiles_list = list_box items: W3DHub::Store[:asterisk_config].irc_profiles.map {| pf| pf.name }.insert(0, "None"), fill: true, height: 1.0
@irc_profiles_list.subscribe(:changed) do |list|
@changes_made = true if @server_profiles_list.value.length.positive?
valid_for_multiplayer?
end
button get_image("#{GAME_ROOT_PATH}/media/ui_icons/plus.png"), image_height: 1.0, tip: "Add IRC profile" do
push_state(Asterisk::States::IRCProfileForm, save_callback: method(:save_irc_profile))
end
@irc_delete_button = button get_image("#{GAME_ROOT_PATH}/media/ui_icons/minus.png"), image_height: 1.0, tip: "Remove selected IRC profile" do
push_state(ConfirmDialog, title: "Are you sure?", message: "Delete IRC Profile: #{@irc_profiles_list.value}?", accept_callback: -> { delete_irc_profile(irc_profile_from_name(@irc_profiles_list.value)) })
end
@irc_edit_button = button get_image("#{GAME_ROOT_PATH}/media/ui_icons/gear.png"), image_height: 1.0, tip: "Edit selected IRC profile" do
push_state(Asterisk::States::IRCProfileForm, editing: irc_profile_from_name(@irc_profiles_list.value), save_callback: method(:save_irc_profile))
end
end
end
end
end
flow(width: 1.0, height: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
end
stack(fill: true)
@connect_button = button "Connect", width: 0.25 do
pop_state
join_server(game_from_title(@games_list.value).path, @server_nickname.value, @server_hostname.value, @server_port.value, @server_password.value, @launch_arguments.value)
handle_irc
end
end
end
end
def update
super
if @server_profiles_list.value == ""
@server_delete_button.enabled = false
@server_edit_button.enabled = false
else
@server_delete_button.enabled = true
@server_edit_button.enabled = true
end
if @games_list.value == ""
@game_delete_button.enabled = false
@game_edit_button.enabled = false
else
@game_delete_button.enabled = true
@game_edit_button.enabled = true
end
if @irc_profiles_list.value == "None"
@irc_delete_button.enabled = false
@irc_edit_button.enabled = false
else
@irc_delete_button.enabled = true
@irc_edit_button.enabled = true
end
if @games_list.value.empty? || @server_nickname.value.empty? || @server_hostname.value.empty? || @server_port.value.empty?
@connect_button.enabled = false
else
@connect_button.enabled = true
end
end
def populate_from_server_profile(profile)
@server_nickname.value = profile.nickname
@server_password.value = Base64.strict_decode64(profile.password)
@server_hostname.value = profile.server_hostname
@server_port.value = profile.server_port
@games_list.choose = profile.game_title if @games_list.items.find { |game| game == profile.game_title }
@launch_arguments.value = profile.launch_arguments
@irc_profiles_list.choose = profile.irc_profile if @irc_profiles_list.items.find { |irc| irc == profile.irc_profile }
end
def valid_for_singleplayer?
@single_player_button&.enabled = @games_list.value != ""
end
def valid_for_multiplayer?
@join_server_button&.enabled = @games_list.value != "" &&
@server_nickname.value.length.positive? &&
@server_hostname.value.length.positive? &&
@server_port.value.length.positive?
end
def save_server_profile(updated, name)
if updated
updated.name = name
updated.nickname = @server_nickname.value
updated.password = Base64.strict_encode64(@server_password.value)
updated.server_profile = @server_profiles_list.value
updated.server_hostname = @server_hostname.value
updated.server_port = @server_port.value
updated.game_title = @games_list.value
updated.launch_arguments = @launch_arguments.value
updated.irc_profile = @irc_profiles_list.value
else
profile = Asterisk::ServerProfile.new(
{
name: name,
nickname: @server_nickname.value,
password: Base64.strict_encode64(@server_password.value),
server_profile: @server_profiles_list.value,
server_hostname: @server_hostname.value,
server_port: @server_port.value,
game_title: @games_list.value,
launch_arguments: @launch_arguments.value,
irc_profile: @irc_profiles_list.value
}
)
W3DHub::Store[:asterisk_config].server_profiles << profile
end
W3DHub::Store[:asterisk_config].save_config
@server_profiles_list.items = W3DHub::Store[:asterisk_config].server_profiles.map {|profile| profile.name }
@server_profiles_list.items << "" if @server_profiles_list.items.empty?
@server_profiles_list.choose = name
@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
def save_game(updated, path, title)
if updated
updated.path = path
updated.title = title
else
game = Asterisk::Game.new({
path: path,
title: title
})
W3DHub::Store[:asterisk_config].games << game
end
W3DHub::Store[:asterisk_config].save_config
@games_list.items = W3DHub::Store[:asterisk_config].games.map {|g| g.title }
@games_list.choose = title
end
def delete_game(game)
index = W3DHub::Store[:asterisk_config].games.index(game) || 0
W3DHub::Store[:asterisk_config].games.delete(game)
W3DHub::Store[:asterisk_config].save_config
@games_list.items = W3DHub::Store[:asterisk_config].games.map {|g| g.title }
@games_list.choose = W3DHub::Store[:asterisk_config].games[index - 1 > 0 ? index - 1 : 0].title
end
def irc_profile_from_name(name)
W3DHub::Store[:asterisk_config].irc_profiles.find { |pf| name == pf.name }
end
def save_irc_profile(
updated, nickname, username, password,
server_hostname, server_port, server_ssl, server_verify_ssl,
bot_username, bot_auth_username, bot_auth_password
)
generated_name = Asterisk::States::IRCProfileForm.generate_profile_name(
nickname,
server_hostname,
server_port,
bot_username
)
if updated
updated.name = generated_name
updated.nickname = nickname
updated.username = username
updated.password = Base64.strict_encode64(password)
updated.server_hostname = server_hostname
updated.server_port = server_port
updated.server_ssl = server_ssl
updated.server_verify_ssl = server_verify_ssl
updated.bot_username = bot_username
updated.bot_auth_username = bot_auth_username
updated.bot_auth_password = Base64.strict_encode64(bot_auth_password)
else
profile = Asterisk::IRCProfile.new({
name: generated_name,
nickname: nickname,
username: username,
password: Base64.strict_encode64(password),
server_hostname: server_hostname,
server_port: server_port,
server_ssl: server_ssl,
server_verify_ssl: server_verify_ssl,
bot_username: bot_username,
bot_auth_username: bot_auth_username,
bot_auth_password: Base64.strict_encode64(bot_auth_password)
})
W3DHub::Store[:asterisk_config].irc_profiles << profile
end
W3DHub::Store[:asterisk_config].save_config
@irc_profiles_list.items = W3DHub::Store[:asterisk_config].irc_profiles.map {| pf| pf.name }.insert(0, "None")
@irc_profiles_list.choose = generated_name
end
def delete_irc_profile(profile)
index = W3DHub::Store[:asterisk_config].irc_profiles.index(profile)
return unless index
W3DHub::Store[:asterisk_config].irc_profiles.delete(profile)
W3DHub::Store[:asterisk_config].save_config
@irc_profiles_list.items = W3DHub::Store[:asterisk_config].irc_profiles.map {| pf| pf.name }.insert(0, "None")
@irc_profiles_list.choose = W3DHub::Store[:asterisk_config].irc_profiles[index - 1 > 0 ? index - 1 : 0].name
end
def wine_command
return "" if W3DHub.windows?
"#{Store.settings[:wine_command]} "
end
# TODO
def mangohud_command
return "" if W3DHub.windows?
# TODO: Add game specific options
# OPENGL?
if false && system("which mangohud")
"MANGOHUD=1 MANGOHUD_DLSYM=1 DXVK_HUD=1 mangohud "
else
""
end
end
# TODO
def dxvk_command
return "" if W3DHub.windows?
# Vulkan
# SETTING && WINE WILL USE DXVK?
if false && true#system()
_setting = "full"
"DXVK_HUD=#{_setting} "
else
""
end
end
def run(game_path, *args)
pid = Process.spawn("#{dxvk_command}#{mangohud_command}#{wine_command}\"#{game_path}\" #{args.join(' ')}")
Process.detach(pid)
end
def join_server(game_path, nickname, server_address, server_port, server_password, launch_arguments)
server_password = nil if server_password.empty?
launch_arguments = nil if launch_arguments.empty?
run(
game_path,
"-launcher +connect #{server_address}:#{server_port} +netplayername #{nickname}#{server_password ? " +password \"#{server_password}\"" : ""}#{launch_arguments ? " #{launch_arguments}" : ''}"
)
end
def handle_irc
return unless (profile = irc_profile_from_name(@irc_profiles_list.value))
Thread.new do
sleep 15
W3DHub::Asterisk::IRCClient.new(profile)
end
end
end
end
end

View File

@@ -0,0 +1,267 @@
class W3DHub
class States
class GameSettingsDialog < 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)
@app_id = @options[:app_id]
@channel = @options[:channel]
@game_settings = GameSettings.new(@app_id, @channel)
background 0xaa_525252
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, font: BOLD_FONT
end
stack(width: 1.0, fill: true, padding: 16, margin_top: 10) do
flow(width: 1.0, fill: true) do
stack(width: 0.5, height: 1.0, margin_right: 8) do
tagline "General"
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: 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: 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: 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: 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: 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: 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
end
end
stack(width: 0.5, height: 1.0, margin_left: 8) do
tagline "Video"
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, 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)
@game_settings.set_value(:resolution_height, h.to_i)
end
end
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: 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: 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
end
end
flow(width: 1.0, fill: true, margin_top: 16) do
stack(width: 0.5, height: 1.0, margin_right: 8) do
tagline "Audio"
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
toggle_button tip: "Sound Effects", checked: @game_settings.get(:master_enabled), **BUTTON_STYLE do |btn|
@game_settings.set_value(:master_enabled, btn.value)
end
end
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
toggle_button tip: "Sound Effects", checked: @game_settings.get(:sound_effects_enabled), **BUTTON_STYLE do |btn|
@game_settings.set_value(:sound_effects_enabled, btn.value)
end
end
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
toggle_button tip: "Dialogue", checked: @game_settings.get_value(:sound_dialog_enabled), **BUTTON_STYLE do |btn|
@game_settings.set_value(:sound_dialog_enabled, btn.value)
end
end
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
toggle_button tip: "Music", checked: @game_settings.get_value(:sound_music_enabled), **BUTTON_STYLE do |btn|
@game_settings.set_value(:sound_music_enabled, btn.value)
end
end
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
toggle_button tip: "Cinematic", checked: @game_settings.get_value(:sound_cinematic_enabled), **BUTTON_STYLE do |btn|
@game_settings.set_value(:sound_cinematic_enabled, btn.value)
end
end
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
end
end
stack(width: 0.5, height: 1.0, margin_left: 8) do
tagline "Performance"
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: 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: 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, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
# @game_settings.set_value(:texture_filtering, value)
# end
# end
# 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, theme: LIST_ITEM_THEME, **BUTTON_STYLE do |value|
# @game_settings.set_value(:texture_filtering, value)
# end
# end
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: 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: 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
end
end
end
flow(width: 1.0, height: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call
end
flow(fill: true)
button "Save", width: 0.25 do
pop_state
@game_settings.save_settings!
@options[:accept_callback]&.call
end
end
end
end
end
end
end

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

@@ -0,0 +1,34 @@
class W3DHub
class States
class MessageDialog < Dialog
def setup
window.show_cursor = true
theme(W3DHub::THEME)
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: 36, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/warning.png", height: 1.0, align: :center, color: 0xff_ff8800
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: 46, padding: 8) do
button "Okay", width: 1.0 do
pop_state
@options[:accept_callback]&.call
end
end
end
end
end
end
end

View File

@@ -1,34 +1,34 @@
class W3DHub
class States
class PromptDialog < CyberarmEngine::GuiState
class PromptDialog < Dialog
def setup
window.show_cursor = true
theme(W3DHub::THEME)
background 0xee_444444
background 0xaa_525252
stack(width: 1.0, height: 1.0, margin: 128, background: 0xee_222222) do
flow(width: 1.0, height: 0.1, padding: 8) do
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: 36, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/question.png", width: 0.04, 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>", width: 0.9, text_align: :center
title "<b>#{@options[:title]}</b>", fill: true, 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
@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: 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(@prompt_entry.value)
end
stack(width: 0.5)
stack(fill: true)
@accept_button = button "Accept", width: 0.25 do
if @options[:valid_callback]&.call(@prompt_entry.value)
@@ -65,14 +65,6 @@ class W3DHub
end
end
end
def draw
previous_state&.draw
Gosu.flush
super
end
end
end
end

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,84 +23,105 @@ 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
@pages = {}
Store.application_manager.auto_import
Store.application_manager.auto_import # unless Store.offline_mode
theme(W3DHub::THEME)
stack(width: 1.0, height: 1.0, border_thickness: 1, border_color: 0xff_aaaaaa) 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: 0.15, padding: 4) do
image "#{GAME_ROOT_PATH}/media/icons/app.png", width: 0.11
@header_container = flow(width: 1.0, height: 84, padding: 4, border_thickness_bottom: 1, border_color_bottom: W3DHub::BORDER_COLOR) do
background 0xaa_151515
stack(width: 0.89, height: 1.0) do
# background 0xff_885500
flow(width: 148, height: 1.0) do
flow(fill: true)
image "#{GAME_ROOT_PATH}/media/icons/app.png", height: 84
flow(fill: true)
end
@app_info_container = flow(width: 1.0, height: 0.65) do
# background 0xff_8855ff
@navigation_container = stack(fill: true, height: 1.0) do
@nav_padding_top_container = flow(fill: true)
stack(width: 0.75, height: 1.0) do
title "<b>#{I18n.t(:"app_name")}</b>", height: 0.5
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
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
end
flow(width: 1.0, height: 36) do
# background 0xff_666666
@application_taskbar_progressbar = progress fraction: 0.0, height: 2, width: 1.0
end
end
link I18n.t(:"interface.games").upcase, text_size: 34, font: BOLD_FONT do
page(W3DHub::Pages::Games)
end
@account_container = flow(width: 0.25, height: 1.0) do
stack(width: 0.7, height: 1.0) do
# background 0xff_222222
tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none
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
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
Launchy.open("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
end
end
end
link I18n.t(:"interface.community").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Community)
end
link I18n.t(:"interface.downloads").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::DownloadManager)
end
link I18n.t(:"interface.settings").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Settings)
end
end
@navigation_container = flow(width: 1.0, height: 0.35) do
# background 0xff_666666
flow(width: 1.0, height: 1.0, padding_left: 75) do
link I18n.t(:"interface.games") do
page(W3DHub::Pages::Games)
@nav_padding_bottom_container = flow(fill: true)
# Installer task display
@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 = para "", fill: true, text_wrap: :none
@application_taskbar_status_label = para "", width: 0.4, min_width: 256, text_align: :right, text_wrap: :none
end
link I18n.t(:"interface.server_browser"), margin_left: 18 do
page(W3DHub::Pages::ServerBrowser)
end
@application_taskbar_progressbar = progress fraction: 0.0, height: 2, width: 1.0
end
end
end
link I18n.t(:"interface.community"), margin_left: 18 do
page(W3DHub::Pages::Community)
end
@account_container = flow(width: 256, height: 1.0) do
if Store.offline_mode
stack(width: 1.0, height: 1.0) do
flow(fill: true)
link I18n.t(:"interface.downloads"), margin_left: 18 do
page(W3DHub::Pages::DownloadManager)
end
title "<b>OFFLINE</b>", text_wrap: :none, width: 1.0, text_align: :center
link I18n.t(:"interface.settings"), margin_left: 18 do
page(W3DHub::Pages::Settings)
flow(fill: true)
end
else
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: 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
@content_container = flow(width: 1.0, height: 0.85) do
@content_container = flow(width: 1.0, fill: true) do
end
end
@@ -120,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 |list|
if list
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # five minutes
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance.refresh_server_list(list)
BackgroundWorker.foreground_job(-> {}, ->(_) { States::Interface.instance&.update_server_browser(nil, :refresh_all) })
end
end
end
end
def button_down(id)
@@ -155,18 +211,28 @@ 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)
return unless @page.is_a?(Pages::ServerBrowser)
@page.update_server_ping(server)
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

@@ -1,41 +0,0 @@
class W3DHub
class States
class MessageDialog < CyberarmEngine::GuiState
def setup
window.show_cursor = true
theme(W3DHub::THEME)
background 0xee_444444
stack(width: 1.0, height: 1.0, margin: 128, background: 0xee_222222) do
flow(width: 1.0, height: 0.1, padding: 8) do
background 0x88_000000
image "#{GAME_ROOT_PATH}/media/ui_icons/warning.png", width: 0.04, align: :center, color: 0xff_ff8800
tagline "<b>#{@options[:title]}</b>", width: 0.9, text_align: :center
end
stack(width: 1.0, height: 0.78, padding: 16) do
para @options[:message], width: 1.0
end
stack(width: 1.0, height: 0.1, padding: 8) do
button "Okay", width: 1.0 do
pop_state
end
end
end
end
def draw
previous_state&.draw
Gosu.flush
super
end
end
end
end

View File

@@ -5,11 +5,16 @@ class W3DHub
window.show_cursor = true
theme(W3DHub::THEME)
background 0x88_252525
@card_container = stack(width: 1.0, height: 1.0, margin: 128, padding: 16) do
background 0xff_252525
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 0xaa_353535
end
flow(fill: true)
end
@card_container.clear do
@@ -18,14 +23,14 @@ class W3DHub
end
def card_welcome
stack(width: 1.0, height: 0.9) do
stack(width: 1.0, fill: true) do
banner "Welcome", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000
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
end
flow(width: 1.0, height: 0.1) do
stack(width: 0.83, height: 1.0) 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
end
@@ -38,7 +43,7 @@ class W3DHub
end
def card_getting_started
stack(width: 1.0, height: 0.9) do
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
@@ -49,8 +54,8 @@ class W3DHub
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
end
flow(width: 1.0, height: 0.9) do
flow(width: 0.83, height: 1.0) do
flow(width: 1.0, height: 46) do
flow(fill: true, height: 1.0) do
button "< Back" do
@card_container.clear { card_welcome }
end
@@ -67,7 +72,7 @@ class W3DHub
end
def card_communitiy
stack(width: 1.0, height: 0.9) do
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
@@ -82,8 +87,8 @@ class W3DHub
caption "Subscribe to our YouTube channel", margin_left: 32
end
flow(width: 1.0, height: 0.1) do
flow(width: 0.83, height: 1.0) 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

View File

@@ -2,13 +2,33 @@ class W3DHub
REGULAR_FONT = "#{GAME_ROOT_PATH}/media/fonts/NotoSans-Regular.ttf"
BOLD_FONT = "#{GAME_ROOT_PATH}/media/fonts/NotoSans-Bold.ttf"
BORDER_COLOR = W3DHUB_DEVELOPER ? 0xff_ff8844 : 0xff_656565
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
}
}
@@ -16,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: 32,
padding_right: 32,
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: {
@@ -66,8 +133,52 @@ 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: {
padding_left: 8,
padding_right: 8
},
Slider: {
border_color: 0xff_0074e0
},
Handle: {
text_size: 22,
padding_top: 8,
padding_left: 2,
padding_right: 2,
padding_bottom: 8,
border_color: Gosu::Color::NONE,
background: 0xff_0074e0,
hover: {
background: 0xff_004c94
},
active: {
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
}
}
}
end

View File

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

110
lib/win32_stub.rb Normal file
View File

@@ -0,0 +1,110 @@
module Win32
class Registry
module Constants
HKEY_CLASSES_ROOT = 0x80000000
HKEY_CURRENT_USER = 0x80000001
HKEY_LOCAL_MACHINE = 0x80000002
HKEY_USERS = 0x80000003
HKEY_PERFORMANCE_DATA = 0x80000004
HKEY_PERFORMANCE_TEXT = 0x80000050
HKEY_PERFORMANCE_NLSTEXT = 0x80000060
HKEY_CURRENT_CONFIG = 0x80000005
HKEY_DYN_DATA = 0x80000006
REG_NONE = 0
REG_SZ = 1
REG_EXPAND_SZ = 2
REG_BINARY = 3
REG_DWORD = 4
REG_DWORD_LITTLE_ENDIAN = 4
REG_DWORD_BIG_ENDIAN = 5
REG_LINK = 6
REG_MULTI_SZ = 7
REG_RESOURCE_LIST = 8
REG_FULL_RESOURCE_DESCRIPTOR = 9
REG_RESOURCE_REQUIREMENTS_LIST = 10
REG_QWORD = 11
REG_QWORD_LITTLE_ENDIAN = 11
STANDARD_RIGHTS_READ = 0x00020000
STANDARD_RIGHTS_WRITE = 0x00020000
KEY_QUERY_VALUE = 0x0001
KEY_SET_VALUE = 0x0002
KEY_CREATE_SUB_KEY = 0x0004
KEY_ENUMERATE_SUB_KEYS = 0x0008
KEY_NOTIFY = 0x0010
KEY_CREATE_LINK = 0x0020
KEY_READ = STANDARD_RIGHTS_READ |
KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY
KEY_WRITE = STANDARD_RIGHTS_WRITE |
KEY_SET_VALUE | KEY_CREATE_SUB_KEY
KEY_EXECUTE = KEY_READ
KEY_ALL_ACCESS = KEY_READ | KEY_WRITE | KEY_CREATE_LINK
REG_OPTION_RESERVED = 0x0000
REG_OPTION_NON_VOLATILE = 0x0000
REG_OPTION_VOLATILE = 0x0001
REG_OPTION_CREATE_LINK = 0x0002
REG_OPTION_BACKUP_RESTORE = 0x0004
REG_OPTION_OPEN_LINK = 0x0008
REG_LEGAL_OPTION = REG_OPTION_RESERVED |
REG_OPTION_NON_VOLATILE | REG_OPTION_CREATE_LINK |
REG_OPTION_BACKUP_RESTORE | REG_OPTION_OPEN_LINK
REG_CREATED_NEW_KEY = 1
REG_OPENED_EXISTING_KEY = 2
REG_WHOLE_HIVE_VOLATILE = 0x0001
REG_REFRESH_HIVE = 0x0002
REG_NO_LAZY_FLUSH = 0x0004
REG_FORCE_RESTORE = 0x0008
MAX_KEY_LENGTH = 514
MAX_VALUE_LENGTH = 32768
end
include Constants
include Enumerable
class Error < ::StandardError
attr_reader :code
end
class PredefinedKey < Registry
def initialize(hkey, keyname)
@hkey = hkey
@parent = nil
@keyname = keyname
@disposition = REG_OPENED_EXISTING_KEY
end
# Predefined keys cannot be closed
def close
raise Error.new(5) ## ERROR_ACCESS_DENIED
end
# Fake #class method for Registry#open, Registry#create
def class
Registry
end
# Make all
Constants.constants.grep(/^HKEY_/) do |c|
Registry.const_set c, new(Constants.const_get(c), c.to_s)
end
end
def open(*args)
raise Win32::Registry::Error
end
def create(*args)
Stub.new
end
class Stub
def write_i(*arg)
# No OP
end
end
end
end

View File

@@ -1,6 +1,7 @@
class W3DHub
class Window < CyberarmEngine::Window
def setup
self.show_stats_plotter = false
self.caption = I18n.t(:app_name)
Store[:server_list] = []
@@ -9,8 +10,6 @@ class W3DHub
Store[:main_thread_queue] = []
Store.settings.save_settings
begin
I18n.locale = Store.settings[:language]
rescue I18n::InvalidLocale
@@ -18,7 +17,10 @@ class W3DHub
end
# push_state(W3DHub::States::DemoInputDelay)
# push_state(W3DHub::States::Welcome)
push_state(W3DHub::States::Boot)
# push_state(W3DHub::States::DirectConnectDialog)
# push_state(W3DHub::Asterisk::States::IRCProfileForm)
end
def update
@@ -29,14 +31,13 @@ class W3DHub
while (block = Store.main_thread_queue.shift)
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?
end
def gain_focus
self.update_interval = 1000.0 / 60
end
def lose_focus
self.update_interval = 1000.0 / 10
def needs_redraw?
states.any?(&:needs_repaint?)
end
def close

View File

@@ -1,9 +1,10 @@
---
en:
app_name: Cyberarm's Linux Friendly W3D Hub Launcher # W3D Hub Launcher
app_name: Cyberarm's Linux Friendly W3D Hub Launcher
app_name_simple: W3D Hub Linux Launcher
boot:
w3dhub_service_is_down: W3D Hub service is down.
checking_for_updates: Checking for updates...
checking_for_updates: Checking for updates
interface:
log_in: Log in
register: Register
@@ -12,10 +13,14 @@ en:
profile: Profile
games: Games
server_browser: Server Browser
servers: Servers
community: Community
download_manager: Download Manager
downloads: Downloads
play_now: Play Now
play: Play
join_now: Join Now
join: Join
install_update: Install Update
single_player: Single Player
import: Import
@@ -24,6 +29,8 @@ en:
settings: Settings
games:
game_settings: Game Settings
game_options: Game Options
game_version: Game Version
wine_configuration: Wine Configuration
game_modifications: Game Modifications
repair_installation: Repair Installation
@@ -32,9 +39,11 @@ en:
user_data_folder: User Data Folder
view_screenshots: View Screenshots
read_more: Read More
fetching_news: Fetching news...
fetching_news: Fetching news
channel: Channel
version: Version
server_browser:
direct_connect: Direct Connect
refresh: Refresh
join_server: Join Server
game: Game
@@ -42,7 +51,7 @@ en:
max_players: Max Players
filters: Filters
region: Region
fetching_server_list: Fetching server list...
fetching_server_list: Fetching server list
no_server_selected: No server selected
hostname: Hostname
current_map: Current Map
@@ -53,3 +62,5 @@ en:
set_nickname_message: Set a nickname that will be used when joining a server
enter_password: Enter Password
enter_password_message: This server requires a password
time: Time
remaining: Remaining

71
locales/generate.rb Normal file
View File

@@ -0,0 +1,71 @@
require "csv"
require "yaml"
PATH = File.expand_path(".", __dir__)
TRANSLATIONS_PATH = "#{PATH}/translations.csv".freeze
puts "Loading translations.csv [Using ■ as column seperator]"
TRANSLATIONS = {}
LANGUAGES = []
i = 0
CSV.foreach("#{PATH}/translations.csv", col_sep: "") do |row|
key = row.delete(row.first)
if i.zero?
row.map { |language| language.split("-").first.downcase }.each do |language|
TRANSLATIONS[language] ||= {}
LANGUAGES << language
end
else
row.each_with_index do |translation, lang_id|
next unless translation
next if key.empty? || key.nil?
hash = TRANSLATIONS[LANGUAGES[lang_id]]
parts = key.split(".")
parts_size = parts.size
key = parts.delete(parts.last) if parts.size > 1
if parts_size > 1
parts.each do |part|
hash = hash[part] ||= {}
end
end
hash[key] = translation
end
end
i += 1
end
puts "Done."
puts
puts "Removing existing translations..."
Dir.glob("#{PATH}/*.yml") do |file|
File.delete(file)
end
puts "Done."
puts
puts "Writing out translations..."
written_languages = []
LANGUAGES.each do |language|
translations = TRANSLATIONS[language]
next unless translations.size.positive?
yaml = YAML.dump({ language => translations })
written_languages << language
File.write("#{PATH}/#{language}.yml", yaml)
end
puts "Done."
puts
puts "Wrote translations for: #{written_languages.join(', ')}"

61
locales/translations.csv Normal file
View File

@@ -0,0 +1,61 @@
__KEY__■EN-ENGLISH■DE-GERMAN■FR-FRENCH■ES-SPANISH■KO-KOREAN■JA-JAPANESE■ZH-CHINESE
app_name■Cyberarm's Linux Friendly W3D Hub Launcher■■■■■■
app_name_simple■W3D Hub Linux Launcher■■■■■■
boot.w3dhub_service_is_down■W3D Hub service is down.■■■■■■
boot.checking_for_updates■Checking for updates…■■■■■■
interface.log_in■Log in■■■■■■
interface.register■Register■■■■■■
interface.log_out■Log out■■■■■■
interface.not_logged_in■Not Logged In■■■■■■
interface.profile■Profile■■■■■■
interface.games■Games■■■■■■
interface.server_browser■Server Browser■■■■■■
interface.servers■Servers■■■■■■
interface.community■Community■■■■■■
interface.download_manager■Download Manager■■■■■■
interface.downloads■Downloads■■■■■■
interface.play_now■Play Now■■■■■■
interface.play■Play■■■■■■
interface.join_now■Join Now■■■■■■
interface.join■Join■■■■■■
interface.install_update■Install Update■■■■■■
interface.single_player■Single Player■■■■■■
interface.import■Import■■■■■■
interface.install■Install■■■■■■
interface.app_settings_tip■W3D Hub Launcher Settings■■■■■■
interface.settings■Settings■■■■■■
games.game_settings■Game Settings■■■■■■
games.game_options■Game Options■■■■■■
games.game_version■Game Version■■■■■■
games.wine_configuration■Wine Configuration■■■■■■
games.game_modifications■Game Modifications■■■■■■
games.repair_installation■Repair Installation■■■■■■
games.uninstall_game■Uninstall Game■■■■■■
games.install_folder■Install Folder■■■■■■
games.user_data_folder■User Data Folder■■■■■■
games.view_screenshots■View Screenshots■■■■■■
games.read_more■Read More■■■■■■
games.fetching_news■Fetching news…■■■■■■
games.channel■Channel■■■■■■
games.version■Version■■■■■■
server_browser.direct_connect■Direct Connect■■■■■■
server_browser.refresh■Refresh■■■■■■
server_browser.join_server■Join Server■■■■■■
server_browser.game■Game■■■■■■
server_browser.map■Map■■■■■■
server_browser.max_players■Max Players■■■■■■
server_browser.filters■Filters■■■■■■
server_browser.region■Region■■■■■■
server_browser.fetching_server_list■Fetching server list…■■■■■■
server_browser.no_server_selected■No server selected■■■■■■
server_browser.hostname■Hostname■■■■■■
server_browser.current_map■Current Map■■■■■■
server_browser.players■Players■■■■■■
server_browser.ping■Ping■■■■■■
server_browser.nickname■Nickname■■■■■■
server_browser.set_nickname■Set Nickname■■■■■■
server_browser.set_nickname_message■Set a nickname that will be used when joining a server■■■■■■
server_browser.enter_password■Enter Password■■■■■■
server_browser.enter_password_message■This server requires a password■■■■■■
server_browser.time■Time■■■■■■
server_browser.remaining■Remaining■■■■■■
1 __KEY__■EN-ENGLISH■DE-GERMAN■FR-FRENCH■ES-SPANISH■KO-KOREAN■JA-JAPANESE■ZH-CHINESE
2 app_name■Cyberarm's Linux Friendly W3D Hub Launcher■■■■■■
3 app_name_simple■W3D Hub Linux Launcher■■■■■■
4 boot.w3dhub_service_is_down■W3D Hub service is down.■■■■■■
5 boot.checking_for_updates■Checking for updates…■■■■■■
6 interface.log_in■Log in■■■■■■
7 interface.register■Register■■■■■■
8 interface.log_out■Log out■■■■■■
9 interface.not_logged_in■Not Logged In■■■■■■
10 interface.profile■Profile■■■■■■
11 interface.games■Games■■■■■■
12 interface.server_browser■Server Browser■■■■■■
13 interface.servers■Servers■■■■■■
14 interface.community■Community■■■■■■
15 interface.download_manager■Download Manager■■■■■■
16 interface.downloads■Downloads■■■■■■
17 interface.play_now■Play Now■■■■■■
18 interface.play■Play■■■■■■
19 interface.join_now■Join Now■■■■■■
20 interface.join■Join■■■■■■
21 interface.install_update■Install Update■■■■■■
22 interface.single_player■Single Player■■■■■■
23 interface.import■Import■■■■■■
24 interface.install■Install■■■■■■
25 interface.app_settings_tip■W3D Hub Launcher Settings■■■■■■
26 interface.settings■Settings■■■■■■
27 games.game_settings■Game Settings■■■■■■
28 games.game_options■Game Options■■■■■■
29 games.game_version■Game Version■■■■■■
30 games.wine_configuration■Wine Configuration■■■■■■
31 games.game_modifications■Game Modifications■■■■■■
32 games.repair_installation■Repair Installation■■■■■■
33 games.uninstall_game■Uninstall Game■■■■■■
34 games.install_folder■Install Folder■■■■■■
35 games.user_data_folder■User Data Folder■■■■■■
36 games.view_screenshots■View Screenshots■■■■■■
37 games.read_more■Read More■■■■■■
38 games.fetching_news■Fetching news…■■■■■■
39 games.channel■Channel■■■■■■
40 games.version■Version■■■■■■
41 server_browser.direct_connect■Direct Connect■■■■■■
42 server_browser.refresh■Refresh■■■■■■
43 server_browser.join_server■Join Server■■■■■■
44 server_browser.game■Game■■■■■■
45 server_browser.map■Map■■■■■■
46 server_browser.max_players■Max Players■■■■■■
47 server_browser.filters■Filters■■■■■■
48 server_browser.region■Region■■■■■■
49 server_browser.fetching_server_list■Fetching server list…■■■■■■
50 server_browser.no_server_selected■No server selected■■■■■■
51 server_browser.hostname■Hostname■■■■■■
52 server_browser.current_map■Current Map■■■■■■
53 server_browser.players■Players■■■■■■
54 server_browser.ping■Ping■■■■■■
55 server_browser.nickname■Nickname■■■■■■
56 server_browser.set_nickname■Set Nickname■■■■■■
57 server_browser.set_nickname_message■Set a nickname that will be used when joining a server■■■■■■
58 server_browser.enter_password■Enter Password■■■■■■
59 server_browser.enter_password_message■This server requires a password■■■■■■
60 server_browser.time■Time■■■■■■
61 server_browser.remaining■Remaining■■■■■■

0
media/banners/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
media/textures/noise.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
media/textures/noiseb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
media/textures/noisec.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
media/textures/noised.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
media/ui_icons/plus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,19 +1,38 @@
# Hint to SDL that we're not a game and that the system may sleep
ENV["SDL_VIDEO_ALLOW_SCREENSAVER"] = "1"
BUNDLER_USED = ARGV.join.include?("--bundler")
if BUNDLER_USED
require "bundler/setup"
Bundler.require
end
require "fileutils"
require "digest"
require "rexml"
require "logger"
require "time"
require "base64"
require "zip"
require "excon"
class W3DHub
W3DHUB_DEBUG = ARGV.join.include?("--debug")
W3DHUB_DEVELOPER = ARGV.join.include?("--developer")
# Use the real working directory as the root for runtime data/logs
GAME_ROOT_PATH = Dir.pwd
GAME_ROOT_PATH = File.expand_path(".", __dir__)
CACHE_PATH = "#{GAME_ROOT_PATH}/data/cache"
LOGS_PATH = "#{GAME_ROOT_PATH}/data/logs"
SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.json"
APPLICATIONS_CACHE_FILE_PATH = "#{GAME_ROOT_PATH}/data/applications_cache.json"
LOGGER = Logger.new("#{GAME_ROOT_PATH}/data/logs/w3d_hub_linux_launcher.log", "daily")
# Ensure data/cache and data/logs exist
FileUtils.mkdir_p(CACHE_PATH) unless Dir.exist?(CACHE_PATH)
FileUtils.mkdir_p(LOGS_PATH) unless Dir.exist?(LOGS_PATH)
LOGGER = 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"
@@ -21,17 +40,43 @@ end
module Kernel
def logger
W3DHub::LOGGER
@logger = W3DHub::LOGGER
end
class W3DHubLogger
def initialize
end
def level=(options)
end
def info(tag, &block)
pp [tag, block&.call]
end
def debug(tag, &block)
pp [tag, block&.call]
end
def warn(tag, &block)
pp [tag, block&.call]
end
def error(tag, &block)
pp [tag, block&.call]
end
end
end
begin
require_relative "../cyberarm_engine/lib/cyberarm_engine"
rescue LoadError => e
logger.warn(W3D::LOG_TAG) { "Failed to load local cyberarm_engine:" }
logger.warn(W3D::LOG_TAG) { e }
unless BUNDLER_USED
begin
require_relative "../cyberarm_engine/lib/cyberarm_engine"
rescue LoadError => e
logger.warn(W3DHub::LOG_TAG) { "Failed to load local cyberarm_engine:" }
logger.warn(W3DHub::LOG_TAG) { e }
require "cyberarm_engine"
require "cyberarm_engine"
end
end
class W3DHub
@@ -39,20 +84,17 @@ class W3DHub
BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff")
end
require "i18n"
require "launchy"
require "async"
require "async/barrier"
require "async/semaphore"
require "async/http/internet/instance"
require "async/http/endpoint"
require "async/websocket/client"
require "protocol/websocket/connection"
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/win32_stub" unless Gem.win_platform?
require_relative "lib/version"
require_relative "lib/theme"
require_relative "lib/common"
@@ -63,6 +105,8 @@ require_relative "lib/settings"
require_relative "lib/mixer"
require_relative "lib/ico"
require_relative "lib/multicast_server"
require_relative "lib/hardware_survey"
require_relative "lib/game_settings"
require_relative "lib/background_worker"
require_relative "lib/application_manager"
require_relative "lib/application_manager/manifest"
@@ -73,14 +117,18 @@ 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"
require_relative "lib/states/welcome"
require_relative "lib/states/message_dialog"
require_relative "lib/states/prompt_dialog"
require_relative "lib/states/confirm_dialog"
require_relative "lib/states/dialog"
require_relative "lib/states/dialogs/message_dialog"
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"
@@ -90,6 +138,7 @@ require_relative "lib/api/server_list_server"
require_relative "lib/api/server_list_updater"
require_relative "lib/api/account"
require_relative "lib/api/package"
require_relative "lib/api/event"
require_relative "lib/page"
require_relative "lib/pages/games"
@@ -99,18 +148,51 @@ require_relative "lib/pages/login"
require_relative "lib/pages/settings"
require_relative "lib/pages/download_manager"
require_relative "lib/asterisk/irc_client"
require_relative "lib/asterisk/config"
require_relative "lib/asterisk/game"
require_relative "lib/asterisk/irc_profile"
require_relative "lib/asterisk/server_profile"
require_relative "lib/asterisk/settings"
require_relative "lib/asterisk/states/game_form"
require_relative "lib/asterisk/states/irc_profile_form"
require_relative "lib/asterisk/states/server_profile_form"
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).show unless defined?(Ocra)
# 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
# Wait for BackgroundWorker to return
while W3DHub::BackgroundWorker.alive?
W3DHub::BackgroundWorker.kill! if Gosu.milliseconds - worker_soft_halt >= 1_000
sleep 0.1
end