mirror of
https://github.com/cyberarm/w3d_hub_linux_launcher.git
synced 2025-12-15 16:52:34 +00:00
Merge branch 'cyberarm:master' into feature-buildBinaryPackage
This commit is contained in:
10
Gemfile.lock
10
Gemfile.lock
@@ -1,14 +1,14 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
cyberarm_engine (0.24.4)
|
cyberarm_engine (0.24.5)
|
||||||
gosu (~> 1.1)
|
gosu (~> 1.1)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
event_emitter (0.2.6)
|
event_emitter (0.2.6)
|
||||||
excon (1.2.5)
|
excon (1.2.7)
|
||||||
logger
|
logger
|
||||||
ffi (1.17.2-x64-mingw-ucrt)
|
ffi (1.17.2-x64-mingw-ucrt)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
@@ -21,7 +21,7 @@ GEM
|
|||||||
libui (0.1.2-x64-mingw)
|
libui (0.1.2-x64-mingw)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
rake (13.2.1)
|
rake (13.3.0)
|
||||||
rexml (3.4.1)
|
rexml (3.4.1)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
sdl2-bindings (0.2.3)
|
sdl2-bindings (0.2.3)
|
||||||
@@ -58,4 +58,4 @@ DEPENDENCIES
|
|||||||
win32-security
|
win32-security
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.4.3
|
2.6.7
|
||||||
|
|||||||
213
lib/api.rb
213
lib/api.rb
@@ -55,12 +55,26 @@ class W3DHub
|
|||||||
end
|
end
|
||||||
|
|
||||||
#! === W3D Hub API === !#
|
#! === W3D Hub API === !#
|
||||||
|
W3DHUB_API_ENDPOINT = "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
|
||||||
|
W3DHUB_API_CONNECTION = Excon.new(W3DHUB_API_ENDPOINT, persistent: true)
|
||||||
|
|
||||||
ENDPOINT = "https://w3dhub-api.w3d.cyberarm.dev" # "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
|
ALT_W3DHUB_API_ENDPOINT = "https://w3dhub-api.w3d.cyberarm.dev".freeze # "https://secure.w3dhub.com".freeze # "https://example.com" # "http://127.0.0.1:9292".freeze #
|
||||||
API_CONNECTION = Excon.new(ENDPOINT, persistent: true)
|
ALT_W3DHUB_API_API_CONNECTION = Excon.new(ALT_W3DHUB_API_ENDPOINT, persistent: true)
|
||||||
|
|
||||||
def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil, api = :api)
|
def self.excon(method, url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
|
||||||
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{url}\"..." }
|
case backend
|
||||||
|
when :w3dhub
|
||||||
|
connection = W3DHUB_API_CONNECTION
|
||||||
|
endpoint = W3DHUB_API_ENDPOINT
|
||||||
|
when :alt_w3dhub
|
||||||
|
connection = ALT_W3DHUB_API_API_CONNECTION
|
||||||
|
endpoint = ALT_W3DHUB_API_ENDPOINT
|
||||||
|
when :gsh
|
||||||
|
connection = GSH_CONNECTION
|
||||||
|
endpoint = SERVER_LIST_ENDPOINT
|
||||||
|
end
|
||||||
|
|
||||||
|
logger.debug(LOG_TAG) { "Fetching #{method.to_s.upcase} \"#{endpoint}#{url}\"..." }
|
||||||
|
|
||||||
# Inject Authorization header if account data is populated
|
# Inject Authorization header if account data is populated
|
||||||
if Store.account
|
if Store.account
|
||||||
@@ -69,9 +83,6 @@ class W3DHub
|
|||||||
headers["Authorization"] = "Bearer #{Store.account.access_token}"
|
headers["Authorization"] = "Bearer #{Store.account.access_token}"
|
||||||
end
|
end
|
||||||
|
|
||||||
connection = api == :api ? API_CONNECTION : GSH_CONNECTION
|
|
||||||
endpoint = api == :api ? ENDPOINT : SERVER_LIST_ENDPOINT
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
connection.send(
|
connection.send(
|
||||||
method,
|
method,
|
||||||
@@ -100,8 +111,48 @@ class W3DHub
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.post(url, headers = DEFAULT_HEADERS, body = nil, api = :api)
|
def self.post(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
|
||||||
excon(:post, url, headers, body, api)
|
excon(:post, url, headers, body, backend)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.get(url, headers = DEFAULT_HEADERS, body = nil, backend = :w3dhub)
|
||||||
|
excon(:get, url, headers, body, backend)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Api.get but handles any URL instead of known hosts
|
||||||
|
def self.fetch(url, headers = DEFAULT_HEADERS, body = nil, backend = nil)
|
||||||
|
uri = URI(url)
|
||||||
|
|
||||||
|
# Use Api.get for `W3DHUB_API_ENDPOINT` URL's to exploit keep alive and connection reuse (faster responses)
|
||||||
|
return excon(:get, url, headers, body, backend) if "#{uri.scheme}://#{uri.host}" == W3DHUB_API_ENDPOINT
|
||||||
|
|
||||||
|
logger.debug(LOG_TAG) { "Fetching GET \"#{url}\"..." }
|
||||||
|
|
||||||
|
begin
|
||||||
|
Excon.get(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
nonblock: true,
|
||||||
|
tcp_nodelay: true,
|
||||||
|
write_timeout: API_TIMEOUT,
|
||||||
|
read_timeout: API_TIMEOUT,
|
||||||
|
connect_timeout: API_TIMEOUT,
|
||||||
|
idempotent: true,
|
||||||
|
retry_limit: 3,
|
||||||
|
retry_interval: 1,
|
||||||
|
retry_errors: [Excon::Error::Socket, Excon::Error::HTTPStatus] # Don't retry on timeout
|
||||||
|
)
|
||||||
|
rescue Excon::Errors::Timeout => e
|
||||||
|
logger.error(LOG_TAG) { "Connection to \"#{url}\" timed out after: #{API_TIMEOUT} seconds" }
|
||||||
|
|
||||||
|
DummyResponse.new(e)
|
||||||
|
rescue Excon::Error => e
|
||||||
|
logger.error(LOG_TAG) { "Connection to \"#{url}\" errored:" }
|
||||||
|
logger.error(LOG_TAG) { e }
|
||||||
|
|
||||||
|
DummyResponse.new(e)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Method: POST
|
# Method: POST
|
||||||
@@ -119,24 +170,16 @@ 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)
|
def self.refresh_user_login(refresh_token, backend = :w3dhub)
|
||||||
body = "data=#{JSON.dump({refreshToken: refresh_token})}"
|
body = "data=#{JSON.dump({refreshToken: refresh_token})}"
|
||||||
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
|
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
user_data = JSON.parse(response.body, symbolize_names: true)
|
user_data = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
|
||||||
return false if user_data[:error]
|
return false if user_data[:error]
|
||||||
|
|
||||||
body = "data=#{JSON.dump({ id: user_data[:userid] })}"
|
user_details_data = user_details(user_data[:userid]) || {}
|
||||||
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
|
|
||||||
|
|
||||||
if user_details.status == 200
|
|
||||||
user_details_data = JSON.parse(user_details.body, symbolize_names: true)
|
|
||||||
else
|
|
||||||
logger.error(LOG_TAG) { "Failed to fetch refresh user details:" }
|
|
||||||
logger.error(LOG_TAG) { user_details }
|
|
||||||
end
|
|
||||||
|
|
||||||
Account.new(user_data, user_details_data)
|
Account.new(user_data, user_details_data)
|
||||||
else
|
else
|
||||||
@@ -147,24 +190,16 @@ class W3DHub
|
|||||||
end
|
end
|
||||||
|
|
||||||
# See #user_refresh_token
|
# See #user_refresh_token
|
||||||
def self.user_login(username, password)
|
def self.user_login(username, password, backend = :w3dhub)
|
||||||
body = "data=#{JSON.dump({username: username, password: password})}"
|
body = "data=#{JSON.dump({username: username, password: password})}"
|
||||||
response = post("#{ENDPOINT}/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body)
|
response = post("/apis/launcher/1/user-login", FORM_ENCODED_HEADERS, body, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
user_data = JSON.parse(response.body, symbolize_names: true)
|
user_data = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
|
||||||
return false if user_data[:error]
|
return false if user_data[:error]
|
||||||
|
|
||||||
body = "data=#{JSON.dump({ id: user_data[:userid] })}"
|
user_details_data = user_details(user_data[:userid]) || {}
|
||||||
user_details = post("#{ENDPOINT}/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body)
|
|
||||||
|
|
||||||
if user_details.status == 200
|
|
||||||
user_details_data = JSON.parse(user_details.body, symbolize_names: true)
|
|
||||||
else
|
|
||||||
logger.error(LOG_TAG) { "Failed to fetch user details:" }
|
|
||||||
logger.error(LOG_TAG) { user_details }
|
|
||||||
end
|
|
||||||
|
|
||||||
Account.new(user_data, user_details_data)
|
Account.new(user_data, user_details_data)
|
||||||
else
|
else
|
||||||
@@ -174,18 +209,27 @@ class W3DHub
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# /apis/launcher/1/user-login
|
# /apis/w3dhub/1/get-user-details
|
||||||
# Client sends an Authorization header bearer token which is received from logging in (Required?)
|
|
||||||
#
|
#
|
||||||
# Response: avatar-uri (Image download uri), id, username
|
# Response: avatar-uri (Image download uri), id, username
|
||||||
def self.user_details(id)
|
def self.user_details(id, backend = :w3dhub)
|
||||||
|
body = "data=#{JSON.dump({ id: id })}"
|
||||||
|
user_details = post("/apis/w3dhub/1/get-user-details", FORM_ENCODED_HEADERS, body, backend)
|
||||||
|
|
||||||
|
if user_details.status == 200
|
||||||
|
JSON.parse(user_details.body, symbolize_names: true)
|
||||||
|
else
|
||||||
|
logger.error(LOG_TAG) { "Failed to fetch user details:" }
|
||||||
|
logger.error(LOG_TAG) { user_details }
|
||||||
|
false
|
||||||
|
end
|
||||||
end
|
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
|
def self.service_status(backend = :w3dhub)
|
||||||
response = post("#{ENDPOINT}/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS)
|
response = post("/apis/w3dhub/1/get-service-status", DEFAULT_HEADERS, nil, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
ServiceStatus.new(response.body)
|
ServiceStatus.new(response.body)
|
||||||
@@ -200,8 +244,8 @@ class W3DHub
|
|||||||
# 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
|
def self.applications(backend = :w3dhub)
|
||||||
response = post("#{ENDPOINT}/apis/launcher/1/get-applications")
|
response = post("/apis/launcher/1/get-applications", DEFAULT_HEADERS, nil, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
Applications.new(response.body)
|
Applications.new(response.body)
|
||||||
@@ -212,13 +256,82 @@ class W3DHub
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Populate applications list from primary and alternate backends
|
||||||
|
# (alternate only has latest public builds of _most_ games)
|
||||||
|
def self._applications
|
||||||
|
applications_primary = Store.account ? Api.applications(:w3dhub) : false
|
||||||
|
applications_alternate = Api.applications(:alt_w3dhub)
|
||||||
|
|
||||||
|
# Fail if we fail to fetch applications list from either backend
|
||||||
|
return false unless applications_primary || applications_alternate
|
||||||
|
|
||||||
|
return applications_alternate unless applications_primary
|
||||||
|
|
||||||
|
# Merge the two app lists together
|
||||||
|
apps = applications_alternate
|
||||||
|
if applications_primary
|
||||||
|
applications_primary.games.each do |game|
|
||||||
|
# Check if game exists in alternate list
|
||||||
|
_game = apps.games.find { |g| g.id == game.id }
|
||||||
|
unless _game
|
||||||
|
apps.games << game
|
||||||
|
|
||||||
|
# App didn't exist in alternates list
|
||||||
|
# comparing channels isn't useful
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# If it does, check that all of its channels also exist in alternate list
|
||||||
|
# and that the primary versions are the same as the alternates list
|
||||||
|
game.channels.each do |channel|
|
||||||
|
_channel = _game.channels.find { |c| c.id == channel.id }
|
||||||
|
|
||||||
|
unless _channel
|
||||||
|
_game.channels << channel
|
||||||
|
|
||||||
|
# App didn't have channel in alternates list
|
||||||
|
# comparing channel isn't useful
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# If channel versions and access levels match then all's well
|
||||||
|
if channel.current_version == _channel.current_version &&
|
||||||
|
channel.user_level == _channel.user_level
|
||||||
|
|
||||||
|
# All's Well!
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# If the access levels doen't match then overwrite alternate's channel with primary's channel
|
||||||
|
if channel.user_level != _channel.user_level
|
||||||
|
# Replace alternate's channel with primary's channel
|
||||||
|
_game.channels[_game.channels.index(_channel)] = channel
|
||||||
|
|
||||||
|
# Replaced, continue.
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# If versions doen't match then pick whichever one is higher
|
||||||
|
if Gem::Version.new(channel.current_version) > Gem::Version.new(_channel.current_version)
|
||||||
|
# Replace alternate's channel with primary's channel
|
||||||
|
_game.channels[_game.channels.index(_channel)] = channel
|
||||||
|
else
|
||||||
|
# Do nothing, alternate backend version is greater.
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
apps
|
||||||
|
end
|
||||||
|
|
||||||
# /apis/w3dhub/1/get-news
|
# /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)
|
def self.news(category, backend = :w3dhub)
|
||||||
body = "data=#{JSON.dump({category: category})}"
|
body = "data=#{JSON.dump({category: category})}"
|
||||||
response = post("#{ENDPOINT}/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body)
|
response = post("/apis/w3dhub/1/get-news", FORM_ENCODED_HEADERS, body, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
News.new(response.body)
|
News.new(response.body)
|
||||||
@@ -234,9 +347,9 @@ class W3DHub
|
|||||||
|
|
||||||
# /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)
|
def self.package_details(packages, backend = :w3dhub)
|
||||||
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
|
body = URI.encode_www_form("data": JSON.dump({ packages: packages }))
|
||||||
response = post("#{ENDPOINT}/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body)
|
response = post("/apis/launcher/1/get-package-details", FORM_ENCODED_HEADERS, body, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
hash = JSON.parse(response.body, symbolize_names: true)
|
hash = JSON.parse(response.body, symbolize_names: true)
|
||||||
@@ -261,9 +374,9 @@ class W3DHub
|
|||||||
# /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)
|
def self.events(app_id, backend = :w3dhub)
|
||||||
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
|
body = URI.encode_www_form("data": JSON.dump({ serverPath: app_id }))
|
||||||
response = post("#{ENDPOINT}/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body)
|
response = post("/apis/w3dhub/1/get-server-events", FORM_ENCODED_HEADERS, body, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
array = JSON.parse(response.body, symbolize_names: true)
|
array = JSON.parse(response.body, symbolize_names: true)
|
||||||
@@ -280,10 +393,6 @@ class W3DHub
|
|||||||
# SERVER_LIST_ENDPOINT = "http://127.0.0.1:9292".freeze
|
# SERVER_LIST_ENDPOINT = "http://127.0.0.1:9292".freeze
|
||||||
GSH_CONNECTION = Excon.new(SERVER_LIST_ENDPOINT, persistent: true)
|
GSH_CONNECTION = Excon.new(SERVER_LIST_ENDPOINT, persistent: true)
|
||||||
|
|
||||||
def self.get(url, headers = DEFAULT_HEADERS, body = nil, api = :api)
|
|
||||||
excon(:get, url, headers, body, api)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Method: GET
|
# Method: GET
|
||||||
# FORMAT: JSON
|
# FORMAT: JSON
|
||||||
|
|
||||||
@@ -301,8 +410,8 @@ 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)
|
def self.server_list(level = 1, backend = :gsh)
|
||||||
response = get("#{SERVER_LIST_ENDPOINT}/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, :gsh)
|
response = get("/listings/getAll/v2?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
data = JSON.parse(response.body, symbolize_names: true)
|
data = JSON.parse(response.body, symbolize_names: true)
|
||||||
@@ -323,10 +432,10 @@ 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)
|
def self.server_details(id, level, backend = :gsh)
|
||||||
return false unless id && level
|
return false unless id && level
|
||||||
|
|
||||||
response = get("#{SERVER_LIST_ENDPOINT}/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, :gsh)
|
response = get("/listings/getStatus/v2/#{id}?statusLevel=#{level}", DEFAULT_HEADERS, nil, backend)
|
||||||
|
|
||||||
if response.status == 200
|
if response.status == 200
|
||||||
hash = JSON.parse(response.body, symbolize_names: true)
|
hash = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ 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("#{Api::SERVER_LIST_ENDPOINT}/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh)
|
response = Api.post("/listings/push/v2/negotiate?negotiateVersion=1", Api::DEFAULT_HEADERS, "", :gsh)
|
||||||
|
|
||||||
if response.status != 200
|
if response.status != 200
|
||||||
@auto_reconnect = true
|
@auto_reconnect = true
|
||||||
|
|||||||
10
lib/cache.rb
10
lib/cache.rb
@@ -9,18 +9,18 @@ class W3DHub
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Fetch a generic uri
|
# Fetch a generic uri
|
||||||
def self.fetch(uri:, force_fetch: false, async: true)
|
def self.fetch(uri:, force_fetch: false, async: true, backend: :w3dhub)
|
||||||
path = path(uri)
|
path = path(uri)
|
||||||
|
|
||||||
if !force_fetch && File.exist?(path)
|
if !force_fetch && File.exist?(path)
|
||||||
path
|
path
|
||||||
elsif async
|
elsif async
|
||||||
BackgroundWorker.job(
|
BackgroundWorker.job(
|
||||||
-> { Api.get(uri, W3DHub::Api::DEFAULT_HEADERS) },
|
-> { Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend) },
|
||||||
->(response) { File.open(path, "wb") { |f| f.write response.body } if response.status == 200 }
|
->(response) { File.open(path, "wb") { |f| f.write response.body } if response.status == 200 }
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
response = Api.get(uri, W3DHub::Api::DEFAULT_HEADERS)
|
response = Api.fetch(uri, W3DHub::Api::DEFAULT_HEADERS, nil, backend)
|
||||||
File.open(path, "wb") { |f| f.write response.body } if response.status == 200
|
File.open(path, "wb") { |f| f.write response.body } if response.status == 200
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -69,7 +69,7 @@ class W3DHub
|
|||||||
|
|
||||||
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
|
body = "data=#{JSON.dump({ category: package.category, subcategory: package.subcategory, name: package.name, version: package.version })}"
|
||||||
|
|
||||||
response = Api.post("#{Api::ENDPOINT}/apis/launcher/1/get-package", headers, body)
|
response = Api.post("/apis/launcher/1/get-package", headers, body)
|
||||||
|
|
||||||
total_bytes = package.size
|
total_bytes = package.size
|
||||||
remaining_bytes = total_bytes - start_from_bytes
|
remaining_bytes = total_bytes - start_from_bytes
|
||||||
@@ -89,7 +89,7 @@ class W3DHub
|
|||||||
|
|
||||||
# Download a W3D Hub package
|
# Download a W3D Hub package
|
||||||
def self.fetch_package(package, block)
|
def self.fetch_package(package, block)
|
||||||
endpoint_download_url = package.download_url || "#{Api::ENDPOINT}/apis/launcher/1/get-package"
|
endpoint_download_url = package.download_url || "#{Api::W3DHUB_API_ENDPOINT}/apis/launcher/1/get-package"
|
||||||
path = package_path(package.category, package.subcategory, package.name, package.version)
|
path = package_path(package.category, package.subcategory, package.name, package.version)
|
||||||
headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT }
|
headers = { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": Api::USER_AGENT }
|
||||||
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account && !package.download_url
|
headers["Authorization"] = "Bearer #{Store.account.access_token}" if Store.account && !package.download_url
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class W3DHub
|
|||||||
return unless news
|
return unless news
|
||||||
|
|
||||||
news.items[0..15].each do |item|
|
news.items[0..15].each do |item|
|
||||||
Cache.fetch(uri: item.image, async: false)
|
Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
|
||||||
end
|
end
|
||||||
|
|
||||||
@w3dhub_news = news
|
@w3dhub_news = news
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ class W3DHub
|
|||||||
return false unless news
|
return false unless news
|
||||||
|
|
||||||
news.items[0..15].each do |item|
|
news.items[0..15].each do |item|
|
||||||
Cache.fetch(uri: item.image, async: false)
|
Cache.fetch(uri: item.image, async: false, backend: :w3dhub)
|
||||||
end
|
end
|
||||||
|
|
||||||
@game_news[game.id] = news
|
@game_news[game.id] = news
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ class W3DHub
|
|||||||
Store.settings[:account][:data] = account
|
Store.settings[:account][:data] = account
|
||||||
Store.settings.save_settings
|
Store.settings.save_settings
|
||||||
|
|
||||||
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false) if account
|
if account
|
||||||
applications = Api.applications if account
|
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
|
||||||
|
applications = Api._applications
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
[account, applications]
|
[account, applications]
|
||||||
@@ -79,7 +81,7 @@ class W3DHub
|
|||||||
|
|
||||||
if Store.account
|
if Store.account
|
||||||
BackgroundWorker.foreground_job(
|
BackgroundWorker.foreground_job(
|
||||||
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false) },
|
-> { Cache.fetch(uri: Store.account.avatar_uri, async: false, backend: :w3dhub) },
|
||||||
->(result) {
|
->(result) {
|
||||||
populate_account_info
|
populate_account_info
|
||||||
page(W3DHub::Pages::Games)
|
page(W3DHub::Pages::Games)
|
||||||
@@ -152,7 +154,7 @@ class W3DHub
|
|||||||
Store.account = nil
|
Store.account = nil
|
||||||
|
|
||||||
BackgroundWorker.foreground_job(
|
BackgroundWorker.foreground_job(
|
||||||
-> { Api.applications },
|
-> { Api._applications },
|
||||||
lambda do |applications|
|
lambda do |applications|
|
||||||
if applications
|
if applications
|
||||||
Store.applications = applications
|
Store.applications = applications
|
||||||
|
|||||||
@@ -3,87 +3,57 @@ class W3DHub
|
|||||||
class Settings < Page
|
class Settings < Page
|
||||||
def setup
|
def setup
|
||||||
body.clear do
|
body.clear do
|
||||||
stack(width: 1.0, height: 1.0, padding: 16, scroll: true) do
|
stack(width: 1.0, height: 1.0, padding: 16) do
|
||||||
background 0xaa_252525
|
background 0xaa_252525
|
||||||
|
|
||||||
stack(width: 1.0, fill: true) do
|
stack(width: 1.0, fill: true, max_width: 720, h_align: :center, scroll: true) do
|
||||||
para "<b>Language</b>"
|
stack(width: 1.0, height: 112) do
|
||||||
para "<b>Launcher Language</b>", width: 0.249, margin_left: 32, margin_top: 12
|
tagline "Launcher Language"
|
||||||
stack(width: 0.75) do
|
@language_menu = list_box items: I18n.available_locales.map { |l| expand_language_code(l.to_s) }, choose: expand_language_code(Store.settings[:language]), width: 1.0, margin_left: 16
|
||||||
@language_menu = list_box items: I18n.available_locales.map { |l| expand_language_code(l.to_s) }, choose: expand_language_code(Store.settings[:language]), width: 1.0
|
para "Select the UI language you'd like to use in the W3D Hub Launcher.", margin_left: 16
|
||||||
para "Select the UI language you'd like to use in the W3D Hub Launcher."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
stack(width: 1.0, height: 144) do
|
|
||||||
para "<b>Folder Paths</b>", margin_top: 8, padding_top: 8, border_thickness_top: 2, border_color_top: 0xee_ffffff, width: 1.0
|
|
||||||
flow(width: 1.0, height: 0.5) do
|
|
||||||
para "<b>App Install Folder</b>", width: 0.249, margin_left: 32, margin_top: 12
|
|
||||||
|
|
||||||
stack(width: 0.75) do
|
|
||||||
@app_install_dir_input = edit_line Store.settings[:app_install_dir], width: 1.0
|
|
||||||
para "The folder into which new games and apps will be installed by the launcher"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
flow(width: 1.0, height: 256, margin_top: 16) do
|
|
||||||
para "<b>Package Cache Folder</b>", width: 0.249, margin_left: 32, margin_top: 12
|
|
||||||
|
|
||||||
stack(width: 0.75, height: 200) do
|
stack(width: 1.0, height: 200, margin_top: 16) do
|
||||||
flow(width: 1.0, height: 1.0) do
|
tagline "Launcher Directories"
|
||||||
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
|
caption "Applications Install Directory", margin_left: 16
|
||||||
button "Browse...", width: 128, height: 1.0, tip: "Browse for game executable" do
|
flow(width: 1.0, fill: true, margin_left: 16) do
|
||||||
path = W3DHub.ask_file
|
@app_install_dir_input = edit_line Store.settings[:app_install_dir], fill: true
|
||||||
end
|
button "Browse...", width: 128, tip: "Browse for applications install directory" do
|
||||||
|
path = W3DHub.ask_folder
|
||||||
|
@app_install_dir_input.value = path unless path.empty?
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
para "A folder which will be used to cache downloaded packages used to install games and apps"
|
caption "Package Cache Directory", margin_left: 16, margin_top: 16
|
||||||
|
flow(width: 1.0, fill: true, margin_left: 16) do
|
||||||
|
@package_cache_dir_input = edit_line Store.settings[:package_cache_dir], fill: true
|
||||||
|
button "Browse...", width: 128, tip: "Browse for package cache directory" do
|
||||||
|
path = W3DHub.ask_folder
|
||||||
|
@package_cache_dir_input.value = path unless path.empty?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if W3DHub.unix?
|
||||||
|
stack(width: 1.0, height: 224, margin_top: 16) do
|
||||||
|
tagline "Wine - Windows compatibility layer"
|
||||||
|
caption "Wine Command", margin_left: 16
|
||||||
|
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0, margin_left: 16
|
||||||
|
para "Command to use to for Windows compatiblity layer.", margin_left: 16
|
||||||
|
|
||||||
|
caption "Wine Prefix", margin_left: 16, margin_top: 16
|
||||||
|
flow(width: 1.0, height: 48, margin_left: 16) do
|
||||||
|
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix], enabled: false
|
||||||
|
para "Whether each game gets its own prefix. Uses global/default prefix by default."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if true # W3DHub.unix?
|
stack(width: 128, max_height: 256, h_align: :center, margin_top: 16, fill: true) do
|
||||||
stack(width: 1.0, fill: true) do
|
button "Save", width: 1.0 do
|
||||||
para "<b>Wine</b>", margin_top: 8, padding_top: 8, border_thickness_top: 2, border_color_top: 0xee_ffffff, width: 1.0
|
save_settings!
|
||||||
para "<b>Wine Command</b>", width: 0.249, margin_left: 32, margin_top: 12
|
|
||||||
stack(width: 0.75) do
|
|
||||||
@wine_command_input = edit_line Store.settings[:wine_command], width: 1.0
|
|
||||||
para "Command to use to for Windows compatiblity layer"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
stack(width: 1.0, fill: true, margin_top: 16) do
|
|
||||||
para "<b>Wine Prefix</b>", width: 0.249, margin_left: 32, margin_top: 12
|
|
||||||
stack(width: 0.75) do
|
|
||||||
@wine_prefix_toggle = toggle_button checked: Store.settings[:wine_prefix]
|
|
||||||
para "Whether each game gets its own prefix. Uses global/default prefix by default."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
button "Save" do
|
|
||||||
old_language = Store.settings[:language]
|
|
||||||
Store.settings[:language] = language_code(@language_menu.value)
|
|
||||||
|
|
||||||
Store.settings[:app_install_dir] = @app_install_dir_input.value
|
|
||||||
Store.settings[:package_cache_dir] = @package_cache_dir_input.value
|
|
||||||
|
|
||||||
Store.settings[:wine_command] = @wine_command_input.value
|
|
||||||
Store.settings[:wine_prefix] = @wine_prefix_toggle.value
|
|
||||||
|
|
||||||
Store.settings.save_settings
|
|
||||||
|
|
||||||
begin
|
|
||||||
I18n.locale = Store.settings[:language]
|
|
||||||
rescue I18n::InvalidLocale
|
|
||||||
I18n.locale = :en
|
|
||||||
end
|
|
||||||
|
|
||||||
if old_language == Store.settings[:language]
|
|
||||||
page(Pages::Games)
|
|
||||||
else
|
|
||||||
# pop back to Boot state which will immediately push a new instance of Interface
|
|
||||||
pop_state
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -117,6 +87,32 @@ class W3DHub
|
|||||||
logger.warn("W3DHub::Settings") { "Unknown language code: #{string.inspect}" }
|
logger.warn("W3DHub::Settings") { "Unknown language code: #{string.inspect}" }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def save_settings!
|
||||||
|
old_language = Store.settings[:language]
|
||||||
|
Store.settings[:language] = language_code(@language_menu.value)
|
||||||
|
|
||||||
|
Store.settings[:app_install_dir] = @app_install_dir_input.value
|
||||||
|
Store.settings[:package_cache_dir] = @package_cache_dir_input.value
|
||||||
|
|
||||||
|
Store.settings[:wine_command] = @wine_command_input.value
|
||||||
|
Store.settings[:wine_prefix] = @wine_prefix_toggle.value
|
||||||
|
|
||||||
|
Store.settings.save_settings
|
||||||
|
|
||||||
|
begin
|
||||||
|
I18n.locale = Store.settings[:language]
|
||||||
|
rescue I18n::InvalidLocale
|
||||||
|
I18n.locale = :en
|
||||||
|
end
|
||||||
|
|
||||||
|
if old_language == Store.settings[:language]
|
||||||
|
page(Pages::Games)
|
||||||
|
else
|
||||||
|
# pop back to Boot state which will immediately push a new instance of Interface
|
||||||
|
pop_state
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class W3DHub
|
|||||||
@w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png")
|
@w3dhub_logo = get_image("#{GAME_ROOT_PATH}/media/icons/app.png")
|
||||||
@tasks = {
|
@tasks = {
|
||||||
# connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com?
|
# connectivity_check: { started: false, complete: false }, # HEAD connectivity-check.ubuntu.com or HEAD secure.w3dhub.com?
|
||||||
|
# launcher_updater: { started: false, complete: false },
|
||||||
server_list: { started: false, complete: false },
|
server_list: { started: false, complete: false },
|
||||||
refresh_user_token: { started: false, complete: false },
|
refresh_user_token: { started: false, complete: false },
|
||||||
service_status: { started: false, complete: false },
|
service_status: { started: false, complete: false },
|
||||||
@@ -127,7 +128,7 @@ class W3DHub
|
|||||||
|
|
||||||
Store.settings[:account][:data] = account
|
Store.settings[:account][:data] = account
|
||||||
|
|
||||||
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false)
|
Cache.fetch(uri: account.avatar_uri, force_fetch: true, async: false, backend: :w3dhub)
|
||||||
else
|
else
|
||||||
Store.settings[:account] = {}
|
Store.settings[:account] = {}
|
||||||
end
|
end
|
||||||
@@ -159,10 +160,36 @@ class W3DHub
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def launcher_updater
|
||||||
|
@status_label.value = "Checking for Launcher updates..." # I18n.t(:"boot.checking_for_updates")
|
||||||
|
|
||||||
|
Api.on_thread(:fetch, "https://api.github.com/repos/Inq8/CAmod/releases/latest") do |response|
|
||||||
|
if response.status == 200
|
||||||
|
hash = JSON.parse(response.body, symbolize_names: true)
|
||||||
|
available_version = hash[:tag_name].downcase.sub("v", "")
|
||||||
|
|
||||||
|
pp Gem::Version.new(available_version) > Gem::Version.new(W3DHub::VERSION)
|
||||||
|
pp [Gem::Version.new(available_version), Gem::Version.new(W3DHub::VERSION)]
|
||||||
|
|
||||||
|
push_state(
|
||||||
|
LauncherUpdaterDialog,
|
||||||
|
release_data: hash,
|
||||||
|
available_version: available_version,
|
||||||
|
cancel_callback: -> { @tasks[:launcher_updater][:complete] = true },
|
||||||
|
accept_callback: -> { @tasks[:launcher_updater][:complete] = true }
|
||||||
|
)
|
||||||
|
else
|
||||||
|
# Failed to retrieve release data from github
|
||||||
|
log "Failed to retrieve release data from Github"
|
||||||
|
@tasks[:launcher_updater][:complete] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def applications
|
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 |applications|
|
||||||
if applications
|
if applications
|
||||||
Store.applications = applications
|
Store.applications = applications
|
||||||
Store.settings.save_application_cache(applications.data.to_json)
|
Store.settings.save_application_cache(applications.data.to_json)
|
||||||
@@ -185,7 +212,7 @@ 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) do |package_details|
|
Api.on_thread(:package_details, packages, :alt_w3dhub) do |package_details|
|
||||||
package_details ||= nil
|
package_details ||= nil
|
||||||
|
|
||||||
package_details&.each do |package|
|
package_details&.each do |package|
|
||||||
|
|||||||
62
lib/states/dialogs/launcher_updater_dialog.rb
Normal file
62
lib/states/dialogs/launcher_updater_dialog.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
class W3DHub
|
||||||
|
class States
|
||||||
|
class LauncherUpdaterDialog < Dialog
|
||||||
|
BUTTON_STYLE = { text_size: 18, padding_top: 3, padding_bottom: 3, padding_left: 3, padding_right: 3, height: 18 }
|
||||||
|
LIST_ITEM_THEME = Marshal.load(Marshal.dump(THEME))
|
||||||
|
BUTTON_STYLE.each do |key, value|
|
||||||
|
LIST_ITEM_THEME[:Button][key] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup
|
||||||
|
window.show_cursor = true
|
||||||
|
|
||||||
|
theme(THEME)
|
||||||
|
|
||||||
|
background 0xaa_525252
|
||||||
|
|
||||||
|
stack(width: 1.0, max_width: 760, height: 1.0, max_height: 640, v_align: :center, h_align: :center, background: 0xee_222222, border_thickness: 2, border_color: 0xee_222222, padding: 16) do
|
||||||
|
flow(width: 1.0, height: 36, padding: 8) do
|
||||||
|
background 0xff_0052c0
|
||||||
|
|
||||||
|
title @options[:title] || "Launcher Update Available", fill: true, text_align: :center, font: BOLD_FONT
|
||||||
|
end
|
||||||
|
|
||||||
|
stack(width: 1.0, fill: true, margin_top: 14) do
|
||||||
|
subtitle "Release Notes - #{@options[:available_version]}"
|
||||||
|
|
||||||
|
# case launcher_release_type
|
||||||
|
# when :git
|
||||||
|
# when :tebako
|
||||||
|
# end
|
||||||
|
|
||||||
|
pp @options[:release_data]
|
||||||
|
|
||||||
|
stack(width: 1.0, fill: true, scroll: true, padding: 8, border_thickness: 1, border_color: 0x44_ffffff) do
|
||||||
|
# para @options[:release_data][:body], width: 1.0
|
||||||
|
# FIXME: Finish this bit
|
||||||
|
@options[:release_data][:body].lines.each do |line|
|
||||||
|
line.strip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
flow(width: 1.0, height: 46, margin_top: 16) do
|
||||||
|
background 0xff_ffffff
|
||||||
|
|
||||||
|
button "Cancel", width: 0.25 do
|
||||||
|
pop_state
|
||||||
|
@options[:cancel_callback]&.call
|
||||||
|
end
|
||||||
|
|
||||||
|
flow(fill: true)
|
||||||
|
|
||||||
|
button "Update", width: 0.25 do
|
||||||
|
pop_state
|
||||||
|
@options[:accept_callback]&.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -138,7 +138,7 @@ class W3DHub
|
|||||||
if Gosu.milliseconds >= @applications_expire
|
if Gosu.milliseconds >= @applications_expire
|
||||||
@applications_expire = Gosu.milliseconds + 30_000
|
@applications_expire = Gosu.milliseconds + 30_000
|
||||||
|
|
||||||
Api.on_thread(:applications) do |applications|
|
Api.on_thread(:_applications) do |applications|
|
||||||
if applications
|
if applications
|
||||||
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
|
@applications_expire = Gosu.milliseconds + APPLICATIONS_UPDATE_INTERVAL # ten minutes
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
class W3DHub
|
class W3DHub
|
||||||
DIR_NAME = "W3DHubAlt"
|
DIR_NAME = "W3DHubAlt"
|
||||||
VERSION = "0.6.2"
|
VERSION = "0.7.0"
|
||||||
end
|
end
|
||||||
@@ -128,6 +128,7 @@ require_relative "lib/states/dialogs/confirm_dialog"
|
|||||||
require_relative "lib/states/dialogs/direct_connect_dialog"
|
require_relative "lib/states/dialogs/direct_connect_dialog"
|
||||||
require_relative "lib/states/dialogs/game_settings_dialog"
|
require_relative "lib/states/dialogs/game_settings_dialog"
|
||||||
require_relative "lib/states/dialogs/import_game_dialog"
|
require_relative "lib/states/dialogs/import_game_dialog"
|
||||||
|
require_relative "lib/states/dialogs/launcher_updater_dialog"
|
||||||
|
|
||||||
require_relative "lib/api"
|
require_relative "lib/api"
|
||||||
require_relative "lib/api/service_status"
|
require_relative "lib/api/service_status"
|
||||||
|
|||||||
Reference in New Issue
Block a user