174 Commits

Author SHA1 Message Date
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
4e469cb12c Improved server nickname validation 2022-03-23 23:04:03 -05:00
4e0b013a93 Don't log server updates, way to verbose 2022-03-23 21:34:24 -05:00
b5fc3e9239 Use proper logging, more errors from tasks should now trigger showing the error dialog instead of siliently failing 2022-03-23 15:09:51 -05:00
57bcff8019 Added some net locks to prevent fetching news while a fetch is already in progress 2022-03-20 18:28:19 -05:00
8766ed7d86 Fixed race condition where images for user avatar or news icons were not blocking execution until after they were downloaded causing a possible crash due to trying to save and load at the same time (sometimes also causing images to be only partly saved making them corrupt causing another crash) 2022-03-20 13:51:52 -05:00
76ca7e369f Added packaging support for windows (windows is still glitchy/crashy when it comes to downloading images) 2022-03-18 18:33:44 -05:00
8ba577d5fd Fixed boot saving in a thread causing png based icons to be blank, improvements to server browser to show same team and score data as android app, fixed settings page not using a find height for containers causing them to not position properly on initial gui regeneration, task fail fast now checks for write access to target path, fail fast now raises an exception to prevent incorrect fail fast check from being reported. 2022-02-21 09:29:40 -06:00
9be118b1ad Added prototype welcome screen 2022-02-19 15:56:54 -06:00
a5fa7507b5 Fixed infinite stack dive 2022-02-13 08:54:31 -06:00
3ffbc2892c Auto import any game installed by the official launcher at start up, should also pick up updated version strings now that it is run for 'installed' games. 2022-02-12 20:05:34 -06:00
f2edc30bbb Fixed crashes, added W3DHUB_DEBUG constant, reduced size of Excon download buffer from 4 MB to ~50KB to make ui able to more accurately show download activity 2022-02-12 12:40:08 -06:00
232ed2032f Window is no longer a fiber, should prevent window from locking up due to a fiber not yielding, replaced ui's direct async calls with BackgroundWorker.foreground_job, show pulsing circle behind app logo on boot 2022-02-12 09:03:46 -06:00
202966fd08 Drafted async-http downloader, fixed not correctly fetching icons at startup 2022-02-10 22:40:51 -06:00
e267a17c76 Fixed crash on Windows due to not ensuring file mode is binary for saving app icons 2022-02-07 13:31:35 -06:00
a9f5c29a18 Fixed ICO implementation having a top and left offset for bitmap images, icons are now fetched at start up instead of statically being included (More work needed to fetch them after logging), fixed importer incorrectly storing executable basename instead of dirname 2022-02-07 10:18:40 -06:00
fd7c858bd8 Stubbed multicast server, require ico parser 2022-02-06 09:29:40 -06:00
aef62b423f ICO parsing of 32-bit icons now possible, added default_icon image for missing app icon 2022-02-06 08:35:38 -06:00
af95acbd4d Downgrade async gem to 1.30.1 until 2.0+ is more stable (getting: Errno::EPERM: Operation not permitted - IO_Event_Selector_EPoll_io_wait:epoll_ctl, when trying to cache a file) 2022-02-05 20:10:48 -06:00
ff1ffca3c5 Fixed crash due to encoding error while patching a certain always.dat 2022-02-05 10:56:24 -06:00
d709e86e14 Updated gemfile.lock, fixed crash on windows when trying to repair data/ -> Data/ when windows thinks they're the same. 2022-02-05 08:09:02 -06:00
610bee05eb Initial ICO parsing 2022-02-04 20:15:10 -06:00
e3cb9805fe Fixed token refresh happening to often at startup, replace missing game icon image with question mark, add support for more wwconfig.exe naming funkiness... 2022-02-04 18:36:20 -06:00
85 changed files with 6337 additions and 1506 deletions

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

@@ -0,0 +1,47 @@
name: Build Launcher Binary
on:
push:
branches: [ master, test ]
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

9
.gitignore vendored
View File

