21 Commits

Author SHA1 Message Date
3e4b25f0d4 Progress towards background work replacement, work getting new NetworkManager and HttpClient up and running 2026-02-01 15:12:24 -06:00
ebc045019a More work on network handling refactor 2026-01-31 22:33:14 -06:00
79858a02ce Merge branch 'master' into development 2026-01-31 19:08:51 -06:00
f651143937 WIP API refactor 2026-01-31 14:07:08 -06:00
1425225eef Pivot away from Ractor (stil overly complicated to work with, even in Ruby 4) and towards 1 thread for http requests and 1 thread for application tasks, WIP refactor if Api to be non-blocking async- everything is broken atm 😁 2026-01-31 11:18:41 -06:00
f98d8c3394 Break API calls in work towards moving all blocking io to ractors, dropped BackgroundWorker class 2026-01-30 19:36:11 -06:00
6e79c4639d WIP Ractor Task stuff 2026-01-29 21:44:54 -06:00
4a8457e233 Initial work on reimplementing Task to be Ractor/sub-Process safe 2026-01-28 18:27:58 -06:00
46dece0479 Update gems 2026-01-28 14:02:47 -06:00
68af00bd2f Remove binding.irb call from Cache that was accidently left in... 2026-01-28 10:39:23 -06:00
208f2b8a39 Disable PingManager debug printing 2026-01-28 10:38:50 -06:00
b5d975761c Don't assume application executable is 'game.exe' 2026-01-28 10:38:22 -06:00
44483117d8 Initial work on process monitoring, minor refactoring 2026-01-21 10:45:07 -06:00
4146debc4c Initial work on PingManager 2026-01-20 17:53:25 -06:00
1f4185ada2 Tweak ping ratings for ServerBrowser 2026-01-20 15:38:18 -06:00
e2496d0a09 Fix up Play Now server selection logic, again 😁 2026-01-20 15:36:57 -06:00
d4e4697983 Initial work on UDP mode ICMP ping (https://keith.github.io/xcode-man-pages/icmp.4.html#Non-privileged_ICMP) 2026-01-20 09:19:16 -06:00
3e949cf863 Add some docs 2026-01-17 22:45:08 -06:00
027ba27b37 Consider player count in 'play now' server auto selection :D 2026-01-17 13:51:34 -06:00
adff1f9159 Fix play now auto-selected servers not sorted by ping 2026-01-17 13:32:51 -06:00
355a4503ea Fix Api.async_http logging empty urls 2026-01-15 08:38:35 -06:00
35 changed files with 1735 additions and 1076 deletions

27
Gemfile
View File

@@ -1,26 +1,15 @@
source "https://rubygems.org" source "https://rubygems.org"
# "standard lib" gems
gem "base64" gem "base64"
gem "rexml"
gem "logger"
# networking libs
gem "async-http" gem "async-http"
gem "async-websocket" gem "async-websocket"
# "game" library gem
gem "cyberarm_engine" gem "cyberarm_engine"
gem "sdl2-bindings" gem "sdl2-bindings"
gem "libui", platforms: [:windows]
# misc. libs
gem "digest-crc" gem "digest-crc"
gem "ircparser" gem "ircparser"
gem "rexml"
gem "rubyzip" gem "rubyzip"
# file selection dialogs on windows (SDL3 has these built-in, but we're on SDL2)
gem "libui", platforms: [:windows]
# misc. windows only gems
gem "win32-process", platforms: [:windows] gem "win32-process", platforms: [:windows]
gem "win32-security", platforms: [:windows] gem "win32-security", platforms: [:windows]
@@ -29,9 +18,9 @@ gem "win32-security", platforms: [:windows]
# use `bundle _x.y.z_ COMMAND` to use this one... # use `bundle _x.y.z_ COMMAND` to use this one...
# NOTE: Releasy needs to be installed as a system gem i.e. `rake install` # NOTE: Releasy needs to be installed as a system gem i.e. `rake install`
# NOTE: contents of the `gemhome` folder in the packaged folder need to be moved into the lib/ruby/gems\<RUBY_VERSION> folder # NOTE: contents of the `gemhome` folder in the packaged folder need to be moved into the lib/ruby/gems\<RUBY_VERSION> folder
# group :windows_packaging do # group :windows_packaging do
# gem "bundler", "~>2.4.3" # gem "bundler", "~>2.4.3"
# gem "rake" # gem "rake"
# gem "ocran" # gem "ocran"
# gem "releasy"#, path: "../releasy" # gem "releasy"#, path: "../releasy"
# end # end

View File

@@ -1,7 +1,7 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
async (2.38.1) async (2.36.0)
console (~> 1.29) console (~> 1.29)
fiber-annotation fiber-annotation
io-event (~> 1.11) io-event (~> 1.11)
@@ -18,7 +18,7 @@ GEM
protocol-http2 (~> 0.22) protocol-http2 (~> 0.22)
protocol-url (~> 0.2) protocol-url (~> 0.2)
traces (~> 0.10) traces (~> 0.10)
async-pool (0.11.2) async-pool (0.11.1)
async (>= 2.0) async (>= 2.0)
async-websocket (0.30.0) async-websocket (0.30.0)
async-http (~> 0.76) async-http (~> 0.76)
@@ -26,7 +26,7 @@ GEM
protocol-rack (~> 0.7) protocol-rack (~> 0.7)
protocol-websocket (~> 0.17) protocol-websocket (~> 0.17)
base64 (0.3.0) base64 (0.3.0)
console (1.34.3) console (1.34.2)
fiber-annotation fiber-annotation
fiber-local (~> 1.1) fiber-local (~> 1.1)
json json
@@ -44,29 +44,28 @@ GEM
fiddle (1.1.8) fiddle (1.1.8)
gosu (1.4.6) gosu (1.4.6)
io-endpoint (0.17.2) io-endpoint (0.17.2)
io-event (1.14.4) io-event (1.14.2)
io-stream (0.11.1) io-stream (0.11.1)
ircparser (1.0.0) ircparser (1.0.0)
json (2.19.2) json (2.18.0)
libui (0.2.0-x64-mingw-ucrt) libui (0.2.0-x64-mingw-ucrt)
fiddle fiddle
logger (1.7.0)
metrics (0.15.0) metrics (0.15.0)
protocol-hpack (1.5.1) protocol-hpack (1.5.1)
protocol-http (0.60.0) protocol-http (0.58.1)
protocol-http1 (0.37.0) protocol-http1 (0.37.0)
protocol-http (~> 0.58) protocol-http (~> 0.58)
protocol-http2 (0.24.0) protocol-http2 (0.24.0)
protocol-hpack (~> 1.4) protocol-hpack (~> 1.4)
protocol-http (~> 0.47) protocol-http (~> 0.47)
protocol-rack (0.22.0) protocol-rack (0.21.0)
io-stream (>= 0.10) io-stream (>= 0.10)
protocol-http (~> 0.58) protocol-http (~> 0.58)
rack (>= 1.0) rack (>= 1.0)
protocol-url (0.4.0) protocol-url (0.4.0)
protocol-websocket (0.20.2) protocol-websocket (0.20.2)
protocol-http (~> 0.2) protocol-http (~> 0.2)
rack (3.2.5) rack (3.2.4)
rake (13.3.1) rake (13.3.1)
rexml (3.4.4) rexml (3.4.4)
rubyzip (3.2.2) rubyzip (3.2.2)
@@ -91,7 +90,6 @@ DEPENDENCIES
digest-crc digest-crc
ircparser ircparser
libui libui
logger
rexml rexml
rubyzip rubyzip
sdl2-bindings sdl2-bindings

35
docs/README.md Normal file
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ class W3DHub
LOG_TAG = "W3DHub::Api".freeze LOG_TAG = "W3DHub::Api".freeze
API_TIMEOUT = 30 # seconds API_TIMEOUT = 10 # seconds
USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze USER_AGENT = "Cyberarm's Linux Friendly W3D Hub Launcher v#{W3DHub::VERSION}".freeze
DEFAULT_HEADERS = [ DEFAULT_HEADERS = [
["user-agent", USER_AGENT], ["user-agent", USER_AGENT],
@@ -16,31 +16,7 @@ class W3DHub
].freeze ].freeze
def self.on_thread(method, *args, &callback) def self.on_thread(method, *args, &callback)
BackgroundWorker.foreground_job(-> { Api.send(method, *args) }, callback) Api.send(method, *args, &callback)
end
class Response
def initialize(error: nil, status: -1, body: "")
@status = status
@body = body
@error = error
end
def success?
@status == 200
end
def status
@status
end
def body
@body
end
def error
@error
end
end end
#! === W3D Hub API === !# #! === W3D Hub API === !#
@@ -49,7 +25,9 @@ class W3DHub
HTTP_CLIENTS = {} HTTP_CLIENTS = {}
def self.async_http(method, path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub) def self.async_http(method:, path:, headers:, body:, backend:, async:, &callback)
raise "NO CALLBACK DEFINED!" unless callback
case backend case backend
when :w3dhub when :w3dhub
endpoint = W3DHUB_API_ENDPOINT endpoint = W3DHUB_API_ENDPOINT
@@ -60,15 +38,15 @@ class W3DHub
end end
# Handle arbitrary urls that may come through # Handle arbitrary urls that may come through
url = nil
if path.start_with?("http") if path.start_with?("http")
uri = URI(path) uri = URI(path)
endpoint = uri.origin endpoint = uri.origin
path = uri.request_uri path = uri.request_uri
else
url = "#{endpoint}#{path}"
end end
url = "#{endpoint}#{path}"
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." } logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." }
# Inject Authorization header if account data is populated # Inject Authorization header if account data is populated
@@ -78,52 +56,20 @@ class W3DHub
headers << ["authorization", "Bearer #{Store.account.access_token}"] headers << ["authorization", "Bearer #{Store.account.access_token}"]
end end
Sync do Store.network_manager.request(method, url, headers, body, async, &callback)
begin
response = provision_http_client(endpoint).send(method, path, headers, body)
Response.new(status: response.status, body: response.read)
rescue Async::TimeoutError => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
Response.new(error: e)
rescue StandardError => e
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
logger.error(LOG_TAG) { e }
Response.new(error: e)
ensure
response&.close
end
end
end end
def self.provision_http_client(hostname) def self.post(path:, headers: DEFAULT_HEADERS, body: nil, backend: :w3dhub, async: true, &callback)
# Pin http clients to their host Thread so the fiber scheduler doesn't get upset and raise an error async_http(method: :post, path: path, headers: headers, body: body, backend: backend, async: async, &callback)
HTTP_CLIENTS[Thread.current] ||= {}
return HTTP_CLIENTS[Thread.current][hostname.downcase] if HTTP_CLIENTS[Thread.current][hostname.downcase]
ssl_context = W3DHub.ca_bundle_path ? OpenSSL::SSL::SSLContext.new : nil
ssl_context&.set_params(
ca_file: W3DHub.ca_bundle_path,
verify_mode: OpenSSL::SSL::VERIFY_PEER
)
endpoint = Async::HTTP::Endpoint.parse(hostname, ssl_context: ssl_context)
HTTP_CLIENTS[Thread.current][hostname.downcase] = Async::HTTP::Client.new(endpoint)
end end
def self.post(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub) def self.get(path:, headers: DEFAULT_HEADERS, body: nil, backend: :w3dhub, async: true, &callback)
async_http(:post, path, headers, body, backend) async_http(method: :get, path: path, headers: headers, body: body, backend: backend, async: async, &callback)
end
def self.get(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
async_http(:get, path, headers, body, backend)
end end
# Api.get but handles any URL instead of known hosts # Api.get but handles any URL instead of known hosts
def self.fetch(path, headers = DEFAULT_HEADERS, body = nil, backend = nil) def self.fetch(path:, headers: DEFAULT_HEADERS, body: nil, backend: :w3dhub, async: true, &callback)
async_http(:get, path, headers, body, backend) async_http(method: :get, path: path, headers: headers, body: body, backend: backend, async: async, &callback)
end end
# Method: POST # Method: POST
@@ -141,220 +87,270 @@ class W3DHub
# #
# On a failed login the service responds with: # On a failed login the service responds with:
# {"error":"login-failed"} # {"error":"login-failed"}
def self.refresh_user_login(refresh_token, backend = :w3dhub) def self.refresh_user_login(refresh_token, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({refreshToken: refresh_token})) body = URI.encode_www_form("data": JSON.dump({ refreshToken: refresh_token }))
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 handler = lambda do |result|
user_data = JSON.parse(response.body, symbolize_names: true) if result.okay?
user_data = JSON.parse(result.data, symbolize_names: true)
return false if user_data[:error] if user_data[:error]
callback.call(CyberarmEngine::Result.new(data: false))
next
end
user_details_data = user_details(user_data[:userid]) || {} user_details_data = user_details(user_data[:userid]) || {}
Account.new(user_data, user_details_data) callback.call(CyberarmEngine::Result.new(data: Account.new(user_data, user_details_data)))
else else
logger.error(LOG_TAG) { "Failed to fetch refresh user login:" } logger.error(LOG_TAG) { "Failed to fetch refresh user login:" }
logger.error(LOG_TAG) { response } logger.error(LOG_TAG) { result.error }
false
callback.call(result)
end
end end
post(path: "/apis/launcher/1/user-login", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end end
# See #user_refresh_token # See #user_refresh_token
def self.user_login(username, password, backend = :w3dhub) def self.user_login(username, password, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({username: username, password: password})) body = URI.encode_www_form("data": JSON.dump({ username: username, password: password }))
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200 handler = lambda do |result|
user_data = JSON.parse(response.body, symbolize_names: true) if result.okay?
user_data = JSON.parse(result.data, symbolize_names: true)
return false if user_data[:error] if user_data[:error]
callback.call(CyberarmEngine::Result.new(data: false))
next
end
user_details_data = user_details(user_data[:userid]) || {} user_details_data = user_details(user_data[:userid]) || {}
Account.new(user_data, user_details_data) callback.call(CyberarmEngine::Result.new(data: Account.new(user_data, user_details_data)))
else else
logger.error(LOG_TAG) { "Failed to fetch user login:" } logger.error(LOG_TAG) { "Failed to fetch user login:" }
logger.error(LOG_TAG) { response } logger.error(LOG_TAG) { result.error }
false
callback.call(result)
end
end end
post(path: "/apis/launcher/1/user-login", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end end
# /apis/w3dhub/1/get-user-details # /apis/w3dhub/1/get-user-details
# #
# Response: avatar-uri (Image download uri), id, username # Response: avatar-uri (Image download uri), id, username
def self.user_details(id, backend = :w3dhub) def self.user_details(id, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({ id: id })) body = URI.encode_www_form("data": JSON.dump({ id: id }))
user_details = post("/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body, backend)
if user_details.status == 200 handler = lambda do |result|
JSON.parse(user_details.body, symbolize_names: true) if result.okay?
else callback.call(CyberarmEngine::Result.new(data: JSON.parse(result.data, symbolize_names: true)))
logger.error(LOG_TAG) { "Failed to fetch user details:" } else
logger.error(LOG_TAG) { user_details } logger.error(LOG_TAG) { "Failed to fetch user details:" }
false logger.error(LOG_TAG) { result.error }
callback.call(result)
end
end end
post(path: "/apis/w3dhub/1/get-user-details", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end end
# /apis/w3dhub/1/get-service-status # /apis/w3dhub/1/get-service-status
# Service response: # Service response:
# {"services":{"authentication":true,"packageDownload":true}} # {"services":{"authentication":true,"packageDownload":true}}
def self.service_status(backend = :w3dhub) def self.service_status(backend = :w3dhub, &callback)
response = post("/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS, nil, backend) handler = lambda do |result|
if result.okay?
callback.call(CyberarmEngine::Result.new(data: ServiceStatus.new(result.data)))
else
logger.error(LOG_TAG) { "Failed to fetch service status:" }
logger.error(LOG_TAG) { result.error }
if response.status == 200 callback.call(result)
ServiceStatus.new(response.body) end
else
logger.error(LOG_TAG) { "Failed to fetch service status:" }
logger.error(LOG_TAG) { response }
false
end end
post(path: "/apis/w3dhub/1/get-service-status", backend: backend, &handler)
end end
# /apis/launcher/1/get-applications # /apis/launcher/1/get-applications
# Client sends an Authorization header bearer token which is received from logging in (Optional) # Client sends an Authorization header bearer token which is received from logging in (Optional)
# Launcher sends an empty data request: data={} # Launcher sends an empty data request: data={}
# Response is a list of applications/games # Response is a list of applications/games
def self.applications(backend = :w3dhub) def self.applications(backend = :w3dhub, &callback)
response = post("/apis/launcher/1/get-applications", DEFAULT_HEADERS, nil, backend) async = !callback.nil?
if response.status == 200 # Complicated why to "return" direct value
Applications.new(response.body, backend) callback = ->(result) { result }
else
logger.error(LOG_TAG) { "Failed to fetch applications list:" } handler = lambda do |result|
logger.error(LOG_TAG) { response } if result.okay?
false callback.call(CyberarmEngine::Result.new(data: Applications.new(result.data, backend)))
else
logger.error(LOG_TAG) { "Failed to fetch applications list:" }
logger.error(LOG_TAG) { result.error }
callback.call(result)
end
end end
post(path: "/apis/launcher/1/get-applications", async: async, backend: backend, &handler)
end end
# Populate applications list from primary and alternate backends # Populate applications list from primary and alternate backends
# (alternate only has latest public builds of _most_ games) # (alternate only has latest public builds of _most_ games)
def self._applications def self._applications(&callback)
applications_primary = Store.account ? Api.applications(:w3dhub) : false handler = lambda do |result|
applications_alternate = Api.applications(:alt_w3dhub) # nothing special on offer if we're not logged in
applications_primary = Store.account ? Api.applications(:w3dhub).data : false
applications_alternate = Api.applications(:alt_w3dhub).data
# Fail if we fail to fetch applications list from either backend # Fail if we fail to fetch applications list from either backend
return false unless applications_primary || applications_alternate unless applications_primary || applications_alternate
callback.call(CyberarmEngine::Result.new)
next
end
return applications_alternate unless applications_primary unless applications_primary
callback.call(CyberarmEngine::Result.new(data: applications_alternate))
next
end
# Merge the two app lists together # Merge the two app lists together
apps = applications_alternate apps = applications_alternate
if applications_primary if applications_primary
applications_primary.games.each do |game| applications_primary.games.each do |game|
# Check if game exists in alternate list # Check if game exists in alternate list
_game = apps.games.find { |g| g.id == game.id } _game = apps.games.find { |g| g.id == game.id }
unless _game unless _game
apps.games << game apps.games << game
# App didn't exist in alternates list # App didn't exist in alternates list
# comparing channels isn't useful # 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 next
end end
# If channel versions and access levels match then all's well # If it does, check that all of its channels also exist in alternate list
if channel.current_version == _channel.current_version && # and that the primary versions are the same as the alternates list
channel.user_level == _channel.user_level game.channels.each do |channel|
_channel = _game.channels.find { |c| c.id == channel.id }
# All's Well! unless _channel
next _game.channels << channel
end
# If the access levels don't match then overwrite alternate's channel with primary's channel # App didn't have channel in alternates list
if channel.user_level != _channel.user_level # comparing channel isn't useful
# Replace alternate's channel with primary's channel next
_game.channels[_game.channels.index(_channel)] = channel end
# Replaced, continue. # If channel versions and access levels match then all's well
next if channel.current_version == _channel.current_version &&
end channel.user_level == _channel.user_level
# If versions don't match then pick whichever one is higher # All's Well!
if Gem::Version.new(channel.current_version) > Gem::Version.new(_channel.current_version) next
# Replace alternate's channel with primary's channel end
_game.channels[_game.channels.index(_channel)] = channel
else # If the access levels don't match then overwrite alternate's channel with primary's channel
# Do nothing, alternate backend version is greater. 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 end
end end
callback.call(CyberarmEngine::Result.new(data: apps))
end end
apps # Bit hacky but we just need to run this handler from the networking thread and async reactor
get(path: "", backend: nil, &handler)
end end
# /apis/w3dhub/1/get-news # /apis/w3dhub/1/get-news
# Client sends an Authorization header bearer token which is received from logging in (Optional) # 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) # 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 # Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one
def self.news(category, backend = :w3dhub) def self.news(category, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({category: category})) handler = lambda do |result|
response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend) if result.okay?
callback.call(CyberarmEngine::Result.new(data: News.new(result.data)))
else
logger.error(LOG_TAG) { "Failed to fetch news for:" }
logger.error(LOG_TAG) { category }
logger.error(LOG_TAG) { result.error }
if response.status == 200 callback.call(result)
News.new(response.body) end
else
logger.error(LOG_TAG) { "Failed to fetch news for:" }
logger.error(LOG_TAG) { category }
logger.error(LOG_TAG) { response }
false
end end
body = URI.encode_www_form("data": JSON.dump({ category: category }))
post(path: "/apis/w3dhub/1/get-news", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end end
# Downloading games # Downloading games
# /apis/launcher/1/get-package-details # /apis/launcher/1/get-package-details
# client requests package details: data={"packages":[{"category":"games","name":"apb.ico","subcategory":"apb","version":""}]} # client requests package details: data={"packages":[{"category":"games","name":"apb.ico","subcategory":"apb","version":""}]}
def self.package_details(packages, backend = :w3dhub) def self.package_details(packages, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({ packages: packages })) handler = lambda do |result|
response = post("/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body, backend) if result.okay?
hash = JSON.parse(result.data, symbolize_names: true)
if response.status == 200 callback.call(CyberarmEngine::Result.new(data: hash[:packages].map { |pkg| Package.new(pkg) }))
hash = JSON.parse(response.body, symbolize_names: true) else
logger.error(LOG_TAG) { "Failed to fetch package details for:" }
logger.error(LOG_TAG) { packages }
logger.error(LOG_TAG) { result.error }
hash[:packages].map { |pkg| Package.new(pkg) } callback.call(result)
else end
logger.error(LOG_TAG) { "Failed to fetch package details for:" }
logger.error(LOG_TAG) { packages }
logger.error(LOG_TAG) { response }
false
end end
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
post(path: "/apis/launcher/1/get-package-details", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end end
# /apis/launcher/1/get-package # /apis/launcher/1/get-package
# client requests package: data={"category":"games","name":"ECW_Asteroids.zip","subcategory":"ecw","version":"1.0.0.0"} # client requests package: data={"category":"games","name":"ECW_Asteroids.zip","subcategory":"ecw","version":"1.0.0.0"}
# #
# server responds with download bytes, probably supports chunked download and resume # server responds with download bytes, probably supports chunked download and resume
def self.package(package, &block) # FIXME: REFACTOR Cache.fetch_package to use HttpClient
Cache.fetch_package(package, block) def self.package(package, &callback)
Cache.fetch_package(package, callback)
end end
# /apis/w3dhub/1/get-events # /apis/w3dhub/1/get-events
# #
# clients requests events: data={"serverPath":"apb"} # clients requests events: data={"serverPath":"apb"}
def self.events(app_id, backend = :w3dhub) def self.events(app_id, backend = :w3dhub, &callback)
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id })) handler = lambda do |result|
response = post("/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body, backend) if result.okay?
array = JSON.parse(result.data, symbolize_names: true)
if response.status == 200 callback.call(CyberarmEngine::Result.new(data: array.map { |e| Event.new(e) }))
array = JSON.parse(response.body, symbolize_names: true) else
array.map { |e| Event.new(e) } callback.call(result)
else end
false
end end
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
post(path: "/apis/w3dhub/1/get-server-events", headers: FORM_ENCODED_HEADERS, body: body, backend: backend, &handler)
end end
#! === Server List API === !# #! === Server List API === !#
@@ -380,15 +376,17 @@ class W3DHub
# id, name, score, kills, deaths # id, name, score, kills, deaths
# ...players[]: # ...players[]:
# nick, team (index of teams array), score, kills, deaths # nick, team (index of teams array), score, kills, deaths
def self.server_list(level = 1, backend = :gsh) def self.server_list(level = 1, backend = :gsh, &callback)
response = get("/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend) handler = lambda do |result|
if result.okay?
if response.status == 200 data = JSON.parse(result.data, symbolize_names: true)
data = JSON.parse(response.body, symbolize_names: true) callback.call(CyberarmEngine::Result.new(data: data.map { |hash| ServerListServer.new(hash) }))
return data.map { |hash| ServerListServer.new(hash) } else
callback.call(result)
end
end end
false get(path: "/listings/getAll/v2?statusLevel=#{level}", backend: backend, &handler)
end end
# /listings/getStatus/v2/:id?statusLevel=#{0-2} # /listings/getStatus/v2/:id?statusLevel=#{0-2}
@@ -402,23 +400,24 @@ class W3DHub
# id, name, score, kills, deaths # id, name, score, kills, deaths
# ...players[]: # ...players[]:
# nick, team (index of teams array), score, kills, deaths # nick, team (index of teams array), score, kills, deaths
def self.server_details(id, level, backend = :gsh) def self.server_details(id, level, backend = :gsh, &callback)
return false unless id && level return false unless id && level
response = get("/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend) handler = lambda do |result|
if result.okay?
if response.status == 200 callback.call(CyberarmEngine::Result.new(data: JSON.parse(result.data, symbolize_names: true)))
hash = JSON.parse(response.body, symbolize_names: true) else
return hash callback.call(result)
end
end end
false get(path: "/listings/getStatus/v2/#{id}?statusLevel=#{level}", backend: backend, &handler)
end end
# /listings/push/v2/negotiate?negotiateVersion=1 # /listings/push/v2/negotiate?negotiateVersion=1
##? /listings/push/v2/?id=#{websocket token?} ##? /listings/push/v2/?id=#{websocket token?}
## Websocket server list listener ## Websocket server list listener
def self.server_list_push(id) def self.server_list_push(id, &callback)
end end
end end
end end

View File

@@ -20,8 +20,12 @@ class W3DHub
@status = Status.new(@data[:status]) @status = Status.new(@data[:status])
@ping_interval = 30_000 # if we're on unix and using the PingManager then check every second since
# we're not _actually_ pinging the server.
@ping_interval = W3DHub.unix? ? 1_000 : 60_000
@last_pinged = Gosu.milliseconds + @ping_interval + 1_000 @last_pinged = Gosu.milliseconds + @ping_interval + 1_000
Store.ping_manager.add_address(@address)
end end
def update(hash) def update(hash)
@@ -49,6 +53,16 @@ class W3DHub
if force_ping || Gosu.milliseconds - @last_pinged >= @ping_interval if force_ping || Gosu.milliseconds - @last_pinged >= @ping_interval
@last_pinged = Gosu.milliseconds @last_pinged = Gosu.milliseconds
if W3DHub.unix?
average_ping = Store.ping_manager.ping_for(@address)
@ping = average_ping.negative? ? NO_OR_BAD_PING : average_ping
States::Interface.instance&.update_server_ping(self)
end
return #unless W3DHub.windows?
W3DHub::BackgroundWorker.foreground_parallel_job( W3DHub::BackgroundWorker.foreground_parallel_job(
lambda do lambda do
W3DHub.command("ping #{@address} #{W3DHub.windows? ? '-n 3' : '-c 3'}") do |line| W3DHub.command("ping #{@address} #{W3DHub.windows? ? '-n 3' : '-c 3'}") do |line|

View File

@@ -23,7 +23,7 @@ class W3DHub
@invocation_id = 0 @invocation_id = 0
logger.info(LOG_TAG) { "Starting emulated SignalR Server List Updater..." } logger.info(LOG_TAG) { "Starting emulated SignalR Server List Updater..." }
run # run
end end
def run def run
@@ -32,7 +32,8 @@ class W3DHub
begin begin
@auto_reconnect = true @auto_reconnect = true
while W3DHub::BackgroundWorker.alive? # FIXME
while true #W3DHub::BackgroundWorker.alive?
connect if @auto_reconnect connect if @auto_reconnect
sleep @reconnection_delay sleep @reconnection_delay
end end
@@ -54,19 +55,31 @@ class W3DHub
@auto_reconnect = false @auto_reconnect = false
logger.debug(LOG_TAG) { "Requesting connection token..." } logger.debug(LOG_TAG) { "Requesting connection token..." }
response = Api.post("/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh)
if response.status != 200 result = nil
Api.post("/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh) do |callback_result|
result = callback_result
end
# FIXME: we've introduced ourselves to callback hell, yay!
while result.nil?
sleep 0.1
end
if result.error?
@auto_reconnect = true @auto_reconnect = true
@reconnection_delay = @reconnection_delay * 2 @reconnection_delay *= 2
@reconnection_delay = 60 if @reconnection_delay > 60 @reconnection_delay = 60 if @reconnection_delay > 60
return return
end end
@reconnection_delay = 1 @reconnection_delay = 1
data = JSON.parse(response.body, symbolize_names: true) connect_websocket(JSON.parse(result.data, symbolize_names: true))
end
def connect_websocket(data)
@invocation_id = 0 if @invocation_id > 9095 @invocation_id = 0 if @invocation_id > 9095
id = data[:connectionToken] id = data[:connectionToken]
endpoint = "#{Api::SERVER_LIST_ENDPOINT}/listings/push/v2?id=#{id}" endpoint = "#{Api::SERVER_LIST_ENDPOINT}/listings/push/v2?id=#{id}"

View File

@@ -23,7 +23,7 @@ class W3DHub
# unpack packages # unpack packages
# install dependencies (e.g. visual C runtime) # install dependencies (e.g. visual C runtime)
@tasks.push(Installer.new(app_id, channel)) @tasks.push(Installer.new(context: task_context(app_id, channel, "version")))
end end
def update(app_id, channel) def update(app_id, channel)
@@ -31,7 +31,9 @@ class W3DHub
return false unless installed?(app_id, channel) return false unless installed?(app_id, channel)
@tasks.push(Updater.new(app_id, channel)) updater = Updater.new(Installer.new(context: task_context(app_id, channel, "version")))
@tasks.push(updater)
end end
def import(app_id, channel) def import(app_id, channel)
@@ -94,6 +96,18 @@ class W3DHub
Process.spawn(exe) Process.spawn(exe)
end end
def task_context(app_id, channel, version)
Task::Context.new(
SecureRandom.hex,
"games",
app_id,
channel,
version,
"",
""
)
end
def repair(app_id, channel) def repair(app_id, channel)
logger.info(LOG_TAG) { "Repair Installation Request: #{app_id}-#{channel}" } logger.info(LOG_TAG) { "Repair Installation Request: #{app_id}-#{channel}" }
@@ -106,7 +120,7 @@ class W3DHub
# unpack packages # unpack packages
# install dependencies (e.g. visual C runtime) if appropriate # install dependencies (e.g. visual C runtime) if appropriate
@tasks.push(Repairer.new(app_id, channel)) @tasks.push(Repairer.new(context: task_context(app_id, channel, "version")))
end end
def uninstall(app_id, channel) def uninstall(app_id, channel)
@@ -121,7 +135,7 @@ class W3DHub
title: "Uninstall #{game.name}?", title: "Uninstall #{game.name}?",
message: "Are you sure you want to uninstall #{game.name} (#{channel})?", message: "Are you sure you want to uninstall #{game.name} (#{channel})?",
accept_callback: proc { accept_callback: proc {
@tasks.push(Uninstaller.new(app_id, channel)) @tasks.push(Uninstaller.new(context: task_context(app_id, channel, "version")))
} }
) )
end end
@@ -174,6 +188,7 @@ class W3DHub
return vars if W3DHub.windows? return vars if W3DHub.windows?
vars["WINEPREFIX"] = Store.settings[:wine_prefix] unless Store.settings[:wine_prefix].to_s.empty? vars["WINEPREFIX"] = Store.settings[:wine_prefix] unless Store.settings[:wine_prefix].to_s.empty?
# vars["WINEDEBUG"] = "-all" if true # TODO make this an option. wine debug interferences with pid returned from Process.spawn
vars vars
end end
@@ -243,7 +258,6 @@ class W3DHub
wine_enviroment_variables(app_id, channel) wine_enviroment_variables(app_id, channel)
) )
end end
attempted = false attempted = false
begin begin
pid = Process.spawn( pid = Process.spawn(
@@ -255,6 +269,7 @@ class W3DHub
"-launcher #{args.join(' ')}" "-launcher #{args.join(' ')}"
) )
Process.detach(pid) Process.detach(pid)
BackgroundWorker.foreground_parallel_job(-> { monitor_process(app_id, channel, pid) }, ->(result) { handle_process_result(app_id, channel, result) })
rescue Errno::EINVAL => e rescue Errno::EINVAL => e
retryable = !attempted retryable = !attempted
attempted = true attempted = true
@@ -267,14 +282,51 @@ class W3DHub
end end
end end
def monitor_process(app_id, channel, pid)
key = "#{app_id}-#{channel}"
@running_applications[key] = pid
status = Process::Status.wait(pid)
pp [pid, status]
@running_applications.delete(key)
status
end
def handle_process_result(app_id, channel, status)
pp [app_id, channel, status]
# Everything's fine
return if status.pid >= 0 && status.success?
# Everything's not fine
reason = status.pid.positive? ? "Crashed" : "Failed to Launch"
game = Store.applications.games.find { |g| g.id == app_id }
title = "#{reason}: #{game.name}" if game
title = "Application #{reason}" unless game
message = if status.pid.negative?
"Command Not Found."
else
"Application crashed."
end
push_state(
States::MessageDialog,
title: title,
message: message,
accept_callback: proc {
}
)
end
def join_server(app_id, channel, server, username = Store.settings[:server_list_username], password = nil, multi = false) def join_server(app_id, channel, server, username = Store.settings[:server_list_username], password = nil, multi = false)
return unless installed?(app_id, channel) && username.to_s.length.positive? return unless installed?(app_id, channel) && username.to_s.length.positive?
run( run(
app_id, channel, app_id, channel,
"+connect #{server.address}:#{server.port} "\ "+connect #{server.address}:#{server.port} +netplayername #{username}#{password ? " +password \"#{password}\"" : ""}#{multi ? " +multi" : ""}"
"+netplayername #{username}#{password ? " +password \"#{password}\"" : ""}"\
"#{multi ? " +multi" : ""}"
) )
end end
@@ -503,7 +555,7 @@ class W3DHub
end end
def installing?(app_id, channel) def installing?(app_id, channel)
@tasks.find { |t| t.is_a?(Installer) && t.app_id == app_id && t.release_channel == channel } @tasks.find { |t| t.is_a?(Installer) && t.context.app_id == app_id && t.context.channel_id == channel }
end end
def updateable?(app_id, channel) def updateable?(app_id, channel)
@@ -556,6 +608,54 @@ class W3DHub
app.channels.detect { |g| g.id.to_s == channel_id.to_s } app.channels.detect { |g| g.id.to_s == channel_id.to_s }
end end
def handle_task_event(event)
# ONLY CALL on MAIN Ractor
raise "Something has gone horribly wrong!" unless Ractor.main?
pp event
task = @tasks.find { |t| t.context.task_id == event.task_id }
return unless task # FIXME: This is probably a fatal error
case event.type
when Task::EVENT_FAILURE
Store.main_thread_queue << proc do
window.push_state(
W3DHub::States::MessageDialog,
type: event.data[:type],
title: event.data[:title],
message: event.data[:message]
)
end
# FIXME: Send event to Games page to trigger refresh
States::Interface.instance&.hide_application_taskbar
@tasks.delete(task)
when Task::EVENT_START
States::Interface.instance&.show_application_taskbar
when Task::EVENT_SUCCESS
States::Interface.instance&.hide_application_taskbar
@tasks.delete(task)
# FIXME: Send event to Games page to trigger refresh
when Task::EVENT_STATUS
task.status = event.data
States::Interface.instance&.update_interface_task_status(task)
when Task::EVENT_STATUS_OPERATION
hash = event.data
operation = task.status.operations[operation[:id]]
if operation
operation.label = hash[:label]
operation.value = hash[:value]
operation.progress = hash[:progress]
States::Interface.instance&.update_interface_task_status(task)
end
end
end
# No application tasks are being done # No application tasks are being done
def idle? def idle?
!busy? !busy?
@@ -576,15 +676,37 @@ class W3DHub
@tasks.delete_if { |t| t.state == :complete || t.state == :halted || t.state == :failed } @tasks.delete_if { |t| t.state == :complete || t.state == :halted || t.state == :failed }
task = @tasks.find { |t| t.state == :not_started } task = @tasks.find { |t| t.state == :not_started }
task&.start
return unless task
# mark MAIN ractor's task as started before handing off to background ractor
# so that we don't start up multiple tasks at once.
task.start
on_ractor(task)
end
def on_ractor(task)
raise "Something has gone horribly wrong!!!" unless Ractor.main?
ractor = Ractor.new(task) do |t|
t.start
end
Thread.new do
while (message_event = ractor.take)
break unless message_event.is_a?(Task::MessageEvent)
Store.application_manager.handle_task_event(message_event)
end
end
end end
def task?(type, app_id, channel) def task?(type, app_id, channel)
@tasks.find do |t| @tasks.find do |t|
t.type == type && t.type == type &&
t.app_id == app_id && t.context.app_id == app_id &&
t.release_channel == channel && t.context.channel_id == channel &&
[ :not_started, :running, :paused ].include?(t.state) [ :not_started, :running, :paused ].include?(t.state)
end end
end end
end end

View File

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

View File

@@ -8,9 +8,7 @@ class W3DHub
end end
def execute_task def execute_task
show_application_taskbar fail_fast!
fail_fast
return false if failed? return false if failed?
fetch_manifests fetch_manifests
@@ -46,9 +44,6 @@ class W3DHub
mark_application_installed mark_application_installed
return false if failed? return false if failed?
sleep 1
hide_application_taskbar
true true
end end
end end

View File

@@ -76,7 +76,7 @@ class W3DHub
end end
def save_config(config = @config) def save_config(config = @config)
File.write(CONFIG_PATH, JSON.pretty_generate(config)) File.write(CONFIG_PATH, config.to_json)
end end
end end
end end

View File

@@ -1,166 +0,0 @@
class W3DHub
class BackgroundWorker
LOG_TAG = "W3DHub::BackgroundWorker"
@@instance = nil
@@alive = false
def self.create
raise "BackgroundWorker instance already exists!" if @@instance
logger.info(LOG_TAG) { "Starting background job worker..." }
@@thread = Thread.current
@@alive = true
@@run = true
@@instance = self.new
@@instance.handle_jobs
end
def self.instance
@@instance
end
def self.run?
@@run
end
def self.alive?
@@alive
end
def self.busy?
instance&.busy?
end
def self.shutdown!
@@run = false
end
def self.kill!
@@thread.kill
@@instance.kill!
end
def self.job(job, callback, error_handler = nil, 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

@@ -9,19 +9,17 @@ class W3DHub
end end
# Fetch a generic uri # Fetch a generic uri
def self.fetch(uri:, force_fetch: false, async: true, backend: :w3dhub) def self.fetch(uri:, force_fetch: false, backend: :w3dhub)
path = path(uri) path = path(uri)
if !force_fetch && File.exist?(path) if !force_fetch && File.exist?(path)
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 else
response = Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend) Api.fetch(path: uri, backend: backend) do |result|
File.open(path, "wb") { |f| f.write response.body } if response.status == 200 if result.okay?
File.open(path, "wb") { |f| f.write result.data }
end
end
end end
end end
@@ -90,8 +88,6 @@ class W3DHub
result = true result = true
end end
binding.irb unless response
if response&.status == 200 || response&.status == 206 if response&.status == 200 || response&.status == 206
result = true result = true
else else

View File

@@ -1,4 +1,8 @@
class W3DHub class W3DHub
PLATFORM_WINDOWS = RbConfig::CONFIG["host_os"] =~ /(mingw|mswin|windows)/i
PLATFORM_DARWIN = RbConfig::CONFIG["host_os"] =~ /(darwin|mac os)/i
PLATFORM_LINUX = RbConfig::CONFIG["host_os"] =~ /(linux|bsd|aix|solaris)/i
def self.format_size(bytes) def self.format_size(bytes)
case bytes case bytes
when 0..1023 # Bytes when 0..1023 # Bytes
@@ -17,15 +21,15 @@ class W3DHub
end end
def self.windows? def self.windows?
RbConfig::CONFIG["host_os"] =~ /(mingw|mswin|windows)/i PLATFORM_WINDOWS
end end
def self.mac? def self.mac?
RbConfig::CONFIG["host_os"] =~ /(darwin|mac os)/i PLATFORM_DARWIN
end end
def self.linux? def self.linux?
RbConfig::CONFIG["host_os"] =~ /(linux|bsd|aix|solaris)/i PLATFORM_LINUX
end end
def self.unix? def self.unix?
@@ -54,49 +58,51 @@ class W3DHub
end end
def self.prompt_for_nickname(accept_callback: nil, cancel_callback: nil) def self.prompt_for_nickname(accept_callback: nil, cancel_callback: nil)
CyberarmEngine::Window.instance.push_state( CyberarmEngine::Window.instance.push_state(
W3DHub::States::PromptDialog, W3DHub::States::PromptDialog,
title: I18n.t(:"server_browser.set_nickname"), title: I18n.t(:"server_browser.set_nickname"),
message: I18n.t(:"server_browser.set_nickname_message"), message: I18n.t(:"server_browser.set_nickname_message"),
prefill: Store.settings[:server_list_username], prefill: Store.settings[:server_list_username],
accept_callback: accept_callback, accept_callback: accept_callback,
cancel_callback: cancel_callback, cancel_callback: cancel_callback,
valid_callback: proc do |entry| # See: https://gitlab.com/danpaul88/brenbot/-/blob/master/Source/renlog.pm#L136-175
entry.length.between?(3, 40) && (entry =~ /^[a-z0-9_\-\[\]]+$/i) valid_callback: proc do |entry|
entry.length > 1 && entry.length < 30 && (entry =~ /(:|!|&|%| )/i).nil? &&
(entry =~ /[\001\002\037]/).nil? && (entry =~ /\\/).nil?
end
)
end end
)
end
def self.prompt_for_password(accept_callback: nil, cancel_callback: nil) def self.prompt_for_password(accept_callback: nil, cancel_callback: nil)
CyberarmEngine::Window.instance.push_state( CyberarmEngine::Window.instance.push_state(
W3DHub::States::PromptDialog, W3DHub::States::PromptDialog,
title: I18n.t(:"server_browser.enter_password"), title: I18n.t(:"server_browser.enter_password"),
message: I18n.t(:"server_browser.enter_password_message"), message: I18n.t(:"server_browser.enter_password_message"),
input_type: :password, input_type: :password,
accept_callback: accept_callback, accept_callback: accept_callback,
cancel_callback: cancel_callback, cancel_callback: cancel_callback,
valid_callback: proc { |entry| entry.length.positive? } valid_callback: proc { |entry| entry.length.positive? }
) )
end end
def self.join_server(server:, username: Store.settings[:server_list_username], password: nil, multi: false) def self.join_server(server:, username: Store.settings[:server_list_username], password: nil, multi: false)
if ( if (
(server.status.password && password.length.positive?) || (server.status.password && password.length.positive?) ||
!server.status.password) && !server.status.password) &&
username.to_s.length.positive? username.to_s.length.positive?
Store.application_manager.join_server( Store.application_manager.join_server(
server.game, server.game,
server.channel, server.channel,
server, server,
username, username,
password, password,
multi multi
) )
else else
CyberarmEngine::Window.instance.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?") CyberarmEngine::Window.instance.push_state(W3DHub::States::MessageDialog, type: "?", title: "?", message: "?")
end end
end end
def self.command(command, &block) def self.command(command, &block)
if windows? if windows?
@@ -121,6 +127,7 @@ class W3DHub
process_info = Process.create(**hash) process_info = Process.create(**hash)
pid = process_info.process_id pid = process_info.process_id
status = -1
until (status = Process.get_exitcode(pid)) until (status = Process.get_exitcode(pid))
if block if block

View File

@@ -2,7 +2,7 @@ class W3DHub
class HardwareSurvey class HardwareSurvey
attr_reader :data attr_reader :data
def initialize(displays_only: false) def initialize
@data = { @data = {
displays: [], displays: [],
system: { system: {
@@ -26,6 +26,8 @@ class W3DHub
} }
} }
# Hardware survey only works on Windows atm
if Gem::win_platform? if Gem::win_platform?
lib_dir = File.dirname($LOADED_FEATURES.find { |file| file.include?("gosu.so") }) lib_dir = File.dirname($LOADED_FEATURES.find { |file| file.include?("gosu.so") })
SDL.load_lib("#{lib_dir}64/SDL2.dll") SDL.load_lib("#{lib_dir}64/SDL2.dll")
@@ -34,13 +36,11 @@ class W3DHub
end end
query_displays query_displays
unless displays_only query_motherboard
query_motherboard query_operating_system
query_operating_system query_cpus
query_cpus query_ram
query_ram query_gpus
query_gpus
end
@data.freeze @data.freeze
end end
@@ -68,51 +68,37 @@ class W3DHub
end end
def query_motherboard def query_motherboard
if Gem::win_platform? return unless Gem::win_platform?
begin
Win32::Registry::HKEY_LOCAL_MACHINE.open("HARDWARE\\DESCRIPTION\\System\\BIOS", Win32::Registry::KEY_READ) do |reg| 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][:manufacturer] = safe_reg(reg, "SystemManufacturer")
@data[:system][:motherboard][:model] = safe_reg(reg, "SystemProductName") @data[:system][:motherboard][:model] = safe_reg(reg, "SystemProductName")
@data[:system][:motherboard][:bios_vendor] = safe_reg(reg, "BIOSVendor") @data[:system][:motherboard][:bios_vendor] = safe_reg(reg, "BIOSVendor")
@data[:system][:motherboard][:bios_release_date] = safe_reg(reg, "BIOSReleaseDate") @data[:system][:motherboard][:bios_release_date] = safe_reg(reg, "BIOSReleaseDate")
@data[:system][:motherboard][:bios_version] = safe_reg(reg, "BIOSVersion") @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
else # unix
@data[:system][:motherboard][:manufacturer] = safe_file("/sys/devices/virtual/dmi/id/board_vendor")
@data[:system][:motherboard][:model] = safe_file("/sys/devices/virtual/dmi/id/board_name")
@data[:system][:motherboard][:bios_version] = safe_file("/sys/devices/virtual/dmi/id/board_version")
end 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 end
def query_operating_system def query_operating_system
if Gem::win_platform? return unless Gem::win_platform?
begin
Win32::Registry::HKEY_LOCAL_MACHINE.open("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", Win32::Registry::KEY_READ) do |reg| 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][:name] = safe_reg(reg, "ProductName")
@data[:system][:operating_system][:build] = safe_reg(reg, "CurrentBuild") @data[:system][:operating_system][:build] = safe_reg(reg, "CurrentBuild")
@data[:system][:operating_system][:version] = safe_reg(reg, "DisplayVersion") @data[:system][:operating_system][:version] = safe_reg(reg, "DisplayVersion")
@data[:system][:operating_system][:edition] = safe_reg(reg, "EditionID") @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
else # unix
release_info = query_release_info
@data[:system][:operating_system][:name] = release_info["pretty_name"] || release_info["name"] || "Unknown"
@data[:system][:operating_system][:build] = release_info["version_codename"] || release_info["build_id"] || "Unknown"
@data[:system][:operating_system][:version] = release_info["version_id"] || release_info["build_id"] || "Unknown"
@data[:system][:operating_system][:edition] = release_info["id"] || release_info["id_like"] || "Unknown"
end 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 end
def query_cpus def query_cpus
@@ -136,16 +122,6 @@ class W3DHub
end end
rescue Win32::Registry::Error rescue Win32::Registry::Error
end end
else
cpu_info = query_cpu_info
cpu_info.each do |cpu|
@data[:system][:cpus] << {
manufacturer: cpu["manufacturer"] || "Unknown",
model: cpu["model"] || "Unknown",
mhz: cpu["mhz"] || "Unknown",
family: cpu["family"] || "Unknown"
}
end
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 instruction_sets = %w[ HasRDTSC HasAltiVec HasMMX Has3DNow HasSSE HasSSE2 HasSSE3 HasSSE41 HasSSE42 HasAVX HasAVX2 HasAVX512F HasARMSIMD HasNEON ] # HasLSX HasLASX # These cause a crash atm
@@ -164,57 +140,44 @@ class W3DHub
end end
def query_gpus def query_gpus
if Gem::win_platform? return unless Gem::win_platform?
begin
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, _| Win32::Registry::HKEY_LOCAL_MACHINE.open("SYSTEM\\ControlSet001\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}", Win32::Registry::KEY_READ) do |reg|
next unless key.start_with?("0") i = 0
reg.open(key) do |device| 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 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 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
rescue Win32::Registry::Error
end
else # unix
gpu_info = query_glx_info
gpu_info.each do |gpu|
@data[:system][:gpus] << {
manufacturer: gpu["manufacturer"] || "Unknown",
model: gpu["model"] || "Unknown",
vram: gpu["vram"].to_i,
driver_date: gpu["driver_date"] || "Unknown",
driver_version: gpu["driver_version"] || "Unknown"
}
end end
end end
rescue Win32::Registry::Error
end end
def safe_reg(reg, key, default_value = "Unknown") def safe_reg(reg, key, default_value = "Unknown")
@@ -222,130 +185,5 @@ class W3DHub
rescue Win32::Registry::Error rescue Win32::Registry::Error
default_value default_value
end end
def safe_file(path, default_value = "Unknown")
value = File.read(path).to_s.strip
return default_value if value.downcase == "default string"
value
rescue
default_value
end
def query_release_info
hash = {}
File.open("/etc/os-release") do |f|
f.each_line do |line|
line = line.strip
key, value = line.split("=", 2)
value.gsub!('"', "")
hash[key.downcase] = value
end
end
hash
rescue
hash
end
def query_cpu_info
cpus = []
cpu = {}
File.open("/proc/cpuinfo") do |f|
f.each_line do |line|
line = line.strip
if line.empty?
cpu["family"] = format(
"%s Family %s Model %s Stepping %s",
cpu["manufacturer"] || "Unknown",
cpu["_family"] || "Unknown",
cpu["_model"] || "Unknown",
cpu["_stepping"] || "Unknown",
)
cpus << cpu
cpu = {}
next
end
key, value = line.split(":", 2).map(&:strip)
case key.downcase
when "vendor_id"
cpu["manufacturer"] = value
when "model name"
cpu["model"] = value
when "cpu mhz"
cpu["mhz"] = value
when "cpu family"
cpu["_family"] = value
when "model"
cpu["_model"] = value
when "stepping"
cpu["_stepping"] = value
end
end
end
cpus
rescue
cpus
end
def query_glx_info
gpus = []
glxinfo = `glxinfo`
return gpus if glxinfo.empty?
gpu = {}
glxinfo.lines do |line|
line = line.strip
next if line.empty?
key, value = line.split(":", 2).map(&:strip)
mesa_info = false
gpu_memory_info = false
case key.downcase
when "opengl vendor string"
if mesa_info
gpus << gpu
gpu = {}
break
end
when /extended renderer info \(GLX_MESA_query_renderer\)/i
# Joy and happiness
mesa_info = true
when /Memory info \(GL_NVX_gpu_memory_info\)/i
# Happiness and joy
gpu_memory_info = true
when "vendor", "opengl vendor string"
gpu["manufacturer"] = value
when "device", "opengl renderer string"
gpu["model"] = value
when "version"
gpu["driver_version"] = value
when "video memory", "dedicated video memory"
gpu["vram"] = value.gsub(/[\D]+/, "")
when "opengl version string"
gpus << gpu
gpu = {}
break
end
end
gpus
end
end end
end end

92
lib/network_manager.rb Normal file
View File

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

View File

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

View File

@@ -65,15 +65,7 @@ class W3DHub
para I18n.t(:"games.fetching_news"), padding: 8 para I18n.t(:"games.fetching_news"), padding: 8
end end
BackgroundWorker.foreground_job( fetch_w3dhub_news
-> { fetch_w3dhub_news },
lambda do |result|
if result
populate_w3dhub_news
Cache.release_net_lock(result)
end
end
)
end end
end end
@@ -89,15 +81,7 @@ class W3DHub
title I18n.t(:"games.fetching_news"), padding: 8 title I18n.t(:"games.fetching_news"), padding: 8
end end
BackgroundWorker.foreground_job( fetch_w3dhub_news
-> { fetch_w3dhub_news },
lambda do |result|
if result
populate_w3dhub_news
Cache.release_net_lock(result)
end
end
)
end end
end end
@@ -105,19 +89,24 @@ class W3DHub
lock = Cache.acquire_net_lock("w3dhub_news") lock = Cache.acquire_net_lock("w3dhub_news")
return false unless lock return false unless lock
news = Api.news("launcher-home") Api.news("launcher-home") do |result|
Cache.release_net_lock("w3dhub_news") unless news news = result.data
return unless news Cache.release_net_lock("w3dhub_news") unless news
news.items[0..15].each do |item| next false unless news
Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
news.items[0..15].each do |item|
Cache.fetch(uri: item.image, backend: :w3dhub)
end
@w3dhub_news = news
@w3dhub_news_expires = Gosu.milliseconds + (60 * 60 * 1000) # 1 hour (in ms)
populate_w3dhub_news
ensure
Cache.release_net_lock("w3dhub_news")
end end
@w3dhub_news = news
@w3dhub_news_expires = Gosu.milliseconds + (60 * 60 * 1000) # 1 hour (in ms)
"w3dhub_news"
end end
def populate_w3dhub_news def populate_w3dhub_news
@@ -125,37 +114,7 @@ class W3DHub
if (feed = @w3dhub_news) if (feed = @w3dhub_news)
@wd3hub_news_container.clear do @wd3hub_news_container.clear do
# feed.items.sort_by { |i| i.timestamp }.reverse[0..9].each do |item| feed.items.sort_by(&: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|
image_path = Cache.path(item.image) 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 flow(width: 1.0, max_width: 1230, height: 200, margin: 8, border_thickness: 1, border_color: lighten(Gosu::Color.new(0xff_252525))) do

View File

@@ -31,30 +31,16 @@ class W3DHub
def update def update
super super
@game_news.each do |key, value| @game_news.each do |key, value|
next if key.end_with?("_expires") next unless key.end_with?("_expires")
if Gosu.milliseconds >= @game_news["#{key}_expires"] next unless Gosu.milliseconds >= value
@game_news.delete(key)
@game_news["#{key}_expires"] = Gosu.milliseconds + 30_000 # seconds
if @focused_game && @focused_game.id == key # try to refresh game news after last data 'expired', every 30 seconds until success
@game_news_container.clear do @game_news[key] = Gosu.milliseconds + 30_000 # seconds
title I18n.t(:"games.fetching_news"), padding: 8
end
BackgroundWorker.foreground_job( game = Store.applications.games.find { |g| g.id == key.split("_").first }
-> { fetch_game_news(@focused_game) }, fetch_game_news(game)
lambda do |result|
if result
populate_game_news(@focused_game)
Cache.release_net_lock(result)
end
end
)
end
end
end end
end end
@@ -123,6 +109,7 @@ class W3DHub
background_image_path = Cache.package_path(game.category, game.id, "background.png", "") background_image_path = Cache.package_path(game.category, game.id, "background.png", "")
if File.exist?(background_image_path) 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&.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 end
# Game Stuff # Game Stuff
@@ -291,22 +278,6 @@ class W3DHub
return if Store.offline_mode return if Store.offline_mode
unless Cache.net_lock?("game_news_#{game.id}") 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] if @game_news[game.id]
populate_game_news(game) populate_game_news(game)
else else
@@ -314,15 +285,15 @@ class W3DHub
title I18n.t(:"games.fetching_news"), padding: 8 title I18n.t(:"games.fetching_news"), padding: 8
end end
BackgroundWorker.foreground_job( fetch_game_news(game)
-> { fetch_game_news(game) }, end
lambda do |result| end
if result
populate_game_news(game) unless Cache.net_lock?("game_events_#{game.id}")
Cache.release_net_lock(result) if @game_events[game.id]
end populate_game_events(game)
end else
) fetch_game_events(game)
end end
end end
end end
@@ -412,19 +383,25 @@ class W3DHub
lock = Cache.acquire_net_lock("game_news_#{game.id}") lock = Cache.acquire_net_lock("game_news_#{game.id}")
return false unless lock return false unless lock
news = Api.news(game.id) Api.news(game.id) do |result|
Cache.release_net_lock("game_news_#{game.id}") unless news news = result.data
return false unless news unless news
@game_news["#{game.id}_expires"] = Gosu.milliseconds + 30_000 # retry in 30 seconds
next false
end
news.items[0..15].each do |item| news.items[0..15].each do |item|
Cache.fetch(uri: item.image, async: false, backend: :w3dhub) Cache.fetch(uri: item.image, backend: :w3dhub)
end
@game_news[game.id] = news
@game_news["#{game.id}_expires"] = Gosu.milliseconds + (60 * 60 * 1000) # 1 hour (in ms)
populate_game_news(@focused_game)
ensure
Cache.release_net_lock("game_news_#{game.id}")
end 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 end
def populate_game_news(game) def populate_game_news(game)
@@ -435,38 +412,11 @@ class W3DHub
game_color.alpha = 0xaa game_color.alpha = 0xaa
@game_news_container.clear do @game_news_container.clear do
# Patch Notes feed.items.sort_by(&:timestamp).reverse[0..9].each do |item|
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|
image_path = Cache.path(item.image) image_path = Cache.path(item.image)
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 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 if File.file?(image_path)
image image_path, height: 1.0
end
stack(fill: true, height: 1.0, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(game.color))) do stack(fill: true, height: 1.0, padding: 4, border_thickness_left: 1, border_color_left: lighten(Gosu::Color.new(game.color))) do
tagline "<b>#{item.title}</b>", width: 1.0 tagline "<b>#{item.title}</b>", width: 1.0
@@ -493,14 +443,14 @@ class W3DHub
lock = Cache.acquire_net_lock("game_events_#{game.id}") lock = Cache.acquire_net_lock("game_events_#{game.id}")
return false unless lock return false unless lock
events = Api.events(game.id) Api.events(game.id) do |result|
Cache.release_net_lock("game_events_#{game.id}") unless events next unless result.okay?
return false unless events @game_events[game.id] = result.data
populate_game_events(game)
@game_events[game.id] = events ensure
Cache.release_net_lock("game_events_#{game.id}")
"game_events_#{game.id}" end
end end
def populate_game_events(game) def populate_game_events(game)

View File

@@ -34,9 +34,8 @@ class W3DHub
# Do network stuff # Do network stuff
BackgroundWorker.foreground_job( Api.user_login(@username.value, @password.value) do |result|
lambda do if result.okay?
account = Api.user_login(@username.value, @password.value)
applications = nil applications = nil
if account if account
@@ -45,14 +44,15 @@ class W3DHub
Store.settings.save_settings Store.settings.save_settings
if account if account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub) Cache.fetch(uri: account.avatar_uri, force_fetch: true, backend: :w3dhub) {}
applications = Api._applications Api._applications do |r|
applications = r.result if r.okay?
end
end end
end end
[account, applications] [account, applications]
end, else
lambda do |result|
account, applications = result account, applications = result
if account if account
@@ -70,7 +70,7 @@ class W3DHub
@error_label.value = "Incorrect username or password.\nOr too many failed login attempts, try again in a few minutes." @error_label.value = "Incorrect username or password.\nOr too many failed login attempts, try again in a few minutes."
end end
end end
) end
end end
@error_label = caption "", width: 1.0, text_align: :center, color: 0xff_800000 @error_label = caption "", width: 1.0, text_align: :center, color: 0xff_800000
@@ -80,13 +80,10 @@ class W3DHub
end end
if Store.account if Store.account
BackgroundWorker.foreground_job( Cache.fetch(uri: Store.account.avatar_uri, backend: :w3dhub) do |result|
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false, backend: :w3dhub) }, populate_account_info
->(result) { page(W3DHub::Pages::Games)
populate_account_info end
page(W3DHub::Pages::Games)
}
)
end end
end end
@@ -153,29 +150,26 @@ class W3DHub
Store.settings.save_settings Store.settings.save_settings
Store.account = nil Store.account = nil
BackgroundWorker.foreground_job( Api._applications do |result|
-> { Api._applications }, if result.okay?
lambda do |applications| Store.applications = result.data
if applications page(W3DHub::Pages::Games) if @host.current_page.is_a?(W3DHub::Pages::Games)
Store.applications = applications page(W3DHub::Pages::ServerBrowser) if @host.current_page.is_a?(W3DHub::Pages::ServerBrowser)
page(W3DHub::Pages::Games) if @host.current_page.is_a?(W3DHub::Pages::Games) end
page(W3DHub::Pages::ServerBrowser) if @host.current_page.is_a?(W3DHub::Pages::ServerBrowser)
end
@host.instance_variable_get(:"@account_container").clear do @host.instance_variable_get(:"@account_container").clear do
stack(width: 1.0, height: 1.0) do stack(width: 1.0, height: 1.0) do
tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none tagline "<b>#{I18n.t(:"interface.not_logged_in")}</b>", text_wrap: :none
flow(width: 1.0) do 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.log_in"), text_size: 22, width: 0.5) { page(W3DHub::Pages::Login) }
link I18n.t(:"interface.register"), text_size: 22, width: 0.49 do 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") W3DHub.url("https://secure.w3dhub.com/forum/index.php?app=core&module=global&section=register")
end
end end
end end
end end
end end
) end
end end
end end
end end

