5 Commits

29 changed files with 1137 additions and 1610 deletions

View File

@@ -1,13 +1,13 @@
GEM
remote: https://rubygems.org/
specs:
async (2.36.0)
async (2.35.2)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-http (0.94.2)
async-http (0.94.0)
async (>= 2.10.2)
async-pool (~> 0.11)
io-endpoint (~> 0.14)
@@ -30,7 +30,7 @@ GEM
fiber-annotation
fiber-local (~> 1.1)
json
cyberarm_engine (0.25.1)
cyberarm_engine (0.25.0)
gosu (~> 1.1)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@@ -43,7 +43,7 @@ GEM
fiber-storage (1.0.1)
fiddle (1.1.8)
gosu (1.4.6)
io-endpoint (0.17.2)
io-endpoint (0.16.0)
io-event (1.14.2)
io-stream (0.11.1)
ircparser (1.0.0)
@@ -52,8 +52,8 @@ GEM
fiddle
metrics (0.15.0)
protocol-hpack (1.5.1)
protocol-http (0.58.1)
protocol-http1 (0.37.0)
protocol-http (0.58.0)
protocol-http1 (0.36.0)
protocol-http (~> 0.58)
protocol-http2 (0.24.0)
protocol-hpack (~> 1.4)
@@ -97,4 +97,4 @@ DEPENDENCIES
win32-security
BUNDLED WITH
4.0.3
2.6.8

View File

@@ -1,35 +0,0 @@
# 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

@@ -1,73 +0,0 @@
# 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