@@ -1,5 +1,12 @@
pkg/
*.json
data/cache/*
!data/cache/.gitkeep
data/logs/*
!data/logs/.gitkeep
_*.*
*.log
*.log
media/icons/*
!media/icons/app.*
!media/icons/default_icon.png
!media/icons/w3dhub.png

31
Gemfile
View File

@@ -1,12 +1,27 @@
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"
gem "async-http"
gem "async-websocket"
gem "thread-local"
gem "i18n"
gem "ircparser"
gem "rexml"
gem "rubyzip"
gem "websocket-client-simple"
gem "win32-process", platforms: [:windows]
gem "win32-security", platforms: [:windows]
# 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,80 +1,61 @@
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.32.2)
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.19.1)
clipboard (~> 1.3.5)
excon (~> 0.78.0)
base64 (0.3.0)
concurrent-ruby (1.3.5)
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.78.1)
ffi (1.15.4)
ffi (1.15.4-x64-mingw32)
fiber-local (1.0.0)
gosu (1.2.0)
gosu_more_drawables (0.3.1)
i18n (1.8.11)
event_emitter (0.2.6)
excon (1.3.0)
logger
ffi (1.17.2-x64-mingw-ucrt)
ffi (1.17.2-x86_64-linux-gnu)
ffi-win32-extensions (1.0.4)
ffi
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.1.2-x64-mingw)
logger (1.7.0)
mutex_m (0.3.0)
rake (13.3.0)
rexml (3.4.2)
rubyzip (3.0.2)
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-mingw32
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES
async
async-http
async-websocket
base64
cyberarm_engine
digest-crc
ffi
excon
i18n
launchy
ircparser
libui
rexml
thread-local
rubyzip
sdl2-bindings
websocket-client-simple
win32-process
win32-security
BUNDLED WITH
2.2.28
2.6.8

23
Rakefile Normal file
View File

@@ -0,0 +1,23 @@
raise "OCRA doesn't work with Ruby 3.1.0" if RUBY_VERSION == "3.1.0"
# frozen_string_literal: true
require "releasy"
require "bundler/setup" # Releasy requires that your application uses bundler.
require_relative "lib/version"
Releasy::Project.new do
name "W3D Hub Linux Launcher"
version W3DHub::VERSION
executable "w3d_hub_linux_launcher.rb"
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 :windows # :console # Assuming you don't want it to run with a console window.
add_package :exe # Windows self-extracting archive.
end
end

0
data/logs/.gitkeep Normal file
View File

View File

@@ -1,30 +1,150 @@
class W3DHub
class Api
# Set Excon default CA file if found
if (ca_file = W3DHub.ca_bundle_path)
Excon.defaults[:ssl_ca_file] = ca_file
end
LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 30 # seconds
USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze
DEFAULT_HEADERS = [
["User-Agent", USER_AGENT],
["Accept", "application/json"]
].freeze
FORM_ENCODED_HEADERS = (
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_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 #{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
@client.post(url, headers, body)
begin
connection.send(
method,
path: url.sub(endpoint, ""),
headers: headers,
body: body,
nonblock: true,
tcp_nodelay: true,
write_timeout: API_TIMEOUT,
read_timeout: API_TIMEOUT,
connect_timeout: API_TIMEOUT,
idempotent: true,
retry_limit: 3,
retry_interval: 1,
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
)
rescue Excon::Errors::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
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
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::Errors::Timeout => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
DummyResponse.new(e)
rescue Excon::Error => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
DummyResponse.new(e)
end
end
# Method: POST
@@ -42,67 +162,72 @@ 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?#status == 200
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)
user_details_data = user_details(user_data[:userid]) || {}
if user_details.success?
user_details_data = JSON.parse(user_details.read, symbolize_names: true)
end
return Account.new(user_data, user_details_data)
Account.new(user_data, user_details_data)
else
logger.error(LOG_TAG) { "Failed to fetch refresh user login:" }
logger.error(LOG_TAG) { response }
false
end
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)
user_details_data = user_details(user_data[:userid]) || {}
if user_details.success?
user_details_data = JSON.parse(user_details.read, symbolize_names: true)
end
return Account.new(user_data, user_details_data)
Account.new(user_data, user_details_data)
else
logger.error(LOG_TAG) { "Failed to fetch user login:" }
logger.error(LOG_TAG) { response }
false
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 }
false
end
end
@@ -111,27 +236,101 @@ 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 }
false
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 }
logger.error(LOG_TAG) { response }
false
end
end
@@ -140,14 +339,18 @@ 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:" }
logger.error(LOG_TAG) { packages }
logger.error(LOG_TAG) { response }
false
end
end
@@ -160,9 +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
# 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
@@ -181,11 +402,11 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# nick, team (index of teams array), score, kills, deaths
def self.server_list(internet, level = 1)
response = internet.get("#{SERVER_LIST_ENDPOINT}/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS)
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
@@ -203,11 +424,13 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# nick, team (index of teams array), score, kills, deaths
def self.server_details(internet, id, level)
response = internet.get("#{SERVER_LIST_ENDPOINT}/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS)
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

@@ -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.slot }.reverse
games.each { |hash| @games << Game.new(hash, source) }
@games.sort_by!(&:name).reverse
end
def games
@@ -17,57 +19,72 @@ class W3DHub
end
class Game
attr_reader :slot, :id, :name, :type, :category, :studio_id, :channels, :web_links, :color
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
@slot = slot_index(@data[:id])
@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
private def slot_index(app_id)
case app_id
when "ren"
1
when "ecw"
2
when "ia"
3
when "apb"
4
when "tsr"
5
else
-10
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

@@ -1,66 +1,8 @@
class W3DHub
class Api
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
@@ -69,51 +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
Async do |task|
internet = Async::HTTP::Internet.instance
Thread.new do
begin
connect
response = internet.post("https://gsh.w3dhub.com/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, [""])
data = JSON.parse(response.read, symbolize_names: true)
id = data[:connectionToken]
endpoint = Async::HTTP::Endpoint.parse("https://gsh.w3dhub.com/listings/push/v2?id=#{id}", alpn_protocols: Async::HTTP::Protocol::HTTP11.names)
Async::WebSocket::Client.connect(endpoint, headers: Api::DEFAULT_HEADERS, handler: PatchedConnection) do |connection|
connection.write({ protocol: "json", version: 1 })
connection.flush
pp connection.read
connection.write({ "type": 6 })
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)
while W3DHub::BackgroundWorker.alive?
connect if @auto_reconnect
sleep 1
end
rescue => e
puts e
puts e.backtrace
while (message = connection.read)
connection.write({ type: 6 }) if message.first[:type] == 6
sleep 30
retry
end
end
if message&.first&.fetch(:type) == 1
message.each do |rpc|
next unless rpc[:target] == "ServerStatusChanged"
logger.debug(LOG_TAG) { "Cleaning up..." }
@@instance = nil
end
id, data = rpc[:arguments]
def connect
@auto_reconnect = false
logger.debug(LOG_TAG) { "Requesting connection token..." }
response = Api.post("/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh)
if response.status != 200
@auto_reconnect = true
return
end
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)
States::Interface.instance&.update_server_browser(server) if server_updated
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
end
ensure
@@instance = nil
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

@@ -1,5 +1,6 @@
class W3DHub
class ApplicationManager
LOG_TAG = "W3DHub::ApplicationManager".freeze
include CyberarmEngine::Common
def initialize
@@ -7,7 +8,7 @@ class W3DHub
end
def install(app_id, channel)
puts "Installation Request: #{app_id}-#{channel}"
logger.info(LOG_TAG) { "Installation Request: #{app_id}-#{channel}" }
return false if installed?(app_id, channel) || installing?(app_id, channel)
@@ -27,7 +28,7 @@ class W3DHub
end
def update(app_id, channel)
puts "Update Request: #{app_id}-#{channel}"
logger.info(LOG_TAG) { "Update Request: #{app_id}-#{channel}" }
return false unless installed?(app_id, channel)
@@ -37,23 +38,41 @@ class W3DHub
end
def import(app_id, channel)
puts "Import Request: #{app_id}-#{channel}"
logger.info(LOG_TAG) { "Import Request: #{app_id}-#{channel}" }
# Check registry for auto-import if windows
# 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)
puts "Settings Request: #{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))
config_exe = app_id == "ecw" ? "config.exe" : "wwconfig.exe"
exe = "#{app_data[:install_directory]}/#{config_exe}"
exe = if File.exist?("#{app_data[:install_directory]}/wwconfig.exe")
"#{app_data[:install_directory]}/wwconfig.exe"
elsif File.exist?("#{app_data[:install_directory]}/WWConfig.exe")
"#{app_data[:install_directory]}/WWConfig.exe"
elsif File.exist?("#{app_data[:install_directory]}/config.exe")
"#{app_data[:install_directory]}/config.exe"
end
if File.exist?(exe)
pid = Process.spawn("#{wine_command(app_id, channel)}\"#{exe}\"")
@@ -63,7 +82,7 @@ class W3DHub
end
def wine_configuration(app_id, channel)
puts "Wine Configuration Request: #{app_id}-#{channel}"
logger.info(LOG_TAG) { "Wine Configuration Request: #{app_id}-#{channel}" }
# open wwconfig.exe or config.exe for ecw
@@ -79,7 +98,7 @@ class W3DHub
end
def repair(app_id, channel)
puts "Repair Installation Request: #{app_id}-#{channel}"
logger.info(LOG_TAG) { "Repair Installation Request: #{app_id}-#{channel}" }
return false if !installed?(app_id, channel) || installing?(app_id, channel)
@@ -94,7 +113,7 @@ class W3DHub
end
def uninstall(app_id, channel)
puts "Uninstall Request: #{app_id}-#{channel}"
logger.info(LOG_TAG) { "Uninstall Request: #{app_id}-#{channel}" }
return false if !installed?(app_id, channel) || installing?(app_id, channel)
@@ -111,7 +130,7 @@ class W3DHub
end
def show_folder(app_id, channel, type)
puts "Show Folder Request: #{app_id} -> #{type.inspect}"
logger.info(LOG_TAG) { "Show Folder Request: #{app_id} -> #{type.inspect}" }
app_data = installed?(app_id, channel)
@@ -125,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
@@ -151,58 +176,161 @@ 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
return unless W3DHub.windows?
# Renegade
auto_import_win32_registry("ren", "release", 'SOFTWARE\Westwood\Renegade')
# Red Alert: A Path Beyond
auto_import_win32_registry("apb", "release")
# Expansive Civilian Warfare
auto_import_win32_registry("ecw", "release")
# Interim Apex
auto_import_win32_registry("ia", "release")
# Tiberian Sun: Reborn
auto_import_win32_registry("tsr", "release")
Store.applications.games.each do |game|
game.channels.each do |channel|
if game.id == "ren" && channel.id == "release"
auto_import_win32_registry(game, channel.id, 'SOFTWARE\Westwood\Renegade')
else
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?
return if installed?(app_id, channel_id)
puts "Importing: #{app_id}-#{channel_id}"
app_id = game.id
logger.info(LOG_TAG) { "Importing: #{app_id}-#{channel_id}" }
require "win32/registry"
@@ -214,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
}
@@ -232,22 +372,58 @@ class W3DHub
end
end
rescue => e
puts e.message, e.backtrace
# puts e.class, e.message, e.backtrace
if Win32::Registry::Error
logger.warn(LOG_TAG) { " Failed to import #{app_id}-#{channel_id}" }
else
logger.warn(LOG_TAG) { " An error occurred while tying to import #{app_id}-#{channel_id}" }
logger.warn(LOG_TAG) { e }
end
false
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 = {
install_directory: File.basename(exe_path),
installed_version: task.channel.current_version,
name: application.name,
install_directory: File.dirname(exe_path),
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
@@ -258,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
}
@@ -268,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)
@@ -302,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

@@ -1,9 +1,14 @@
class W3DHub
class ApplicationManager
class Task
LOG_TAG = "W3DHub::ApplicationManager::Task".freeze
class FailFast < RuntimeError
end
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)
@@ -15,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
@@ -49,12 +57,17 @@ class W3DHub
@task_state = :running
Thread.new do
Sync do
# Sync do
begin
status = execute_task
rescue RuntimeError => e
rescue FailFast
# no-op
rescue StandardError, Errno::EACCES => e
status = false
@task_failure_reason = e.message[0..512]
logger.error(LOG_TAG) { "Task #{type.inspect} failed for #{@application.name}" }
logger.error(LOG_TAG) { e }
end
# Force free some bytes
@@ -65,12 +78,13 @@ 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
def execute_task; end
def execute_task
end
# Suspend operation, if possible
def pause
@@ -98,6 +112,31 @@ class W3DHub
@task_state == :failed
end
def normalize_path(path, base_path)
path = path.to_s.gsub("\\", "/")
return path if W3DHub.windows? # Windows is easy, or annoying, depending how you look at it...
constructed_path = base_path
split_path = path.split("/")
split_path.each do |segment|
Dir.glob("#{constructed_path}/*").each do |part|
next unless "#{constructed_path}/#{segment}".downcase == part.downcase
constructed_path = part
break if File.file?(constructed_path)
end
end
# Find file if it exists, otherwise downcase the `path` sans `base_path`
if "#{base_path}/#{path}".length == constructed_path.length
constructed_path
else
"#{base_path}/#{path.downcase}"
end
end
def failure_reason
@task_failure_reason || ""
end
@@ -107,6 +146,8 @@ class W3DHub
@task_failure_reason = reason.to_s
hide_application_taskbar
raise FailFast, @task_failure_reason
end
def fail_silently!
@@ -115,18 +156,32 @@ class W3DHub
# Quick checks before network and computational work starts
def fail_fast
# 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
# Have write permissions to target directory
path = Cache.install_path(@application, @channel)
path = Store.settings[:app_install_dir] unless File.exist?(path)
begin
File.write("#{path}/___can_write.wlh", "")
File.delete("#{path}/___can_write.wlh")
Dir.mkdir("#{path}/__can_write")
Dir.rmdir("#{path}/__can_write")
rescue Errno::EACCES
fail!("FAIL FAST: Cannot write to #{path}")
end
# 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
def run_on_main_thread(block)
window.main_thread_queue << block
Store.main_thread_queue << block
end
def send_message_dialog(type, title, message)
@@ -173,21 +228,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..."
@@ -195,44 +257,48 @@ class W3DHub
@status.step = :build_package_list
packages = []
manifests.reverse.each do |manifest|
puts "#{manifest.game}-#{manifest.type}: #{manifest.version} (#{manifest.base_version})"
# 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..."
@@ -247,57 +313,56 @@ class W3DHub
file_count = manifests.map { |m| m.files.count }.sum
processed_files = 0
manifests.each do |manifest|
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}")
folder_exists = File.directory?(path)
file_path = "#{path}/#{safe_file_name}"
# 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
processed_files += 1
@status.progress = processed_files.to_f / file_count
file_path = normalize_path(file.name, path)
next if file.removed_since
next if accepted_files.key?(safe_file_name)
processed_files += 1
@status.progress = processed_files.to_f / file_count
unless File.exist?(file_path)
rejected_files << { file: file, manifest_version: manifest.version }
puts "[#{manifest.version}] File missing: #{file_path}"
next
end
next if file.removed_since
next if accepted_files.key?(file_path)
digest = Digest::SHA256.new
f = File.open(file_path)
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
while (chunk = f.read(32_000_000))
digest.update(chunk)
end
digest = Digest::SHA256.new
f = File.open(file_path)
f.close
while (chunk = f.read(32_000_000))
digest.update(chunk)
end
pp file if file.checksum.nil?
f.close
if digest.hexdigest.upcase == file.checksum.upcase
accepted_files[safe_file_name] = manifest.version
# puts "[#{manifest.version}] Verified file: #{file_path}"
else
rejected_files << { file: file, manifest_version: manifest.version }
puts "[#{manifest.version}] File failed Verification: #{file_path}"
end
logger.info(LOG_TAG) { file.inspect } if file.checksum.nil?
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
puts "#{rejected_files.count} missing or corrupt files"
logger.info(LOG_TAG) { "#{rejected_files.count} missing or corrupt files" }
# TODO: Filter packages to only the required ones
selected_packages = []
selected_packages_hash = {}
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
@@ -307,16 +372,16 @@ class W3DHub
end
end
# FIXME: Order `selected_packages` like `packages`
# Removed packages that don't need to be fetched or processed
packages.delete_if { |package| !selected_packages.find { |pkg| pkg == package } }
@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,
@@ -325,100 +390,105 @@ 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)
puts "Unpacking packages in '#{path}'..."
logger.info(LOG_TAG) { "Unpacking packages in '#{path}'..." }
Cache.create_directories(path, true)
@status.operations.clear
@@ -426,7 +496,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,
@@ -438,7 +508,7 @@ class W3DHub
@status.step = :unpacking
i = -1
packages.each do |package|
@packages.each do |package|
i += 1
status = if package.custom_is_patch
@@ -457,15 +527,13 @@ 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
update_interface_task_status
else
puts "COMMAND FAILED!"
logger.info(LOG_TAG) { "COMMAND FAILED!" }
fail!("Failed to unpack #{package.name}")
break
@@ -473,6 +541,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
@@ -485,7 +570,7 @@ class W3DHub
end
end
def install_dependencies(packages)
def install_dependencies
# TODO: install dependencies
@status.operations.clear
@status.label = "Installing #{@application.name}..."
@@ -495,6 +580,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)
@@ -505,7 +606,7 @@ class W3DHub
@status.step = :mark_application_installed
puts "#{@app_id} has been installed."
logger.info(LOG_TAG) { "#{@app_id} has been installed." }
end
#############
@@ -516,12 +617,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))
@@ -537,17 +643,19 @@ class W3DHub
end
def package_fetch(package, &block)
puts "Downloading: #{package.category}:#{package.subcategory}:#{package.name}-#{package.version}"
logger.info(LOG_TAG) { "Downloading: #{package.category}:#{package.subcategory}:#{package.name}-#{package.version}" }
internet = Async::HTTP::Internet.instance
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)
puts "Verifying: #{package.category}:#{package.subcategory}:#{package.name}-#{package.version}"
logger.info(LOG_TAG) { "Verifying: #{package.category}:#{package.subcategory}:#{package.name}-#{package.version}" }
digest = Digest::SHA256.new
path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
@@ -555,10 +663,10 @@ class W3DHub
return false unless File.exist?(path)
operation = @status.operations[:"#{package.checksum}"]
operation&.value = "Verifying..."
operation&.value = "Verifying..."
file_size = File.size(path)
puts " File size: #{file_size}"
logger.info(LOG_TAG) { " File size: #{file_size}" }
chunk_size = package.checksum_chunk_size
chunks = package.checksum_chunks.size
@@ -583,11 +691,11 @@ class W3DHub
if Digest::SHA256.new.hexdigest(chunk).upcase == checksum.upcase
valid_at = chunk_start + read_length
# puts " Passed chunk: #{chunk_start}"
# logger.debug(LOG_TAG) { " Passed chunk: #{chunk_start}" } # Only enable when deep diving to find a bug (VERBOSE)
# package.partially_valid_at_bytes = valid_at
package.partially_valid_at_bytes = chunk_start
else
puts " FAILED chunk: #{chunk_start}"
logger.info(LOG_TAG) { " FAILED chunk: #{chunk_start}" }
break
end
end
@@ -601,44 +709,47 @@ class W3DHub
end
def unpack_package(package, path)
puts " #{package.name}:#{package.version}"
logger.info(LOG_TAG) { " #{package.name}:#{package.version}" }
package_path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
puts " 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)
puts " #{package.name}:#{package.version}"
logger.info(LOG_TAG) { " #{package.name}:#{package.version}" }
package_path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
temp_path = "#{Store.settings[:package_cache_dir]}/temp"
manifest_file = package.custom_is_patch
Cache.create_directories(temp_path, true)
puts " 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)
puts " 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)
puts " 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)
puts " Removing files..." if patch_info[:removedFiles].size.positive?
logger.info(LOG_TAG) { " Removing files..." } if patch_info[:removedFiles].size.positive?
patch_info[:removedFiles].each do |file|
target_mix.package.files.delete_if { |f| f.name == file }
logger.debug(LOG_TAG) { " #{file}" }
target_mix.package.files.delete_if { |f| f.name.casecmp?(file) }
end
puts " Adding/Updating files..." if patch_info[:updatedFiles].size.positive?
logger.info(LOG_TAG) { " Adding/Updating files..." } if patch_info[:updatedFiles].size.positive?
patch_info[:updatedFiles].each do |file|
patch = patch_mix.package.files.find { |f| f.name == file }
target = target_mix.package.files.find { |f| f.name == file }
logger.debug(LOG_TAG) { " #{file}" }
patch = patch_mix.package.files.find { |f| f.name.casecmp?(file) }
target = target_mix.package.files.find { |f| f.name.casecmp?(file) }
if target
target_mix.package.files[target_mix.package.files.index(target)] = patch
@@ -647,27 +758,36 @@ class W3DHub
end
end
puts " 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))
# Force data/ to Data/
return true unless File.exist?("#{path}/data") && File.directory?("#{path}/data")
while (entry = stream.get_next_entry)
# Normalize the path to handle case-insensitivity consistently
file_path = normalize_path(entry.name, path)
puts " Moving #{path}/data/ to #{path}/Data/"
dir_path = File.dirname(file_path)
unless dir_path.end_with?("/.") || Dir.exist?(dir_path)
FileUtils.mkdir_p(dir_path)
end
FileUtils.mv(Dir.glob("#{path}/data/**"), "#{path}/Data", force: true)
FileUtils.remove_dir("#{path}/data", force: true)
File.open(file_path, "wb") do |f|
i = entry.get_input_stream
true
while (chunk = i.read(32_000_000)) # Read up to ~32 MB per chunk
f.write chunk
end
end
end
return true
end
end
end

View File

@@ -1,48 +0,0 @@
class W3DHub
class ApplicationManager
class Importer < Task
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

@@ -1,6 +1,8 @@
class W3DHub
class ApplicationManager
class Installer < Task
LOG_TAG = "W3DHub::ApplicationManager::Installer".freeze
def type
:installer
end
@@ -11,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

View File

@@ -1,27 +1,11 @@
class W3DHub
class ApplicationManager
class Repairer < Installer
LOG_TAG = "W3DHub::ApplicationManager::Repairer".freeze
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

@@ -1,6 +1,8 @@
class W3DHub
class ApplicationManager
class Uninstaller < Task
LOG_TAG = "W3DHub::ApplicationManager::Uninstaller".freeze
def type
:uninstaller
end
@@ -33,9 +35,11 @@ class W3DHub
@status.value = "Purging installation folder..."
@status.progress = Float::INFINITY
@status.step = :uninstalling_application
path = Cache.install_path(@application, @channel)
puts 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)
@@ -51,7 +55,7 @@ class W3DHub
@status.step = :mark_application_uninstalled
puts "#{@app_id} has been uninstalled."
logger.info(LOG_TAG) { "#{@app_id} has been uninstalled." }
end
end
end

View File

@@ -1,6 +1,8 @@
class W3DHub
class ApplicationManager
class Updater < Installer
LOG_TAG = "W3DHub::ApplicationManager::Updater".freeze
def type
:updater
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

166
lib/background_worker.rb Normal file
View File

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

View File

@@ -1,5 +1,7 @@
class W3DHub
class Cache
LOG_TAG = "W3DHub::Cache".freeze
def self.path(uri)
ext = File.basename(uri).split(".").last
@@ -7,23 +9,19 @@ class W3DHub
end
# Fetch a generic uri
def self.fetch(uri, force_fetch = false)
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(
-> { Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend) },
->(response) { File.open(path, "wb") { |f| f.write response.body } if response.status == 200 }
)
else
internet = Async::HTTP::Internet.instance
response = internet.get(uri, W3DHub::Api::DEFAULT_HEADERS)
if response.success?
response.save(path, "wb")
return path
end
false
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
@@ -36,7 +34,11 @@ class W3DHub
def self.package_path(category, subcategory, name, version)
package_cache_dir = Store.settings[:package_cache_dir]
"#{package_cache_dir}/#{category}/#{subcategory}/#{version}/#{name}.package"
if version.to_s.length.zero?
"#{package_cache_dir}/#{category}/#{subcategory}/#{name}.package"
else
"#{package_cache_dir}/#{category}/#{subcategory}/#{version}/#{name}.package"
end
end
def self.install_path(application, channel)
@@ -48,13 +50,57 @@ class W3DHub
end
# Download a W3D Hub package
def self.fetch_package(package, block)
# TODO: More work needed to make this work reliably
def self._async_fetch_package(package, block)
path = package_path(package.category, package.subcategory, package.name, package.version)
headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT }
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account
headers = Api::FORM_ENCODED_HEADERS
start_from_bytes = package.custom_partially_valid_at_bytes
puts " Start from bytes: #{start_from_bytes} of #{package.size}"
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
create_directories(path)
file = File.open(path, start_from_bytes.positive? ? "r+b" : "wb")
if start_from_bytes.positive?
headers = Api::FORM_ENCODED_HEADERS + [["Range", "bytes=#{start_from_bytes}-"]]
file.pos = start_from_bytes
end
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
response = Api.post("/apis/launcher/1/get-package", headers, body)
total_bytes = package.size
remaining_bytes = total_bytes - start_from_bytes
response.each do |chunk|
file.write(chunk)
remaining_bytes -= chunk.size
block.call(chunk, remaining_bytes, total_bytes)
end
response.status == 200
ensure
file&.close
end
# Download a W3D Hub package
def self.fetch_package(package, block)
endpoint_download_url = package.download_url || "#{Api::W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package"
if package.download_url
uri_path = package.download_url.split("/").last
endpoint_download_url = package.download_url.sub(uri_path, URI.encode_uri_component(uri_path))
end
path = package_path(package.category, package.subcategory, package.name, package.version)
headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT }
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account && !package.download_url
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
start_from_bytes = package.custom_partially_valid_at_bytes
logger.info(LOG_TAG) { " Start from bytes: #{start_from_bytes} of #{package.size}" }
create_directories(path)
@@ -69,22 +115,56 @@ class W3DHub
file.write(chunk)
block.call(chunk, remaining_bytes, total_bytes)
# puts " Remaining: #{((remaining_bytes.to_f / total_bytes) * 100.0).round}% (#{W3DHub::format_size(total_bytes - remaining_bytes)} / #{W3DHub::format_size(total_bytes)})"
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 })}",
chunk_size: 4_000_000,
response_block: streamer
body: package.download_url ? "" : body,
chunk_size: 50_000,
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}" }
false
end
ensure
file&.close
end
def self.acquire_net_lock(key)
Store["net_locks"] ||= {}
if Store["net_locks"][key]
false
else
Store["net_locks"][key] = true
end
end
def self.release_net_lock(key)
Store["net_locks"] ||= {}
if Store["net_locks"][key]
Store["net_locks"].delete(key)
else
warn "!!! net_lock not found for #{key.inspect}"
end
end
def self.net_lock?(key)
Store["net_locks"] ||= {}
Store["net_locks"][key]
end
end
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

141
lib/ico.rb Normal file
View File

@@ -0,0 +1,141 @@
require "stringio"
class W3DHub
class ICO
PNG_IDENT = "\211PNG\r\n\032\n".force_encoding("ASCII-8BIT").freeze
IconDirectory = Struct.new(:reserved, :type, :image_count)
IconDirectoryEntity = Struct.new(
:width,
:height,
:palette_size,
:reserved,
:color_planes,
:bit_depth,
:image_size,
:image_offset
)
def initialize(file:)
@file = StringIO.new(File.binread(file))
@images = []
parse
end
def directory
@icon_directory
end
def images
@images
end
# SEE: https://en.wikipedia.org/wiki/ICO_(file_format)
def parse
# Parse IconDirectory
@icon_directory = IconDirectory.new(
read_u16, read_u16, read_u16
)
@icon_directory.image_count.times do
@images << IconDirectoryEntity.new(
read_u8,
read_u8,
read_u8,
read_u8,
read_u16,
read_u16,
read_u32,
read_u32
)
@images.last.width = 256 if @images.last.width == 0
@images.last.height = 256 if @images.last.height == 0
end
end
def read_u8
@file.read(1).unpack1("C")
end
def read_u16
@file.read(2).unpack1("v")
end
def read_u32
@file.read(4).unpack1("V")
end
def select_pngs
@images.select do |image|
image_png?(image)
end
end
def image_png?(image)
@file.pos = image.image_offset
buf = @file.read(8).unpack1("a*")
buf == PNG_IDENT
end
def select_bmps
@images.select do |image|
image_bmp?(image) && image.palette_size == 0 && image.bit_depth == 32
end
end
def image_bmp?(image)
!image_png?(image)
end
def to_rgba32_blob(image)
@file.pos = image.image_offset
buf = StringIO.new(@file.read(image.image_size))
raise NotImplementedError "Cannot parse png based icons!" unless image_bmp?(image)
raise NotImplementedError "Cannot parse #{image.bit_depth}" unless image.bit_depth == 32
blob = "".force_encoding("ASCII-8BIT")
width = image.width
height = image.height - 1
image.height.times do |y|
image.width.times do |x|
buf.pos = ((height - y) * width + x + 10) * 4
blue = buf.read(1)
green = buf.read(1)
red = buf.read(1)
alpha = buf.read(1)
blob << red
blob << green
blob << blue
blob << alpha
end
end
Gosu::Image.from_blob(image.width, image.height, blob)
end
def image_data(image)
@file.pos = image.image_offset
StringIO.new(@file.read(image.image_size)).string
end
def save(image, filename)
if image_bmp?(image)
to_rgba32_blob(image).save(filename)
else
File.open(filename, "wb") { |f| f.write(image_data(image)) }
end
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
@@ -177,7 +179,7 @@ class W3DHub
@file.pos = @last_chunk * @buffer_size
@file.write(string)
@buffer.string = ""
@buffer.string = "".force_encoding(@encoding)
end
def string
@@ -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)

16
lib/multicast_server.rb Normal file
View File

@@ -0,0 +1,16 @@
class W3DHub
# Maybe add remote game launch from server list app?
class MulticastServer
MULTICAST_ADDR = "224.87.51.68"
PORT = 7050
def initialize
end
def listen
end
def stop
end
end
end

View File

@@ -15,10 +15,6 @@ class W3DHub
@options = {}
end
def main_thread_queue
@host.main_thread_queue
end
def options=(options)
@options = options
end

View File

@@ -3,56 +3,61 @@ 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/w3dhub_ruby")
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) { 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) { 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
end
end
return if Cache.net_lock?("w3dhub_news")
if @w3dhub_news
populate_w3dhub_news
else
@@ -60,25 +65,59 @@ class W3DHub
para I18n.t(:"games.fetching_news"), padding: 8
end
Async do
internet = Async::HTTP::Internet.instance
BackgroundWorker.foreground_job(
-> { fetch_w3dhub_news },
lambda do |result|
if result
populate_w3dhub_news
Cache.release_net_lock(result)
end
end
)
end
end
fetch_w3dhub_news
populate_w3dhub_news
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
news = Api.news("launcher-home")
Cache.release_net_lock("w3dhub_news") unless news
if news
news.items[0..9].each do |item|
Cache.fetch(item.image)
end
return unless news
@w3dhub_news = news
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
def populate_w3dhub_news
@@ -86,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/app.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/app.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,158 +117,368 @@ 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
if @game_news[game.id]
populate_game_news(game)
else
@game_news_container.clear do
title I18n.t(:"games.fetching_news"), padding: 8
end
return if Store.offline_mode
Async do
fetch_game_news(game)
unless Cache.net_lock?("game_news_#{game.id}")
if @game_events[game.id]
populate_game_events(game)
else
BackgroundWorker.foreground_job(
-> { fetch_game_events(game) },
lambda do |result|
if result
populate_game_events(game)
Cache.release_net_lock(result)
end
end
)
end
end
unless Cache.net_lock?("game_events_#{game.id}")
if @game_news[game.id]
populate_game_news(game)
else
@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
def fetch_game_news(game)
lock = Cache.acquire_net_lock("game_news_#{game.id}")
return false unless lock
news = Api.news(game.id)
Cache.release_net_lock("game_news_#{game.id}") unless news
if news
news.items[0..9].each do |item|
Cache.fetch(item.image)
end
return false unless news
@game_news[game.id] = news
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
def populate_game_news(game)
return unless @focused_game == game
if (feed = @game_news[game.id])
game_color = Gosu::Color.new(game.color)
game_color.alpha = 0xaa
@game_news_container.clear do
# Patch Notes
if false # Patch notes
flow(width: 1.0, max_width: 346 * 3 + (8 * 4), height: 346, margin: 8, margin_right: 32, border_thickness: 1, border_color: darken(Gosu::Color.new(game.color))) do
background darken(Gosu::Color.new(game.color), 10)
stack(width: 346, height: 1.0, padding: 8) do
background 0xff_181d22
para "Patch Notes"
tagline "<b>Patch 2.0 is now out!</b>"
para "words go here " * 20
flow(fill: true)
button "Read More", width: 1.0
end
flow(fill: true)
title "Eye Candy Banner Goes Here."
end
end
feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item|
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
@@ -228,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
([
@@ -252,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,48 +18,59 @@ 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
Async do
account = Api.user_login(@username.value, @password.value)
BackgroundWorker.foreground_job(
lambda do
account = Api.user_login(@username.value, @password.value)
applications = nil
if account
Store.account = account
Store.settings[:account][:data] = account
Store.settings.save_settings
if account
Store.account = account
Store.settings[:account][:data] = account
Store.settings.save_settings
internet = Async::HTTP::Internet.instance
Cache.fetch(account.avatar_uri, true)
if account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
applications = Api._applications
end
end
populate_account_info
applications = Api.applications
Store.applications = applications if applications
[account, applications]
end,
lambda do |result|
account, applications = result
page(W3DHub::Pages::Games)
else
# An error occurred, enable account entry
# NOTE: Too many incorrect entries causes lock out (Unknown duration)
@username.enabled = true
@password.enabled = true
btn.enabled = true
if account
populate_account_info
Store.applications = applications if applications
@error_label.value = "Incorrect username or password.\nOr too many failed login attempts, try again in a few minutes."
page(W3DHub::Pages::Games)
else
# An error occurred, enable account entry
# NOTE: Too many incorrect entries causes lock out (Unknown duration)
@username.enabled = true
@password.enabled = true
btn.enabled = true
@error_label.value = "Incorrect username or password.\nOr too many failed login attempts, try again in a few minutes."
end
end
end
)
end
@error_label = caption "", width: 1.0, text_align: :center, color: 0xff_800000
@@ -71,30 +80,71 @@ class W3DHub
end
if Store.account
Async do
Cache.fetch(Store.account.avatar_uri)
BackgroundWorker.foreground_job(
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false, backend: :w3dhub) },
->(result) {
populate_account_info
page(W3DHub::Pages::Games)
}
)
end
end
populate_account_info
page(W3DHub::Pages::Games)
def button_down(id)
case id
when Gosu::KB_TAB
if @username.focused?
window.current_state.request_focus(@password)
else
window.current_state.request_focus(@username)
end
when Gosu::KB_ENTER, Gosu::KB_RETURN
@action_button.enabled? && @action_button.clicked_left_mouse_button(@action_button, 0, 0)
end
end
def populate_account_info
@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>"
return if Store.offline_mode
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
@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
@@ -103,22 +153,29 @@ class W3DHub
Store.settings.save_settings
Store.account = nil
applications = Api.applications
Store.applications if applications
BackgroundWorker.foreground_job(
-> { Api._applications },
lambda do |applications|
if applications
Store.applications = applications
page(W3DHub::Pages::Games) if @host.current_page.is_a?(W3DHub::Pages::Games)
page(W3DHub::Pages::ServerBrowser) if @host.current_page.is_a?(W3DHub::Pages::ServerBrowser)
end
@host.instance_variable_get(:"@account_container").clear do
stack(width: 0.7, height: 1.0) do
# background 0xff_222222
tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none
@host.instance_variable_get(:"@account_container").clear do
stack(width: 1.0, height: 1.0) do
tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none
flow(width: 1.0) do
link(I18n.t(:"interface.log_in"), text_size: 16, width: 0.5) { page(W3DHub::Pages::Login) }
link I18n.t(:"interface.register"), text_size: 16, width: 0.49 do
Launchy.open("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
flow(width: 1.0) do
link(I18n.t(:"interface.log_in"), text_size: 22, width: 0.5) { page(W3DHub::Pages::Login) }
link I18n.t(:"interface.register"), text_size: 22, width: 0.49 do
W3DHub.url("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
end
end
end
end
end
end
)
end
end
end

View File

@@ -8,29 +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/app.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
@@ -46,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
@@ -127,29 +141,146 @@ 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
Async do
fetch_server_details(@refresh_server) if @refresh_server
populate_server_info(@refresh_server) if @refresh_server && @refresh_server == @selected_server
@refresh_server = nil
if @refresh_server
BackgroundWorker.foreground_job(
-> { fetch_server_details(@refresh_server) },
->(result) {
populate_server_info(@refresh_server) if @refresh_server == @selected_server
@refresh_server = nil
}
)
end
end
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
@@ -157,96 +288,111 @@ 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
Async do
fetch_server_details(server)
populate_server_info(server) if server == @selected_server
end
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
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
@@ -255,93 +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.05) do
stack(width: 0.465, height: 1.0) do
para "<b>#{server.status.teams[0].name}</b>", 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
image game_balance[:icon], height: 1.0, tip: game_balance[:message], color: game_balance[:color]
stack(width: 0.2, height: 1.0) do
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
stack(width: 0.46, height: 1.0) do
para "<b>#{server.status.teams[1].name}</b>", width: 1.0, text_align: :center
para game_balance[:ratio].round(2).to_s, width: 1.0, text_align: :center
end
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.65, 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
@@ -352,16 +533,14 @@ class W3DHub
end
def fetch_server_details(server)
Async do
internet = Async::HTTP::Internet.instance
server_data = Api.server_details(internet, server.id, 2)
server.update(server_data) if server_data
end
BackgroundWorker.foreground_job(
-> { Api.server_details(server.id, 2) },
->(server_data) { server.update(server_data) if server_data }
)
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/app.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
@@ -382,7 +561,6 @@ class W3DHub
end
def server_game_balance(server)
data = {
icon: BLACK_IMAGE,
color: 0xff_ffffff,
@@ -390,76 +568,61 @@ class W3DHub
}
# team 0 is left side
team_0_score = server.status.players.select { |ply| ply.team == 0 }.map(&:score).sum.to_f
team_0_score = server.status.teams[0].score
team_0_score = nil if team_0_score.zero?
team_0_score ||= server.status.players.select { |ply| ply.team == 0 }.map(&:score).sum
team_0_score = team_0_score.to_f
# team 1 is right side
team_1_score = server.status.players.select { |ply| ply.team == 1 }.map(&:score).sum.to_f
team_1_score = server.status.teams[1].score
team_1_score = nil if team_1_score.zero?
team_1_score ||= server.status.players.select { |ply| ply.team == 1 }.map(&:score).sum
team_1_score = team_1_score.to_f
ratio = 1.0 / (team_0_score / team_1_score)
ratio = 1.0 if ratio.to_s == "NaN"
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[:ratio] = ratio
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
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,
valid_callback: proc { |entry| entry.length.positive? }
)
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,79 +3,57 @@ 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) do
para "<b>Launcher Language</b>", width: 0.249
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. You should restart the launcher after changing this setting before the UI will update"
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: 32
stack(width: 1.0) do
flow(width: 1.0) do
para "<b>App Install Folder</b>", width: 0.249
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
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: 32
flow(width: 1.0) do
para "<b>Wine Command</b>", width: 0.249
stack(width: 0.75) do
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0
inscription "Command to use to for Windows compatiblity layer"
end
end
flow(width: 1.0, margin_top: 16) do
para "<b>Wine Prefix</b>", width: 0.249
stack(width: 0.75) do
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix]
inscription "Whether each game gets its own prefix. Uses global/default prefix by default."
end
end
end
button "Save" do
old_language = Store.settings[:language]
Store.settings[:language] = language_code(@language_menu.value)
Store.settings[:app_install_dir] = @app_install_dir_input.value
Store.settings[:package_cache_dir_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
stack(width: 128, max_height: 256, h_align: :center, margin_top: 16, fill: true) do
button "Save", width: 1.0 do
save_settings!
end
end
end
@@ -84,11 +62,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 +78,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

@@ -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

@@ -1,46 +1,47 @@
class W3DHub
class States
class Boot < CyberarmEngine::GuiState
LOG_TAG = "W3DHub::States::Boot".freeze
def setup
window.show_cursor = true
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?
# 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 },
server_list: { started: false, complete: false }
app_icons: { 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
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
end
end
Async do
@tasks.keys.each do |key|
Sync do
send(key)
stack(width: 1.0, height: 60) do
flow(width: 1.0, height: 26, margin_left: 16, margin_right: 16, margin_bottom: 8, margin_top: 8) do
@status_label = caption "Starting #{I18n.t(:app_name_simple)}...", fill: true
para "#{I18n.t(:app_name)} #{W3DHub::VERSION}", text_align: :right
end
@progressbar = progress height: 4, width: 1.0, margin_left: 16, margin_right: 16, margin_bottom: 8
end
end
end
def draw
Gosu.draw_circle(window.width / 2, window.height / 2, @w3dhub_logo.width * (0.6 + Math.cos(Gosu.milliseconds / 1000.0 * Math::PI).abs * 0.05), 128, 0xaa_353535, 32)
@w3dhub_logo.draw_rot(window.width / 2, window.height / 2, 32)
super
@@ -49,97 +50,295 @@ class W3DHub
def update
super
# @fraction += 1.0 * window.dt
@fraction = 1.0 / (@tasks.size / @task_index.to_f)
@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]]
if task && !task[:started]
task[:started] = true
send(@tasks.keys[@task_index])
end
@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], {})
if (Time.now.to_i - account.access_token_expiry.to_i) >= 60 * 3 # Older than 3 hours then refresh
@account = Api.refresh_user_login(account.refresh_token)
if (account.access_token_expiry - Time.now) / 60 <= 60 * 3 # Refresh if token expires within 3 hours
logger.info(LOG_TAG) { "Refreshing user login..." }
# TODO: Check without network
Api.on_thread(:refresh_user_login, account.refresh_token) do |refreshed_account|
update_account_data(refreshed_account)
end
else
@account = account
BackgroundWorker.foreground_job(-> { update_account_data(account) }, ->(_) {})
end
if @account
Store.account = @account
Store.settings[:account][:data] = @account
Cache.fetch(@account.avatar_uri, true)
else
Store.settings[:account] = {}
end
Store.settings.save_settings
@tasks[:refresh_user_token][:complete] = true
else
@tasks[:refresh_user_token][:complete] = true
end
end
def service_status
@service_status = Api.service_status
def update_account_data(account)
if account
Store.account = account
if @service_status
Store.service_status = @service_status
Store.settings[:account][:data] = account
if !@service_status.authentication? || !@service_status.package_download?
@status_label.value = "Authentication is #{@service_status.authentication? ? 'Okay' : 'Down'}. Package Download is #{@service_status.package_download? ? 'Okay' : 'Down'}."
end
@tasks[:service_status][:complete] = true
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
else
@status_label.value = I18n.t(:"boot.w3dhub_service_is_down")
Store.settings[:account] = {}
end
Store.settings.save_settings
@tasks[:refresh_user_token][:complete] = true
end
def service_status
Api.on_thread(:service_status) do |service_status|
@service_status = service_status
if @service_status
Store.service_status = @service_status
if !@service_status.authentication? || !@service_status.package_download?
@status_label.value = "Authentication is #{@service_status.authentication? ? 'Okay' : 'Down'}. Package Download is #{@service_status.package_download? ? 'Okay' : 'Down'}."
end
@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
def applications
@status_label.value = I18n.t(:"boot.checking_for_updates")
@applications = Api.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" })
if @applications
Store.applications = @applications
@offline_mode = true
Store.offline_mode = true
end
end
end
@tasks[:applications][:complete] = true
else
# FIXME: Failed to retreive!
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_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 = "#{CACHE_PATH}/#{package.subcategory}.png"
regenerate = false
broken_or_out_dated_icon = Digest::SHA256.new.hexdigest(File.binread(path)).upcase != package.checksum.upcase if File.exist?(path)
if File.exist?(path) && !broken_or_out_dated_icon
regenerate = !File.exist?(generated_icon_path)
else
Cache.fetch_package(package, proc {})
regenerate = true
end
if regenerate
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, ->(result) { result.save(result.images.max_by(&:width), generated_icon_path) })
end
end
@tasks[:app_icons][:complete] = true
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")
begin
internet = Async::HTTP::Internet.instance
list = Api.server_list(internet, 2)
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
Api::ServerListUpdater.instance
list.each do |server|
server.send_ping(true)
end
else
Store.server_list = []
Store.server_list_last_fetch = Gosu.milliseconds
end
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance
@tasks[:server_list][:complete] = true
rescue => e
# Something went wrong!
pp e
Store.server_list = []
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,33 @@
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
end
end
end
end
end
end
end

View File

@@ -0,0 +1,70 @@
class W3DHub
class States
class PromptDialog < 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: 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", height: 1.0, align: :center, color: 0xff_ff8800
title "<b>#{@options[:title]}</b>", fill: true, text_align: :center, font: BOLD_FONT
end
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: 46, padding: 8) do
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call(@prompt_entry.value)
end
stack(fill: true)
@accept_button = button "Accept", width: 0.25 do
if @options[:valid_callback]&.call(@prompt_entry.value)
pop_state
@options[:accept_callback]&.call(@prompt_entry.value)
end
end
end
end
@prompt_entry.subscribe(:changed) do
if @options[:valid_callback]
if @options[:valid_callback].call(@prompt_entry.value)
c = W3DHub::THEME[:Button][:border_color]
@prompt_entry.style.border_color = c
@prompt_entry.style.default[:border_color] = c
@prompt_entry.style.hover[:border_color] = c
@prompt_entry.style.active[:border_color] = c
@accept_button.enabled = true
else
c = 0xff_ff0000
@prompt_entry.style.border_color = c
@prompt_entry.style.default[:border_color] = c
@prompt_entry.style.hover[:border_color] = c
@prompt_entry.style.active[:border_color] = c
@accept_button.enabled = false
end
@prompt_entry.set_border_color
end
end
end
end
end
end

View File

@@ -1,7 +1,11 @@
class W3DHub
class States
class Interface < CyberarmEngine::GuiState
attr_reader :main_thread_queue
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
@@ -19,86 +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 = {}
@main_thread_queue = []
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
@@ -122,11 +145,37 @@ class W3DHub
@page&.update
while(block = @main_thread_queue.shift)
block&.call
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
update_interface_task_status(@interface_task_update_pending) if @interface_task_update_pending
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)
@@ -158,18 +207,32 @@ class W3DHub
@page.focus
end
def update_server_browser(server)
def current_page
@page
end
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

@@ -1,52 +0,0 @@
class W3DHub
class States
class PromptDialog < 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: 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
@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
button "Cancel", width: 0.25 do
pop_state
@options[:cancel_callback]&.call(@prompt_entry.value)
end
stack(width: 0.5)
button "Accept", width: 0.25 do
if @options[:valid_callback]&.call(@prompt_entry.value)
pop_state
@options[:accept_callback]&.call(@prompt_entry.value)
end
end
end
end
end
def draw
previous_state&.draw
Gosu.flush
super
end
end
end
end

112
lib/states/welcome.rb Normal file
View File

@@ -0,0 +1,112 @@
class W3DHub
class States
class Welcome < CyberarmEngine::GuiState
def setup
window.show_cursor = true
theme(W3DHub::THEME)
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
card_welcome
end
end
def card_welcome
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: 46) do
stack(fill: true, height: 1.0) do
link "Skip", border_color_bottom: 0xff_777777 do
pop_state
end
end
button "Next >" do
@card_container.clear { card_getting_started }
end
end
end
def card_getting_started
stack(width: 1.0, fill: true) do
banner "Getting Started", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000
title "Import C&C Renegade"
caption "You can import your installed copy of Renegade if it wasn't automatically imported from the Games tab. If you need to procure a copy of Renegade, EA's Origin Store has the Command & Conquer The Ultimate Collection available. We cannot provide Renegade for installation.", width: 1.0, margin_left: 32
stack(width: 1.0, height: 2, background: 0x88_ffffff)
title "Install one of our standalone games"
caption "Browse our selection of games from the left panel of the Games tab.\n• Interim Apex - Renegade but with hundreds of vehicles and characters.\n• Red Alert: A Path Beyond - DESCRIPTION\n• Tiberian Sun: Reborn - DESCRIPTION\n\nAnd more... Check out the left panel on the Games tab.", width: 1.0, margin_left: 32
end
flow(width: 1.0, height: 46) do
flow(fill: true, height: 1.0) do
button "< Back" do
@card_container.clear { card_welcome }
end
link "Skip", border_color_bottom: 0xff_777777, margin_left: 16 do
pop_state
end
end
button "Next >" do
@card_container.clear { card_communitiy }
end
end
end
def card_communitiy
stack(width: 1.0, fill: true) do
banner "W3D Hub Community", width: 1.0, border_thickness_bottom: 4, border_color_bottom: 0xff_000000
title "Forums"
caption "Join our forum community", margin_left: 32
title "Facebook"
caption "Like us on Facebook", margin_left: 32
title "Discord"
caption "Join our Discord community server", margin_left: 32
title "YouTube"
caption "Subscribe to our YouTube channel", margin_left: 32
end
flow(width: 1.0, height: 46) do
flow(fill: true, height: 1.0) do
button "< Back" do
@card_container.clear { card_getting_started }
end
end
button "Done" do
pop_state
end
end
end
def draw
previous_state&.draw
Gosu.flush
super
end
end
end
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
VERSION = "0.8.0"
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,13 +1,14 @@
class W3DHub
class Window < CyberarmEngine::Window
def setup
self.show_stats_plotter = false
self.caption = I18n.t(:app_name)
Store[:server_list] = []
Store[:settings] = Settings.new
Store[:application_manager] = ApplicationManager.new
Store.settings.save_settings
Store[:main_thread_queue] = []
begin
I18n.locale = Store.settings[:language]
@@ -16,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
@@ -24,16 +28,16 @@ class W3DHub
Store.application_manager.start_next_available_task if Store.application_manager.idle?
current = Async::Task.current?
current&.yield
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
@@ -55,13 +59,7 @@ class W3DHub
end
def main_thread_queue
if current_state.is_a?(W3DHub::States::Interface)
current_state.main_thread_queue
else
warn "Task will not be run for:"
warn caller
[]
end
Store.main_thread_queue
end
end
end
end

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.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

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,40 +1,100 @@
# Hint to SDL that we're not a game and that the system may sleep
ENV["SDL_VIDEO_ALLOW_SCREENSAVER"] = "1"
begin
require_relative "../cyberarm_engine/lib/cyberarm_engine"
rescue LoadError => e
puts "Failed to load local cyberarm_engine:"
pp e
require "cyberarm_engine"
BUNDLER_USED = ARGV.join.include?("--bundler")
if BUNDLER_USED
require "bundler/setup"
Bundler.require
end
require "fileutils"
require "digest"
require "rexml"
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"
I18n.load_path << Dir[File.expand_path("locales") + "/*.yml"]
I18n.default_locale = :en
require "logger"
require "time"
require "base64"
require "zip"
require "excon"
class W3DHub
GAME_ROOT_PATH = File.expand_path(".", __dir__)
CACHE_PATH = "#{GAME_ROOT_PATH}/data/cache"
SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.json"
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
CACHE_PATH = "#{GAME_ROOT_PATH}/data/cache"
LOGS_PATH = "#{GAME_ROOT_PATH}/data/logs"
SETTINGS_FILE_PATH = "#{GAME_ROOT_PATH}/data/settings.json"
APPLICATIONS_CACHE_FILE_PATH = "#{GAME_ROOT_PATH}/data/applications_cache.json"
# Ensure data/cache and data/logs exist
FileUtils.mkdir_p(CACHE_PATH) unless Dir.exist?(CACHE_PATH)
FileUtils.mkdir_p(LOGS_PATH) unless Dir.exist?(LOGS_PATH)
LOGGER = 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"
end
module Kernel
def 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
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"
end
end
class W3DHub
EMPTY_IMAGE = Gosu::Image.from_blob(1, 1)
BLACK_IMAGE = Gosu::Image.from_blob(1, 1, "\x00\x00\x00\xff")
end
require "i18n"
require "websocket-client-simple"
require "English"
require "sdl2"
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"
@@ -43,6 +103,11 @@ require_relative "lib/window"
require_relative "lib/cache"
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"
require_relative "lib/application_manager/status"
@@ -52,13 +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/message_dialog"
require_relative "lib/states/prompt_dialog"
require_relative "lib/states/confirm_dialog"
require_relative "lib/states/welcome"
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"
@@ -68,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"
@@ -77,8 +148,52 @@ require_relative "lib/pages/login"
require_relative "lib/pages/settings"
require_relative "lib/pages/download_manager"
Async do
W3DHub::Window.new(width: 980, height: 720, borderless: false).show
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"
exit # ensure reactor is shutdown when window is closed
if W3DHub.windows?
require "libui"
require "win32/process"
# Using a WHOLE ui library for: native file/folder open dialogs...
LibUI.init
LIBUI_WINDOW = LibUI.new_window("", 100, 100, 0)
at_exit do
LibUI.control_destroy(LIBUI_WINDOW)
LibUI.uninit
end
end
logger.info(W3DHub::LOG_TAG) { "W3D Hub Linux Launcher v#{W3DHub::VERSION}" }
Thread.new do
W3DHub::BackgroundWorker.create
end
until W3DHub::BackgroundWorker.alive?
sleep 0.1
end
logger.info(W3DHub::LOG_TAG) { "Launching window..." }
# W3DHub::Window.new(width: 980, height: 720, borderless: false, resizable: true).show unless defined?(Ocra)
W3DHub::Window.new(width: 1280, height: 800, borderless: false, resizable: true).show unless defined?(Ocra)
# W3DHub::Window.new(width: 1920, height: 1080, borderless: false, resizable: true).show unless defined?(Ocra)
W3DHub::BackgroundWorker.shutdown!
worker_soft_halt = Gosu.milliseconds
# Wait for BackgroundWorker to return
while W3DHub::BackgroundWorker.alive?
W3DHub::BackgroundWorker.kill! if Gosu.milliseconds - worker_soft_halt >= 1_000
sleep 0.1
end
W3DHub::LOGGER&.close