View File

@@ -145,15 +145,7 @@ class W3DHub
reorder_server_list reorder_server_list
if @selected_server&.id == @refresh_server&.id if @selected_server&.id == @refresh_server&.id
if @refresh_server fetch_server_details(@refresh_server) 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 end
end end
@@ -196,13 +188,13 @@ class W3DHub
def ping_icon(server) def ping_icon(server)
case server.ping case server.ping
when 0..150 when 0..50
@ping_icons[:good] @ping_icons[:good]
when 151..200 when 51..150
@ping_icons[:fair] @ping_icons[:fair]
when 201..1_000 when 151..200
@ping_icons[:poor] @ping_icons[:poor]
when 1_001..5_000 when 201..1_000
@ping_icons[:bad] @ping_icons[:bad]
else else
@ping_icons[:unknown] @ping_icons[:unknown]
@@ -271,8 +263,9 @@ class W3DHub
def stylize_selected_server(server_container) def stylize_selected_server(server_container)
server_container.style.background = @selected_color server_container.style.background = @selected_color
server_container.style.hover.background = @selected_color server_container.style.default[:background] = @selected_color
server_container.style.active.background = @selected_color server_container.style.hover[:background] = @selected_color
server_container.style.active[:background] = @selected_color
end end
def reorder_server_list def reorder_server_list
@@ -283,11 +276,11 @@ class W3DHub
end.reverse!.each_with_index do |child, i| end.reverse!.each_with_index do |child, i|
next if @selected_server_container && child == @selected_server_container next if @selected_server_container && child == @selected_server_container
child.style.hover.background = 0xaa_555566 child.style.hover[:background] = 0xaa_555566
child.style.active.background = 0xaa_555588 child.style.hover[:active] = 0xaa_555588
child.style.background = 0xaa_333333 if i.even? child.style.default[:background] = 0xaa_333333 if i.even?
child.style.background = 0x00_000000 if i.odd? child.style.default[:background] = 0x00_000000 if i.odd?
end end
@server_list_container.recalculate @server_list_container.recalculate
@@ -352,10 +345,7 @@ class W3DHub
reorder_server_list if @selected_server_container reorder_server_list if @selected_server_container
BackgroundWorker.foreground_job( fetch_server_details(server)
-> { fetch_server_details(server) },
->(result) { populate_server_info(server) if server == @selected_server }
)
end end
stylize_selected_server(server_container) if server.id == @selected_server&.id stylize_selected_server(server_container) if server.id == @selected_server&.id
@@ -522,10 +512,13 @@ class W3DHub
end end
def fetch_server_details(server) def fetch_server_details(server)
BackgroundWorker.foreground_job( Api.server_details(server.id, 2) do |result|
-> { Api.server_details(server.id, 2) }, if result.okay?
->(server_data) { server.update(server_data) if server_data } server.update(result.data)
) populate_server_info(server) if server == @selected_server
@refresh_server = nil
end
end
end end
def game_icon(server) def game_icon(server)