@@ -1,319 +0,0 @@
> [!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
}
]
}
]
```

View File

@@ -1,132 +0,0 @@
# 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

@@ -1,19 +0,0 @@
# 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

@@ -1,36 +0,0 @@
> [!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

@@ -1,9 +0,0 @@
> [!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

@@ -1,9 +0,0 @@
> [!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

@@ -16,7 +16,31 @@ class W3DHub
].freeze
def self.on_thread(method, *args, &callback)
Api.send(method, *args, &callback)
BackgroundWorker.foreground_job(-> { 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
#! === W3D Hub API === !#
@@ -25,9 +49,7 @@ class W3DHub
HTTP_CLIENTS = {}
def self.async_http(method, path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub, &callback)
raise "NO CALLBACK DEFINED!" unless callback
def self.async_http(method, path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
case backend
when :w3dhub
endpoint = W3DHUB_API_ENDPOINT
@@ -38,14 +60,14 @@ class W3DHub
end
# Handle arbitrary urls that may come through
url = nil
if path.start_with?("http")
uri = URI(path)
endpoint = uri.origin
path = uri.request_uri
end
else
url = "#{endpoint}#{path}"
end
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." }
@@ -56,7 +78,24 @@ class W3DHub
headers << ["authorization", "Bearer #{Store.account.access_token}"]
end
Store.network_manager.request(method, url, headers, body, nil, &callback)
Sync do
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
def self.provision_http_client(hostname)
@@ -74,17 +113,17 @@ class W3DHub
HTTP_CLIENTS[Thread.current][hostname.downcase] = Async::HTTP::Client.new(endpoint)
end
def self.post(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub, &callback)
async_http(:post, path, headers, body, backend, &callback)
def self.post(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
async_http(:post, path, headers, body, backend)
end
def self.get(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub, &callback)
async_http(:get, path, headers, body, backend, &callback)
def self.get(path, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
async_http(:get, path, headers, body, backend)
end
# Api.get but handles any URL instead of known hosts
def self.fetch(path, headers = DEFAULT_HEADERS, body = nil, backend = nil, &callback)
async_http(:get, path, headers, body, backend, &callback)
def self.fetch(path, headers = DEFAULT_HEADERS, body = nil, backend = nil)
async_http(:get, path, headers, body, backend)
end
# Method: POST
@@ -102,113 +141,92 @@ class W3DHub
#
# On a failed login the service responds with:
# {"error":"login-failed"}
def self.refresh_user_login(refresh_token, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
user_data = JSON.parse(result.data, symbolize_names: true)
def self.refresh_user_login(refresh_token, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({refreshToken: refresh_token}))
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
if user_data[:error]
callback.call(false)
next
end
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error]
user_details_data = user_details(user_data[:userid]) || {}
callback.call(Account.new(user_data, user_details_data))
Account.new(user_data, user_details_data)
else
logger.error(LOG_TAG) { "Failed to fetch refresh user login:" }
logger.error(LOG_TAG) { result.error }
callback.call(false)
logger.error(LOG_TAG) { response }
false
end
end
body = URI.encode_www_form("data": JSON.dump({ refreshToken: refresh_token }))
post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend, &handler)
end
# See #user_refresh_token
def self.user_login(username, password, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
user_data = JSON.parse(result.data, symbolize_names: true)
def self.user_login(username, password, backend = :w3dhub)
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 user_data[:error]
callback.call(false)
next
end
if response.status == 200
user_data = JSON.parse(response.body, symbolize_names: true)
return false if user_data[:error]
user_details_data = user_details(user_data[:userid]) || {}
callback.call(Account.new(user_data, user_details_data))
Account.new(user_data, user_details_data)
else
logger.error(LOG_TAG) { "Failed to fetch user login:" }
logger.error(LOG_TAG) { result.error }
callback.call(false)
logger.error(LOG_TAG) { response }
false
end
end
body = URI.encode_www_form("data": JSON.dump({ username: username, password: password }))
post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend, &handler)
end
# /apis/w3dhub/1/get-user-details
#
# Response: avatar-uri (Image download uri), id, username
def self.user_details(id, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
callback.call(JSON.parse(result.data, symbolize_names: true))
def self.user_details(id, backend = :w3dhub)
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
JSON.parse(user_details.body, symbolize_names: true)
else
logger.error(LOG_TAG) { "Failed to fetch user details:" }
logger.error(LOG_TAG) { result.error }
callback.call(false)
logger.error(LOG_TAG) { user_details }
false
end
end
body = URI.encode_www_form("data": JSON.dump({ id: id }))
post("/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body, backend, &handler)
end
# /apis/w3dhub/1/get-service-status
# Service response:
# {"services":{"authentication":true,"packageDownload":true}}
def self.service_status(backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
callback.call(ServiceStatus.new(result.data))
def self.service_status(backend = :w3dhub)
response = post("/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS, nil, backend)
if response.status == 200
ServiceStatus.new(response.body)
else
logger.error(LOG_TAG) { "Failed to fetch service status:" }
logger.error(LOG_TAG) { result.error }
callback.call(false)
logger.error(LOG_TAG) { response }
false
end
end
post("/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS, nil, backend, &handler)
end
# /apis/launcher/1/get-applications
# Client sends an Authorization header bearer token which is received from logging in (Optional)
# Launcher sends an empty data request: data={}
# Response is a list of applications/games
def self.applications(backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
callback.call(Applications.new(result.data, backend))
def self.applications(backend = :w3dhub)
response = post("/apis/launcher/1/get-applications", DEFAULT_HEADERS, nil, backend)
if response.status == 200
Applications.new(response.body, backend)
else
logger.error(LOG_TAG) { "Failed to fetch applications list:" }
logger.error(LOG_TAG) { result.error }
callback.call(false)
logger.error(LOG_TAG) { response }
false
end
end
post("/apis/launcher/1/get-applications", DEFAULT_HEADERS, nil, backend, &handler)
end
# Populate applications list from primary and alternate backends
# (alternate only has latest public builds of _most_ games)
def self._applications
@@ -282,70 +300,61 @@ class W3DHub
# Client sends an Authorization header bearer token which is received from logging in (Optional)
# Client requests news for a specific application/game e.g.: data={"category":"ia"} ("launcher-home" retrieves the weekly hub updates)
# Response is a JSON hash with a "highlighted" and "news" keys; the "news" one seems to be the desired one
def self.news(category, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
callback.call(News.new(result.data))
def self.news(category, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({category: category}))
response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200
News.new(response.body)
else
logger.error(LOG_TAG) { "Failed to fetch news for:" }
logger.error(LOG_TAG) { category }
logger.error(LOG_TAG) { result.error }
callback.call(false)
logger.error(LOG_TAG) { response }
false
end
end
body = URI.encode_www_form("data": JSON.dump({ category: category }))
post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend, &handler)
end
# Downloading games
# /apis/launcher/1/get-package-details
# client requests package details: data={"packages":[{"category":"games","name":"apb.ico","subcategory":"apb","version":""}]}
def self.package_details(packages, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
hash = JSON.parse(result.data, symbolize_names: true)
def self.package_details(packages, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
response = post("/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body, backend)
callback.call(hash[:packages].map { |pkg| Package.new(pkg) })
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
hash[:packages].map { |pkg| Package.new(pkg) }
else
logger.error(LOG_TAG) { "Failed to fetch package details for:" }
logger.error(LOG_TAG) { packages }
logger.error(LOG_TAG) { result.error }
callback.call(false)
logger.error(LOG_TAG) { response }
false
end
end
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
post("/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body, backend, &handler)
end
# /apis/launcher/1/get-package
# client requests package: data={"category":"games","name":"ECW_Asteroids.zip","subcategory":"ecw","version":"1.0.0.0"}
#
# server responds with download bytes, probably supports chunked download and resume
# FIXME: REFACTOR Cache.fetch_package to use HttpClient
def self.package(package, &callback)
Cache.fetch_package(package, callback)
def self.package(package, &block)
Cache.fetch_package(package, block)
end
# /apis/w3dhub/1/get-events
#
# clients requests events: data={"serverPath":"apb"}
def self.events(app_id, backend = :w3dhub, &callback)
handler = lambda do |result|
if result.okay?
array = JSON.parse(response.body, symbolize_names: true)
callback.call(array.map { |e| Event.new(e) })
else
callback.call(false)
end
end
def self.events(app_id, backend = :w3dhub)
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
post("/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body, backend, &handler)
response = post("/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body, backend)
if response.status == 200
array = JSON.parse(response.body, symbolize_names: true)
array.map { |e| Event.new(e) }
else
false
end
end
#! === Server List API === !#
@@ -371,17 +380,15 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# nick, team (index of teams array), score, kills, deaths
def self.server_list(level = 1, backend = :gsh, &callback)
handler = lambda do |result|
if result.okay?
data = JSON.parse(result.data, symbolize_names: true)
callback.call(data.map { |hash| ServerListServer.new(hash) })
else
callback.call(false)
end
def self.server_list(level = 1, backend = :gsh)
response = get("/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
if response.status == 200
data = JSON.parse(response.body, symbolize_names: true)
return data.map { |hash| ServerListServer.new(hash) }
end
get("/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend, &handler)
false
end
# /listings/getStatus/v2/:id?statusLevel=#{0-2}
@@ -395,24 +402,23 @@ class W3DHub
# id, name, score, kills, deaths
# ...players[]:
# nick, team (index of teams array), score, kills, deaths
def self.server_details(id, level, backend = :gsh, &callback)
def self.server_details(id, level, backend = :gsh)
return false unless id && level
handler = lambda do |result|
if result.okay?
callback.call(JSON.parse(response.body, symbolize_names: true))
else
callback.call(false)
end
response = get("/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
if response.status == 200
hash = JSON.parse(response.body, symbolize_names: true)
return hash
end
get("/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend, &handler)
false
end
# /listings/push/v2/negotiate?negotiateVersion=1
##? /listings/push/v2/?id=#{websocket token?}
## Websocket server list listener
def self.server_list_push(id, &callback)
def self.server_list_push(id)
end
end
end

View File

@@ -20,12 +20,8 @@ class W3DHub
@status = Status.new(@data[:status])
# 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
@ping_interval = 30_000
@last_pinged = Gosu.milliseconds + @ping_interval + 1_000
Store.ping_manager.add_address(@address)
end
def update(hash)
@@ -53,16 +49,6 @@ class W3DHub
if force_ping || Gosu.milliseconds - @last_pinged >= @ping_interval
@last_pinged = Gosu.milliseconds
if W3DHub.unix?
average_ping = Store.ping_manager.ping_for(@address)
@ping = average_ping.negative? ? NO_OR_BAD_PING : average_ping
States::Interface.instance&.update_server_ping(self)
end
return unless W3DHub.windows?
W3DHub::BackgroundWorker.foreground_parallel_job(
lambda do
W3DHub.command("ping #{@address} #{W3DHub.windows? ? '-n 3' : '-c 3'}") do |line|

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

166
lib/background_worker.rb Normal file
View File

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

View File

@@ -90,6 +90,8 @@ class W3DHub
result = true
end
binding.irb unless response
if response&.status == 200 || response&.status == 206
result = true
else

View File

@@ -1,8 +1,4 @@
class W3DHub
PLATFORM_WINDOWS = RbConfig::CONFIG["host_os"] =~ /(mingw|mswin|windows)/i
PLATFORM_DARWIN = RbConfig::CONFIG["host_os"] =~ /(darwin|mac os)/i
PLATFORM_LINUX = RbConfig::CONFIG["host_os"] =~ /(linux|bsd|aix|solaris)/i
def self.format_size(bytes)
case bytes
when 0..1023 # Bytes
@@ -21,15 +17,15 @@ class W3DHub
end
def self.windows?
PLATFORM_WINDOWS
RbConfig::CONFIG["host_os"] =~ /(mingw|mswin|windows)/i
end
def self.mac?
PLATFORM_DARWIN
RbConfig::CONFIG["host_os"] =~ /(darwin|mac os)/i
end
def self.linux?
PLATFORM_LINUX
RbConfig::CONFIG["host_os"] =~ /(linux|bsd|aix|solaris)/i
end
def self.unix?

View File

@@ -1,65 +0,0 @@
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(:context, :callback)
Context = Data.define(
:request_id,
:method,
:url,
:headers,
:body,
:bearer_token
)
def initialize
@requests = []
@running = true
Thread.new do
http_client = HttpClient.new
Sync do
while @running
request = @requests.shift
# goto sleep for an second if there is no work to be doing
unless request
sleep 1
next
end
Async do |task|
assigned_request = request
result = http_client.handle(task, assigned_request)
pp [assigned_request, result]
Store.main_thread_queue << -> { assigned_request.callback.call(result) }
end
end
end
end
end
def request(method, url, headers, body, bearer_token, &block)
request_id = SecureRandom.hex
request = Request.new(
Context.new(
request_id,
method,
url,
headers,
body,
bearer_token
),
block
)
@requests << request
request_id
end
end
end

View File

@@ -1,61 +0,0 @@
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(30) do
uri = URI(context.url)
pp uri
response = provision_http_client(uri.origin).send(
context.method,
uri.path,
context.headers,
context.body
)
pp response
if response.success?
result.data = response.body.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

@@ -196,13 +196,13 @@ class W3DHub
def ping_icon(server)
case server.ping
when 0..50
when 0..150
@ping_icons[:good]
when 51..150
@ping_icons[:fair]
when 151..200
@ping_icons[:poor]
@ping_icons[:fair]
when 201..1_000
@ping_icons[:poor]
when 1_001..5_000
@ping_icons[:bad]
else
@ping_icons[:unknown]

View File

@@ -1,166 +0,0 @@
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

View File

@@ -1,54 +0,0 @@
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

@@ -175,11 +175,7 @@ class W3DHub
end
def service_status
@status_label.value = "Checking service status..." #I18n.t(:"server_browser.fetching_server_list")
Api.on_thread(:service_status) do |service_status|
pp service_status
@service_status = service_status
if @service_status
@@ -231,14 +227,14 @@ class W3DHub
def applications
@status_label.value = I18n.t(:"boot.checking_for_updates")
# Api.on_thread(:_applications) do |applications|
Api.on_thread(:applications, :alt_w3dhub) do |applications|
Api.on_thread(:_applications) do |applications|
if applications
Store.applications = applications
Store.settings.save_application_cache(applications.data.to_json)
@tasks[:applications][:complete] = true
else
@status_label.value = "FAILED TO RETREIVE APPS LIST"
# FIXME: Failed to retreive!
BackgroundWorker.foreground_job(-> {}, ->(_) { @status_label.value = "FAILED TO RETREIVE APPS LIST" })
@offline_mode = true
Store.offline_mode = true

View File

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

View File

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

View File

@@ -228,27 +228,34 @@ class W3DHub
@encrypted
end
def add_file(path:)
def add_file(path:, replace: false)
return false unless File.exist?(path)
return false if File.directory?(path)
info = EntryInfoHeader.new(0, 0, File.size(path))
@entries << Entry.new(name: File.basename(path), path: path, info: info)
true
entry = Entry.new(name: File.basename(path), path: path, info: EntryInfoHeader.new(0, 0, File.size(path)))
add_entry(entry: entry, replace: replace)
end
def add_blob(path:, blob:)
def add_blob(path:, blob:, replace: false)
info = EntryInfoHeader.new(0, 0, blob.size)
@entries << Entry.new(name: File.basename(path), path: path, info: info, blob: blob)
entry = Entry.new(name: File.basename(path), path: path, info: info, blob: blob)
into.crc32 = @entries.last.calculate_crc32
true
add_entry(entry: entry, replace: replace)
end
def add_entry(entry:)
@entries << entry
def add_entry(entry:, replace: false)
duplicate = @entries.find { |e| e.name.upcase == entry.name.upcase }
if duplicate
if replace
@entries.delete(duplicate)
else
return false
end
end
@entries << entry
true
end

View File

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