166
lib/ping.rb Normal file
View File

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

54
lib/ping_manager.rb Normal file
View File

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

View File

@@ -78,7 +78,7 @@ class W3DHub
end end
def save_settings def save_settings
File.write(SETTINGS_FILE_PATH, JSON.pretty_generate(@settings)) File.write(SETTINGS_FILE_PATH, @settings.to_json)
end end
def save_application_cache(json) def save_application_cache(json)

View File

@@ -115,9 +115,8 @@ class W3DHub
Api.on_thread(:refresh_user_login, account.refresh_token) do |refreshed_account| Api.on_thread(:refresh_user_login, account.refresh_token) do |refreshed_account|
update_account_data(refreshed_account) update_account_data(refreshed_account)
end end
else else
BackgroundWorker.foreground_job(-> { update_account_data(account) }, ->(_) {}) Store.main_thread_queue << -> { update_account_data(account) }
end end
else else
@@ -131,7 +130,7 @@ class W3DHub
Store.settings[:account][:data] = account Store.settings[:account][:data] = account
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub) Cache.fetch(uri: account.avatar_uri, force_fetch: true, backend: :w3dhub)
else else
Store.settings[:account] = {} Store.settings[:account] = {}
end end
@@ -175,8 +174,10 @@ class W3DHub
end end
def service_status def service_status
Api.on_thread(:service_status) do |service_status| @status_label.value = "Checking service status..." #I18n.t(:"server_browser.fetching_server_list")
@service_status = service_status
Api.on_thread(:service_status) do |result|
@service_status = result.okay? ? result.data : nil
if @service_status if @service_status
Store.service_status = @service_status Store.service_status = @service_status
@@ -187,9 +188,7 @@ class W3DHub
@tasks[:service_status][:complete] = true @tasks[:service_status][:complete] = true
else else
BackgroundWorker.foreground_job(-> {}, lambda { |_| Store.main_thread_queue << -> { @status_label.value = I18n.t(:"boot.w3dhub_service_is_down") }
@status_label.value = I18n.t(:"boot.w3dhub_service_is_down")
})
@tasks[:service_status][:complete] = true @tasks[:service_status][:complete] = true
@offline_mode = true @offline_mode = true
@@ -201,9 +200,9 @@ class W3DHub
def launcher_updater def launcher_updater
@status_label.value = "Checking for Launcher updates..." # I18n.t(:"boot.checking_for_updates") @status_label.value = "Checking for Launcher updates..." # I18n.t(:"boot.checking_for_updates")
Api.on_thread(:fetch, "https://api.github.com/repos/cyberarm/w3d_hub_linux_launcher/releases/latest") do |response| Api.on_thread(:fetch, "https://api.github.com/repos/cyberarm/w3d_hub_linux_launcher/releases/latest") do |result|
if response.status == 200 if result.okay?
hash = JSON.parse(response.body, symbolize_names: true) hash = JSON.parse(result.data, symbolize_names: true)
available_version = hash[:tag_name].downcase.sub("v", "") 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)
@@ -227,14 +226,13 @@ class W3DHub
def applications def applications
@status_label.value = I18n.t(:"boot.checking_for_updates") @status_label.value = I18n.t(:"boot.checking_for_updates")
Api.on_thread(:_applications) do |applications| Api.on_thread(:_applications) do |result|
if applications if result.okay?
Store.applications = applications Store.applications = result.data
Store.settings.save_application_cache(JSON.pretty_generate(applications.data)) Store.settings.save_application_cache(Store.applications.data.to_json)
@tasks[:applications][:complete] = true @tasks[:applications][:complete] = true
else else
# FIXME: Failed to retreive! @status_label.value = "FAILED TO RETRIEVE APPS LIST"
BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = "FAILED TO RETREIVE APPS LIST" })
@offline_mode = true @offline_mode = true
Store.offline_mode = true Store.offline_mode = true
@@ -253,40 +251,39 @@ class W3DHub
packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" } packages << { category: app.category, subcategory: app.id, name: "#{app.id}.ico", version: "" }
end end
Api.on_thread(:package_details, packages, :alt_w3dhub) do |package_details| Api.on_thread(:package_details, packages, :alt_w3dhub) do |result|
package_details ||= nil if result.okay?
result.data.each do |package|
next if package.error?
package_details&.each do |package| path = Cache.package_path(package.category, package.subcategory, package.name, package.version)
next if package.error? generated_icon_path = "#{CACHE_PATH}/#{package.subcategory}.png"
path = Cache.package_path(package.category, package.subcategory, package.name, package.version) regenerate = false
generated_icon_path = "#{CACHE_PATH}/#{package.subcategory}.png"
regenerate = false if File.exist?(path)
broken_or_out_dated_icon = Digest::SHA256.new.hexdigest(File.binread(path)).upcase != package.checksum.upcase
if File.exist?(path)
broken_or_out_dated_icon = Digest::SHA256.new.hexdigest(File.binread(path)).upcase != package.checksum.upcase
end
if File.exist?(path) && !broken_or_out_dated_icon
regenerate = !File.exist?(generated_icon_path)
else
begin
Cache.fetch_package(package, proc {})
regenerate = true
rescue Errno::EACCES => e
failure = true
push_state(MessageDialog, title: "Fatal Error",
message: "Directory Permission Error (#{e.class}):\n#{e}.\n\nIs the required drive mounted?",
accept_callback: -> { window.close })
end end
if File.exist?(path) && !broken_or_out_dated_icon
regenerate = !File.exist?(generated_icon_path)
else
begin
Cache.fetch_package(package, proc {})
regenerate = true
rescue Errno::EACCES => e
failure = true
push_state(MessageDialog, title: "Fatal Error",
message: "Directory Permission Error (#{e.class}):\n#{e}.\n\nIs the required drive mounted?",
accept_callback: -> { window.close })
end
end
next unless regenerate
icon = ICO.new(file: path)
icon.save(icon.images.max_by(&:width), generated_icon_path)
end end
next unless regenerate
BackgroundWorker.foreground_job(-> { ICO.new(file: path) }, lambda { |result|
result.save(result.images.max_by(&:width), generated_icon_path)
})
end end
@tasks[:app_icons][:complete] = true unless failure @tasks[:app_icons][:complete] = true unless failure
@@ -304,18 +301,18 @@ class W3DHub
packages << { category: app.category, subcategory: app.id, name: "background.png", version: "" } packages << { category: app.category, subcategory: app.id, name: "background.png", version: "" }
end end
Api.on_thread(:package_details, packages, :alt_w3dhub) do |package_details| Api.on_thread(:package_details, packages, :alt_w3dhub) do |result|
package_details ||= nil if result.okay?
result.data.each do |package|
next if package.error?
package_details&.each do |package| package_cache_path = Cache.package_path(package.category, package.subcategory, package.name,
next if package.error? package.version)
package_cache_path = Cache.package_path(package.category, package.subcategory, package.name, missing_or_broken_image = File.exist?(package_cache_path) ? Digest::SHA256.new.hexdigest(File.binread(package_cache_path)).upcase != package.checksum.upcase : true
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
Cache.fetch_package(package, proc {}) if missing_or_broken_image
end end
@tasks[:app_logos_and_backgrounds][:complete] = true @tasks[:app_logos_and_backgrounds][:complete] = true
@@ -325,15 +322,15 @@ class W3DHub
def server_list def server_list
@status_label.value = I18n.t(:"server_browser.fetching_server_list") @status_label.value = I18n.t(:"server_browser.fetching_server_list")
Api.on_thread(:server_list, 2) do |list| Api.on_thread(:server_list, 2) do |result|
if list if result.okay?
Store.server_list = list.sort_by! { |s| s&.status&.players&.size }.reverse Store.server_list = result.data.sort_by! { |s| s&.status&.players&.size }.reverse
Store.server_list_last_fetch = Gosu.milliseconds Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance Api::ServerListUpdater.instance
list.each do |server| Store.server_list.each do |server|
server.send_ping(true) server.send_ping(true)
end end
else else

View File

@@ -23,7 +23,6 @@ class W3DHub
@service_status = @options[:service_status] @service_status = @options[:service_status]
@applications = @options[:applications] @applications = @options[:applications]
@account_expire = Gosu.milliseconds
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes @applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # 5 minutes @server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # 5 minutes
@@ -60,21 +59,25 @@ class W3DHub
link I18n.t(:"interface.servers").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.servers").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE @interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::ServerBrowser) page(W3DHub::Pages::ServerBrowser)
end end
link I18n.t(:"interface.community").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.community").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE @interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Community) page(W3DHub::Pages::Community)
end end
link I18n.t(:"interface.downloads").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.downloads").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE @interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::DownloadManager) page(W3DHub::Pages::DownloadManager)
end end
link I18n.t(:"interface.settings").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do link I18n.t(:"interface.settings").upcase, text_size: 34, font: BOLD_FONT, margin_left: 12 do
@interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE @interface_container.style.background_image = DEFAULT_BACKGROUND_IMAGE
@interface_container.style.default[:background_image] = DEFAULT_BACKGROUND_IMAGE
page(W3DHub::Pages::Settings) page(W3DHub::Pages::Settings)
end end
end end
@@ -129,13 +132,6 @@ class W3DHub
end end
hide_application_taskbar hide_application_taskbar
every(3_000) do
# NOTE: each method called, internally checks whether it should act.
refresh_account_token
refresh_applications
refresh_server_list
end
end end
def draw def draw
@@ -150,6 +146,36 @@ class W3DHub
@page&.update @page&.update
update_interface_task_status(@interface_task_update_pending) if @interface_task_update_pending update_interface_task_status(@interface_task_update_pending) if @interface_task_update_pending
if Gosu.milliseconds >= @applications_expire
@applications_expire = Gosu.milliseconds + 30_000
Api.on_thread(:_applications) do |applications|
if applications
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
Store.applications = applications
# TODO: Signal Games and ServerBrowser that applications have been updated
end
end
end
if Gosu.milliseconds >= @server_list_expire
@server_list_expire = Gosu.milliseconds + 30_000
Api.on_thread(:server_list, 2) do |result|
if result.okay?
@server_list_expire = Gosu.milliseconds + SERVER_LIST_UPDATE_INTERVAL # five minutes
Store.server_list_last_fetch = Gosu.milliseconds
Api::ServerListUpdater.instance.refresh_server_list(result.data)
Store.main_thread_queue << -> { States::Interface.instance&.update_server_browser(nil, :refresh_all) }
end
end
end
end end
def button_down(id) def button_down(id)
@@ -248,63 +274,6 @@ class W3DHub
end end
end end
end end
def refresh_account_token
return if Gosu.milliseconds < @account_expire
return unless account = Store.account
@account_expire = Gosu.milliseconds + 30_000
if (account.access_token_expiry - Time.now) / 60 <= 60 * 3 # Refresh if token expires within 3 hours
logger.info(LOG_TAG) { "Refreshing user login..." }
Api.on_thread(:refresh_user_login, account.refresh_token) do |refreshed_account|
if refreshed_account
Store.account = refreshed_account
Store.settings[:account][:data] = refreshed_account
else
Store.settings[:account] = {}
end
Store.settings.save_settings
end
end
end
def refresh_applications
return 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
def refresh_server_list
return 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 end
end end
end end

View File

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

View File

@@ -6,7 +6,12 @@ class W3DHub
Store[:server_list] = [] Store[:server_list] = []
Store[:settings] = Settings.new Store[:settings] = Settings.new
Store[:network_manager] = NetworkManager.new
Store[:application_manager] = ApplicationManager.new Store[:application_manager] = ApplicationManager.new
Store[:ping_manager] = PingManager.new
# FIXME
# BackgroundWorker.parallel_job(-> { Async { |task| Store.ping_manager.monitor(task) } }, nil)
Store[:main_thread_queue] = [] Store[:main_thread_queue] = []
@@ -32,8 +37,7 @@ class W3DHub
block&.call block&.call
end end
# Manually sleep main thread so that the BackgroundWorker thread can be scheduled sleep(update_interval / 1000.0) if Store.application_manager.busy? || Store.network_manager.busy?
sleep(update_interval / 1000.0) if W3DHub::BackgroundWorker.busy? || Store.application_manager.busy?
end end
def needs_redraw? def needs_redraw?

View File

@@ -106,11 +106,14 @@ require_relative "lib/cache"
require_relative "lib/settings" require_relative "lib/settings"
require_relative "lib/ww_mix" require_relative "lib/ww_mix"
require_relative "lib/ico" require_relative "lib/ico"
require_relative "lib/ping"
require_relative "lib/ping_manager"
require_relative "lib/broadcast_server" require_relative "lib/broadcast_server"
require_relative "lib/hardware_survey" require_relative "lib/hardware_survey"
require_relative "lib/game_settings" require_relative "lib/game_settings"
require_relative "lib/websocket_client" require_relative "lib/websocket_client"
require_relative "lib/background_worker" require_relative "lib/network_manager"
require_relative "lib/network_manager/http_client"
require_relative "lib/application_manager" require_relative "lib/application_manager"
require_relative "lib/application_manager/manifest" require_relative "lib/application_manager/manifest"
require_relative "lib/application_manager/status" require_relative "lib/application_manager/status"
@@ -176,27 +179,18 @@ end
logger.info(W3DHub::LOG_TAG) { "W3D Hub Linux Launcher v#{W3DHub::VERSION}" } 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..." } 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: 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: 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::Window.new(width: 1920, height: 1080, borderless: false, resizable: true).show unless defined?(Ocra)
W3DHub::BackgroundWorker.shutdown!
worker_soft_halt = Gosu.milliseconds # worker_soft_halt = Gosu.milliseconds
# Wait for BackgroundWorker to return # # Wait for BackgroundWorker to return
while W3DHub::BackgroundWorker.alive? # while W3DHub::BackgroundWorker.alive?
W3DHub::BackgroundWorker.kill! if Gosu.milliseconds - worker_soft_halt >= 1_000 # W3DHub::BackgroundWorker.kill! if Gosu.milliseconds - worker_soft_halt >= 1_000
sleep 0.1 # sleep 0.1
end # end
W3DHub::LOGGER&.close W3DHub::LOGGER&.close