adds .local/share/millennium and easyeffects
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from urllib.error import URLError
|
||||
from urllib.request import HTTPErrorProcessor, Request, build_opener
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
class NoExceptionErrorProcessor(HTTPErrorProcessor):
|
||||
def http_response(self, request, response):
|
||||
# Allow redirects (3xx) to be processed by the default redirect handler,
|
||||
# but do not raise exceptions for 4xx/5xx — return the raw response instead.
|
||||
try:
|
||||
code = getattr(response, 'status', None)
|
||||
if code is None:
|
||||
code = response.getcode()
|
||||
except Exception:
|
||||
code = None
|
||||
|
||||
# Is the code in the 300 range
|
||||
if code is not None and 300 <= code < 400:
|
||||
# Delegate to the parent to trigger HTTPRedirectHandler
|
||||
return HTTPErrorProcessor.http_response(self, request, response)
|
||||
|
||||
# For non-redirects (incl. 2xx, 4xx, 5xx), just return the response without raising
|
||||
return response
|
||||
|
||||
def https_response(self, request, response):
|
||||
# Apply the same logic for HTTPS
|
||||
return self.http_response(request, response)
|
||||
|
||||
# Proxy request handler
|
||||
class ProxyHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self._handle_request()
|
||||
|
||||
def do_POST(self):
|
||||
self._handle_request()
|
||||
|
||||
def do_PUT(self):
|
||||
self._handle_request()
|
||||
|
||||
def do_DELETE(self):
|
||||
self._handle_request()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
# Handle preflight requests for CORS
|
||||
self.send_response(200)
|
||||
self._send_cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
def _send_cors_headers(self):
|
||||
# Add CORS headers to allow requests from any origin
|
||||
self.send_header('Access-Control-Allow-Origin', 'https://steamloopback.host')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Authorization')
|
||||
self.send_header('Access-Control-Allow-Credentials', 'true')
|
||||
self.send_header('Access-Control-Max-Age', '86400') # 24 hours
|
||||
|
||||
def add_special_case_headers(self, path, headers):
|
||||
if path.startswith('https://extension.steamdb.info'):
|
||||
headers['Origin'] = 'https://github.com/BossSloth/Steam-SteamDB-extension'
|
||||
|
||||
def _handle_request(self):
|
||||
try:
|
||||
# Forward the request to the actual destination
|
||||
if self.path.startswith('/proxy/'):
|
||||
# Remove the /proxy/ prefix
|
||||
target_url = self.path[7:]
|
||||
if not target_url.startswith('http'):
|
||||
target_url = 'https://' + target_url
|
||||
|
||||
# Create a request with the same headers
|
||||
headers = {key: val for key, val in self.headers.items()
|
||||
if key.lower() not in ('host', 'connection', 'transfer-encoding', 'origin')}
|
||||
|
||||
# Add security headers
|
||||
headers['Sec-Fetch-Site'] = 'cross-site'
|
||||
headers['Sec-Fetch-Mode'] = 'cors'
|
||||
headers['Sec-Fetch-Dest'] = 'empty'
|
||||
self.add_special_case_headers(target_url, headers)
|
||||
|
||||
# Get request body for POST/PUT requests
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else None
|
||||
|
||||
# Create the request
|
||||
req = Request(target_url, data=body, headers=headers, method=self.command)
|
||||
|
||||
try:
|
||||
# Create a custom opener that doesn't raise exceptions for HTTP error codes
|
||||
opener = build_opener(NoExceptionErrorProcessor())
|
||||
|
||||
# Forward the request and get the response
|
||||
response = opener.open(req)
|
||||
|
||||
# Send the response status code
|
||||
self.send_response(response.status)
|
||||
|
||||
# Add CORS headers
|
||||
self._send_cors_headers()
|
||||
|
||||
# Get response data first to determine content length
|
||||
response_data = response.read()
|
||||
|
||||
# Send the response headers (except those that might conflict with CORS or chunked encoding)
|
||||
for header, value in response.getheaders():
|
||||
if header.lower() not in ('access-control-allow-origin', 'access-control-allow-methods',
|
||||
'access-control-allow-headers', 'access-control-max-age', 'access-control-allow-credentials',
|
||||
'transfer-encoding', 'content-length'):
|
||||
self.send_header(header, value)
|
||||
|
||||
# Set content length explicitly to avoid chunked encoding
|
||||
self.send_header('Content-Length', str(len(response_data)))
|
||||
self.end_headers()
|
||||
|
||||
# Send the response body
|
||||
self.wfile.write(response_data)
|
||||
|
||||
except URLError as e:
|
||||
self.send_response(500)
|
||||
self._send_cors_headers()
|
||||
error_message = str(e).encode()
|
||||
self.send_header('Content-Length', str(len(error_message)))
|
||||
self.end_headers()
|
||||
self.wfile.write(error_message)
|
||||
else:
|
||||
# Handle other requests or serve local content
|
||||
self.send_response(404)
|
||||
self._send_cors_headers()
|
||||
not_found_message = b"Not found"
|
||||
self.send_header('Content-Length', str(len(not_found_message)))
|
||||
self.end_headers()
|
||||
self.wfile.write(not_found_message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Proxy error: {str(e)}")
|
||||
self.send_response(500)
|
||||
self._send_cors_headers()
|
||||
error_message = f"Internal server error: {str(e)}".encode()
|
||||
self.send_header('Content-Length', str(len(error_message)))
|
||||
self.end_headers()
|
||||
self.wfile.write(error_message)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
class CORSProxy:
|
||||
def __init__(self, port: int, host: str = '127.0.0.1'):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.server = None
|
||||
self.server_thread = None
|
||||
|
||||
def start(self):
|
||||
"""Start the proxy server"""
|
||||
try:
|
||||
# Create the server
|
||||
handler = ProxyHandler
|
||||
self.server = socketserver.ThreadingTCPServer((self.host, self.port), handler)
|
||||
self.server.allow_reuse_address = True
|
||||
|
||||
logger.log(f"Starting CORS proxy server on http://{self.host}:{self.port}")
|
||||
|
||||
# Start the server in a thread
|
||||
def serve_with_logging():
|
||||
try:
|
||||
self.server.serve_forever()
|
||||
except Exception as e:
|
||||
logger.log(f"CORS proxy server error: {e}")
|
||||
finally:
|
||||
logger.log("CORS proxy server thread ending")
|
||||
|
||||
self.server_thread = threading.Thread(target=serve_with_logging)
|
||||
self.server_thread.daemon = False
|
||||
self.server_thread.start()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start proxy server: {str(e)}")
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stop the proxy server"""
|
||||
if self.server:
|
||||
logger.log("Stopping CORS proxy server...")
|
||||
|
||||
try:
|
||||
self.server.shutdown()
|
||||
except Exception as e:
|
||||
logger.log(f"Error during server shutdown: {e}")
|
||||
|
||||
try:
|
||||
self.server.server_close()
|
||||
except Exception as e:
|
||||
logger.log(f"Error closing server socket: {e}")
|
||||
|
||||
# wait for server thread to finish properly
|
||||
if self.server_thread and self.server_thread.is_alive():
|
||||
logger.log("Waiting for proxy server thread to finish...")
|
||||
self.server_thread.join(timeout=10)
|
||||
|
||||
if self.server_thread.is_alive():
|
||||
logger.error("Proxy server thread did not finish within timeout!")
|
||||
|
||||
logger.log("CORS proxy server shutdown complete")
|
||||
self.server = None
|
||||
self.server_thread = None
|
||||
return True
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
proxy = CORSProxy(port=8792)
|
||||
proxy.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
proxy.stop()
|
||||
@@ -0,0 +1,108 @@
|
||||
-- Configuration and path helpers for extension installation
|
||||
|
||||
local millennium = require("millennium")
|
||||
local fs = require("fs")
|
||||
local json = require("json")
|
||||
local utils = require("utils")
|
||||
local logger = require("logger")
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.is_windows()
|
||||
return jit.os == "Windows"
|
||||
end
|
||||
|
||||
function M.get_plugin_dir()
|
||||
local backend_path = utils.get_backend_path()
|
||||
if not backend_path then
|
||||
return nil
|
||||
end
|
||||
return fs.parent_path(backend_path)
|
||||
end
|
||||
|
||||
function M.get_extension_dir()
|
||||
local plugin_dir = M.get_plugin_dir()
|
||||
if not plugin_dir then
|
||||
return nil
|
||||
end
|
||||
return fs.join(plugin_dir, "fake-header-extension")
|
||||
end
|
||||
|
||||
function M.get_steam_config_dir()
|
||||
local steam_path = millennium.steam_path()
|
||||
logger:info("[install] Steam path: " .. tostring(steam_path))
|
||||
|
||||
local is_win = M.is_windows()
|
||||
logger:info("[install] Platform: " .. (is_win and "Windows" or "Linux"))
|
||||
|
||||
if is_win then
|
||||
-- Windows: %LOCALAPPDATA%\Steam\htmlcache\Default
|
||||
local appdata = utils.getenv("LOCALAPPDATA")
|
||||
if appdata then
|
||||
return fs.join(appdata, "Steam", "htmlcache", "Default")
|
||||
end
|
||||
return nil
|
||||
else
|
||||
-- Linux: ~/.local/share/Steam/config/htmlcache/Default
|
||||
-- Or symlink: ~/.steam/steam/config/htmlcache/Default
|
||||
local home = utils.getenv("HOME")
|
||||
if home then
|
||||
return fs.join(home, ".steam", "steam", "config", "htmlcache", "Default")
|
||||
end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Generic JSON file reader with logging
|
||||
function M.read_json_file(path, label)
|
||||
logger:info("[install] Reading " .. label .. ": " .. path)
|
||||
|
||||
if not fs.exists(path) then
|
||||
logger:error("[install] " .. label .. " not found at: " .. path)
|
||||
return nil
|
||||
end
|
||||
|
||||
local content, err = utils.read_file(path)
|
||||
if not content then
|
||||
logger:error("[install] Failed to read " .. label .. ": " .. (err or "unknown error"))
|
||||
return nil
|
||||
end
|
||||
|
||||
local data, decode_err = json.decode(content)
|
||||
if not data then
|
||||
logger:error("[install] Failed to decode " .. label .. ": " .. (decode_err or "unknown error"))
|
||||
return nil
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
function M.read_extension_keys()
|
||||
local extension_dir = M.get_extension_dir()
|
||||
if not extension_dir then
|
||||
logger:error("[install] Failed to get extension directory")
|
||||
return nil
|
||||
end
|
||||
|
||||
local keys = M.read_json_file(fs.join(extension_dir, "extension-keys.json"), "extension-keys.json")
|
||||
if keys then
|
||||
logger:info("[install] Extension ID: " .. tostring(keys.extensionId))
|
||||
end
|
||||
return keys
|
||||
end
|
||||
|
||||
function M.read_manifest()
|
||||
local extension_dir = M.get_extension_dir()
|
||||
if not extension_dir then
|
||||
logger:error("[install] Failed to get extension directory")
|
||||
return nil
|
||||
end
|
||||
|
||||
local manifest = M.read_json_file(fs.join(extension_dir, "manifest.json"), "manifest.json")
|
||||
if manifest then
|
||||
logger:info("[install] Extension: " .. tostring(manifest.name) .. " v" .. tostring(manifest.version))
|
||||
end
|
||||
return manifest
|
||||
end
|
||||
|
||||
return M
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
-- Extension settings builder for Chrome preferences
|
||||
|
||||
local sys_utils = require("utils")
|
||||
local install_utils = require("install_extension.utils")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Get Windows FILETIME (100-nanosecond intervals since January 1, 1601)
|
||||
local function get_windows_file_time()
|
||||
local epoch_diff = 11644473600 -- seconds between 1601 and 1970
|
||||
local now = sys_utils.time() -- current Unix timestamp in seconds
|
||||
local windows_time = (now + epoch_diff) * 10000000 -- convert to 100-ns intervals
|
||||
return string.format("%.0f", windows_time)
|
||||
end
|
||||
|
||||
function M.build_extension_settings(extension_dir, manifest)
|
||||
local permissions = manifest.permissions or {}
|
||||
local api_permissions = {}
|
||||
|
||||
for _, p in ipairs(permissions) do
|
||||
-- Filter out URL patterns, keep API permissions
|
||||
if not p:match("://") and not p:match("^<") and not p:match("^%*") then
|
||||
table.insert(api_permissions, p)
|
||||
end
|
||||
end
|
||||
|
||||
local file_time = get_windows_file_time()
|
||||
|
||||
return {
|
||||
active_permissions = {
|
||||
api = #api_permissions > 0 and api_permissions or install_utils.empty_array(),
|
||||
explicit_host = { "<all_urls>" },
|
||||
manifest_permissions = install_utils.empty_array(),
|
||||
scriptable_host = { "<all_urls>" },
|
||||
},
|
||||
commands = {},
|
||||
content_settings = install_utils.empty_array(),
|
||||
creation_flags = 38,
|
||||
first_install_time = file_time,
|
||||
from_webstore = false,
|
||||
granted_permissions = {
|
||||
api = #api_permissions > 0 and api_permissions or install_utils.empty_array(),
|
||||
explicit_host = { "<all_urls>" },
|
||||
manifest_permissions = install_utils.empty_array(),
|
||||
scriptable_host = { "<all_urls>" },
|
||||
},
|
||||
incognito_content_settings = install_utils.empty_array(),
|
||||
incognito_preferences = {},
|
||||
last_update_time = file_time,
|
||||
location = 4, -- kUnpacked (developer mode)
|
||||
newAllowFileAccess = true,
|
||||
path = extension_dir,
|
||||
preferences = {},
|
||||
regular_only_preferences = {},
|
||||
state = 1, -- Enabled
|
||||
was_installed_by_default = false,
|
||||
was_installed_by_oem = false,
|
||||
withholding_permissions = false,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,67 @@
|
||||
-- HMAC Context for computing MACs on Chrome preferences
|
||||
|
||||
local logger = require("logger")
|
||||
local json_helpers = require("install_extension.json_helpers")
|
||||
local sha2 = require("install_extension.sha2")
|
||||
|
||||
---@class HmacContext
|
||||
---@field sid string
|
||||
---@field seed_hex string
|
||||
---@field seed_bytes string
|
||||
local HmacContext = {}
|
||||
HmacContext.__index = HmacContext
|
||||
|
||||
---@param sid string
|
||||
---@param seed_hex string
|
||||
---@return HmacContext
|
||||
function HmacContext.new(sid, seed_hex)
|
||||
local self = setmetatable({}, HmacContext)
|
||||
self.sid = sid or ""
|
||||
self.seed_hex = seed_hex or "" -- Empty for Steam CEF
|
||||
self.seed_bytes = sha2.hex_to_bin(seed_hex or "")
|
||||
return self
|
||||
end
|
||||
|
||||
---@param json_path string
|
||||
---@param value any
|
||||
---@return string|nil
|
||||
function HmacContext:compute_mac(json_path, value)
|
||||
local cleaned = json_helpers.remove_empty_children(value)
|
||||
-- Use sorted JSON for consistent key ordering (must match JS JSON.stringify)
|
||||
local json_value = json_helpers.encode_sorted(cleaned)
|
||||
json_value = json_helpers.escape_for_hmac(json_value)
|
||||
|
||||
local message = self.sid .. json_path .. json_value
|
||||
logger:info("[install] Computing MAC for path: " .. json_path)
|
||||
logger:info("[install] Message length: " .. #message)
|
||||
logger:info("[install] JSON preview: " .. json_value:sub(1, 100) .. "...")
|
||||
|
||||
local mac = string.upper(sha2.hmac(sha2.sha256, self.seed_bytes, message))
|
||||
if mac then
|
||||
logger:info("[install] MAC: " .. mac:sub(1, 16) .. "...")
|
||||
else
|
||||
logger:error("[install] MAC computation returned nil!")
|
||||
end
|
||||
return mac
|
||||
end
|
||||
|
||||
---@param macs table
|
||||
---@return string|nil
|
||||
function HmacContext:compute_super_mac(macs)
|
||||
-- Use sorted JSON for consistent key ordering
|
||||
local macs_json = json_helpers.encode_sorted(macs)
|
||||
local message = self.sid .. macs_json
|
||||
|
||||
logger:info("[install] Computing super MAC...")
|
||||
logger:info("[install] MACs JSON: " .. macs_json:sub(1, 100) .. "...")
|
||||
|
||||
local mac = string.upper(sha2.hmac(sha2.sha256, self.seed_bytes, message))
|
||||
if mac then
|
||||
logger:info("[install] Super MAC: " .. mac:sub(1, 16) .. "...")
|
||||
else
|
||||
logger:error("[install] Super MAC computation returned nil!")
|
||||
end
|
||||
return mac
|
||||
end
|
||||
|
||||
return HmacContext
|
||||
@@ -0,0 +1,299 @@
|
||||
-- Steam Extension Installer for Millennium
|
||||
-- Installs the fake-header-extension into Steam's Chromium preferences
|
||||
|
||||
local fs = require("fs")
|
||||
local json = require("json")
|
||||
local sys_utils = require("utils")
|
||||
local logger = require("logger")
|
||||
|
||||
local config = require("install_extension.config")
|
||||
local install_utils = require("install_extension.utils")
|
||||
local extension_builder = require("install_extension.extension_builder")
|
||||
local HmacContext = require("install_extension.hmac")
|
||||
local windows = require("install_extension.windows")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Helper to load prefs file with fallback to empty table
|
||||
local function load_prefs_file(path, label)
|
||||
if not fs.exists(path) then
|
||||
logger:warn("[install] " .. label .. " not found, will create new")
|
||||
return {}
|
||||
end
|
||||
local data = config.read_json_file(path, label)
|
||||
if data then
|
||||
logger:info("[install] Loaded existing " .. label)
|
||||
end
|
||||
return data or {}
|
||||
end
|
||||
|
||||
-- Set up extension in a prefs object
|
||||
local function setup_extension_prefs(prefs, extension_id, extension_settings, hmac_ctx, ext_mac, dev_mode_mac, compute_super)
|
||||
install_utils.ensure_nested(prefs, "extensions", "settings")
|
||||
install_utils.ensure_nested(prefs, "extensions", "ui")
|
||||
prefs.extensions.ui.developer_mode = true
|
||||
prefs.extensions.settings[extension_id] = extension_settings
|
||||
|
||||
if hmac_ctx then
|
||||
install_utils.ensure_nested(prefs, "protection", "macs", "extensions", "settings")
|
||||
install_utils.ensure_nested(prefs, "protection", "ui")
|
||||
if ext_mac then prefs.protection.macs.extensions.settings[extension_id] = ext_mac end
|
||||
if dev_mode_mac then prefs.protection.ui.developer_mode = dev_mode_mac end
|
||||
|
||||
if compute_super then
|
||||
local super_mac = hmac_ctx:compute_super_mac(prefs.protection.macs)
|
||||
if super_mac then
|
||||
prefs.protection.super_mac = super_mac
|
||||
logger:info("[install] Super MAC computed")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function kill_steam_webhelper()
|
||||
if config.is_windows() then
|
||||
local msg = "Steam has been closed to complete the Extendium plugin installation. Please restart Steam."
|
||||
sys_utils.exec('start "" powershell -WindowStyle Hidden -Command "taskkill /F /IM steamwebhelper.exe; taskkill /F /IM steam.exe; Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(\'' .. msg .. '\', \'Steam restart required - extendium\', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning)"')
|
||||
else
|
||||
sys_utils.exec('sleep 1 && pkill -KILL -f steam')
|
||||
local msg = "Steam has been closed to complete the Extendium plugin installation.\\nPlease restart Steam."
|
||||
sys_utils.exec('zenity --info --title "Steam restart required - extendium" --text "' .. msg .. '" &')
|
||||
end
|
||||
logger:info("[install] Steam webhelper killed")
|
||||
end
|
||||
|
||||
function M.install()
|
||||
logger:info("========================================")
|
||||
logger:info("[install] Starting extension installation...")
|
||||
logger:info("========================================")
|
||||
|
||||
-- Get paths
|
||||
local extension_dir = config.get_extension_dir()
|
||||
if not extension_dir then
|
||||
logger:error("[install] Failed to get extension directory")
|
||||
return json.encode({ success = false, error = "Failed to get extension directory" })
|
||||
end
|
||||
logger:info("[install] Extension directory: " .. extension_dir)
|
||||
|
||||
if not fs.exists(extension_dir) then
|
||||
logger:error("[install] Extension directory does not exist: " .. extension_dir)
|
||||
return json.encode({ success = false, error = "Extension directory not found" })
|
||||
end
|
||||
|
||||
-- Read extension keys (contains pre-computed extension ID)
|
||||
local keys = config.read_extension_keys()
|
||||
if not keys or not keys.extensionId then
|
||||
return json.encode({ success = false, error = "Failed to read extension keys" })
|
||||
end
|
||||
local extension_id = keys.extensionId
|
||||
|
||||
-- Read manifest
|
||||
local manifest = config.read_manifest()
|
||||
if not manifest then
|
||||
return json.encode({ success = false, error = "Failed to read manifest" })
|
||||
end
|
||||
|
||||
-- Get Steam config directory
|
||||
local config_dir = config.get_steam_config_dir()
|
||||
if not config_dir then
|
||||
logger:error("[install] Failed to determine Steam config directory")
|
||||
return json.encode({ success = false, error = "Failed to determine Steam config directory" })
|
||||
end
|
||||
logger:info("[install] Steam config directory: " .. config_dir)
|
||||
|
||||
if not fs.exists(config_dir) then
|
||||
logger:error("[install] Steam config directory does not exist: " .. config_dir)
|
||||
return json.encode({ success = false, error = "Steam config directory not found. Has Steam been run?" })
|
||||
end
|
||||
|
||||
-- Resolve extension path to canonical form
|
||||
local canonical_extension_dir = fs.canonical(extension_dir)
|
||||
if canonical_extension_dir then
|
||||
extension_dir = canonical_extension_dir
|
||||
logger:info("[install] Canonical extension path: " .. extension_dir)
|
||||
end
|
||||
|
||||
-- Build extension settings
|
||||
local extension_settings = extension_builder.build_extension_settings(extension_dir, manifest)
|
||||
logger:info("[install] Built extension settings")
|
||||
|
||||
-- Initialize HMAC context (Windows only)
|
||||
---@type HmacContext|nil
|
||||
local hmac_ctx = nil
|
||||
if config.is_windows() then
|
||||
logger:info("[install] Initializing HMAC context for Windows...")
|
||||
local sid = windows.get_windows_sid()
|
||||
if sid and sid ~= "" then
|
||||
-- Steam CEF uses empty seed (like Edge/Brave)
|
||||
hmac_ctx = HmacContext.new(sid, "")
|
||||
logger:info("[install] HMAC context initialized")
|
||||
else
|
||||
logger:warn("[install] Could not get Windows SID, HMAC will be skipped")
|
||||
end
|
||||
else
|
||||
logger:info("[install] Linux detected, HMAC not required")
|
||||
end
|
||||
|
||||
-- ========================================================================
|
||||
-- PHASE 1: Read all files and compute everything BEFORE writing
|
||||
-- ========================================================================
|
||||
logger:info("[install] Phase 1: Reading files and computing signatures...")
|
||||
|
||||
local prefs_path = fs.join(config_dir, "Preferences")
|
||||
local secure_prefs_path = fs.join(config_dir, "Secure Preferences")
|
||||
|
||||
local prefs = load_prefs_file(prefs_path, "Preferences")
|
||||
local secure_prefs = fs.exists(secure_prefs_path) and load_prefs_file(secure_prefs_path, "Secure Preferences") or nil
|
||||
|
||||
-- Compute HMACs if needed (Windows only) - do this BEFORE modifying prefs
|
||||
local ext_mac, dev_mode_mac
|
||||
if hmac_ctx then
|
||||
logger:info("[install] Computing HMAC signatures...")
|
||||
ext_mac = hmac_ctx:compute_mac("extensions.settings." .. extension_id, extension_settings)
|
||||
if ext_mac then logger:info("[install] Extension MAC computed") end
|
||||
|
||||
dev_mode_mac = hmac_ctx:compute_mac("extensions.ui.developer_mode", true)
|
||||
if dev_mode_mac then logger:info("[install] Developer mode MAC computed") end
|
||||
end
|
||||
|
||||
-- Set up both Preferences and Secure Preferences
|
||||
setup_extension_prefs(prefs, extension_id, extension_settings, hmac_ctx, ext_mac, dev_mode_mac, false)
|
||||
if secure_prefs then
|
||||
setup_extension_prefs(secure_prefs, extension_id, extension_settings, hmac_ctx, ext_mac, dev_mode_mac, true)
|
||||
end
|
||||
|
||||
-- Pre-encode JSON to minimize time between writes and kill
|
||||
logger:info("[install] Encoding JSON...")
|
||||
local prefs_json = json.encode(prefs)
|
||||
local secure_prefs_json = secure_prefs and json.encode(secure_prefs) or nil
|
||||
|
||||
-- ========================================================================
|
||||
-- PHASE 2: Write files and kill Steam IMMEDIATELY after
|
||||
-- ========================================================================
|
||||
logger:info("[install] Phase 2: Writing files and killing Steam...")
|
||||
|
||||
-- Write Preferences
|
||||
local success, write_err = sys_utils.write_file(prefs_path, prefs_json)
|
||||
if not success then
|
||||
logger:error("[install] Failed to write Preferences: " .. (write_err or "unknown"))
|
||||
return json.encode({ success = false, error = "Failed to write Preferences" })
|
||||
end
|
||||
logger:info("[install] Preferences written")
|
||||
|
||||
-- Write Secure Preferences
|
||||
if secure_prefs_json then
|
||||
success, write_err = sys_utils.write_file(secure_prefs_path, secure_prefs_json)
|
||||
if not success then
|
||||
logger:error("[install] Failed to write Secure Preferences: " .. (write_err or "unknown"))
|
||||
return json.encode({ success = false, error = "Failed to write Secure Preferences" })
|
||||
end
|
||||
logger:info("[install] Secure Preferences written")
|
||||
end
|
||||
|
||||
logger:info("========================================")
|
||||
logger:info("[install] Installation complete! Please restart Steam!")
|
||||
logger:info("[install] Extension ID: " .. extension_id)
|
||||
logger:info("[install] Extension Path: " .. extension_dir)
|
||||
logger:info("========================================")
|
||||
|
||||
-- Kill Steam webhelper IMMEDIATELY after writing to prevent it from overriding our changes
|
||||
kill_steam_webhelper()
|
||||
|
||||
return json.encode({
|
||||
success = true,
|
||||
extensionId = extension_id,
|
||||
extensionPath = extension_dir,
|
||||
message = "Extension installed. Restart Steam to load it."
|
||||
})
|
||||
end
|
||||
|
||||
local function check_extension_in_prefs(prefs, extension_id)
|
||||
return prefs.extensions ~= nil and
|
||||
prefs.extensions.settings ~= nil and
|
||||
prefs.extensions.settings[extension_id] ~= nil
|
||||
end
|
||||
|
||||
function M.check_status()
|
||||
logger:info("[check_status] Checking installation status...")
|
||||
|
||||
local keys = config.read_extension_keys()
|
||||
if not keys or not keys.extensionId then
|
||||
return json.encode({ installed = false, error = "Extension keys not found" })
|
||||
end
|
||||
|
||||
local config_dir = config.get_steam_config_dir()
|
||||
if not config_dir then
|
||||
return json.encode({ installed = false, error = "Steam config directory not found" })
|
||||
end
|
||||
|
||||
local installed = false
|
||||
local needs_cleanup = false
|
||||
|
||||
local prefs_path = fs.join(config_dir, "Preferences")
|
||||
local secure_prefs_path = fs.join(config_dir, "Secure Preferences")
|
||||
|
||||
local prefs = load_prefs_file(prefs_path, "Preferences")
|
||||
local secure_prefs = fs.exists(secure_prefs_path) and load_prefs_file(secure_prefs_path, "Secure Preferences") or nil
|
||||
|
||||
if prefs and check_extension_in_prefs(prefs, keys.extensionId) then
|
||||
logger:info("[check_status] Extension found in Preferences")
|
||||
|
||||
local ext_path = prefs.extensions.settings[keys.extensionId].path
|
||||
if ext_path and not fs.exists(ext_path) then
|
||||
logger:warn("[check_status] Extension path does not exist: " .. ext_path)
|
||||
needs_cleanup = true
|
||||
else
|
||||
installed = true
|
||||
end
|
||||
end
|
||||
|
||||
if secure_prefs and check_extension_in_prefs(secure_prefs, keys.extensionId) then
|
||||
logger:info("[check_status] Extension found in Secure Preferences")
|
||||
|
||||
local ext_path = secure_prefs.extensions.settings[keys.extensionId].path
|
||||
if ext_path and not fs.exists(ext_path) then
|
||||
logger:warn("[check_status] Extension path does not exist in Secure Preferences: " .. ext_path)
|
||||
needs_cleanup = true
|
||||
else
|
||||
installed = true
|
||||
end
|
||||
end
|
||||
|
||||
if needs_cleanup then
|
||||
logger:info("[check_status] Cleaning up invalid extension entries...")
|
||||
|
||||
if prefs and check_extension_in_prefs(prefs, keys.extensionId) then
|
||||
prefs.extensions.settings[keys.extensionId] = nil
|
||||
local prefs_json = json.encode(prefs)
|
||||
local success, write_err = sys_utils.write_file(prefs_path, prefs_json)
|
||||
if success then
|
||||
logger:info("[check_status] Removed invalid extension from Preferences")
|
||||
else
|
||||
logger:error("[check_status] Failed to clean Preferences: " .. (write_err or "unknown"))
|
||||
end
|
||||
end
|
||||
|
||||
if secure_prefs and check_extension_in_prefs(secure_prefs, keys.extensionId) then
|
||||
secure_prefs.extensions.settings[keys.extensionId] = nil
|
||||
local secure_prefs_json = json.encode(secure_prefs)
|
||||
local success, write_err = sys_utils.write_file(secure_prefs_path, secure_prefs_json)
|
||||
if success then
|
||||
logger:info("[check_status] Removed invalid extension from Secure Preferences")
|
||||
else
|
||||
logger:error("[check_status] Failed to clean Secure Preferences: " .. (write_err or "unknown"))
|
||||
end
|
||||
end
|
||||
|
||||
kill_steam_webhelper()
|
||||
installed = false
|
||||
end
|
||||
|
||||
logger:info("[check_status] Installation status: " .. (installed and "installed" or "not installed"))
|
||||
|
||||
return json.encode({
|
||||
installed = installed,
|
||||
extensionId = keys.extensionId
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,108 @@
|
||||
-- JSON helpers for HMAC computation
|
||||
|
||||
local json = require("json")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Encode JSON with sorted keys (critical for HMAC - must match JS JSON.stringify order)
|
||||
function M.encode_sorted(value)
|
||||
local t = type(value)
|
||||
|
||||
if value == nil then
|
||||
return "null"
|
||||
elseif t == "boolean" then
|
||||
return value and "true" or "false"
|
||||
elseif t == "number" then
|
||||
if value == math.floor(value) and value >= -2147483648 and value <= 2147483647 then
|
||||
return string.format("%d", value)
|
||||
else
|
||||
return string.format("%.14g", value)
|
||||
end
|
||||
elseif t == "string" then
|
||||
-- Use cjson for proper string escaping
|
||||
return json.encode(value)
|
||||
elseif t == "table" then
|
||||
-- Check if array (sequential integer keys starting at 1)
|
||||
local is_array = false
|
||||
local max_idx = 0
|
||||
local count = 0
|
||||
for k, _ in pairs(value) do
|
||||
count = count + 1
|
||||
if type(k) == "number" and k == math.floor(k) and k >= 1 then
|
||||
if k > max_idx then max_idx = k end
|
||||
end
|
||||
end
|
||||
-- It's an array if all keys are sequential integers 1..n
|
||||
if count > 0 and max_idx == count then
|
||||
is_array = true
|
||||
for i = 1, max_idx do
|
||||
if value[i] == nil then
|
||||
is_array = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Empty table - check metatable hint
|
||||
if next(value) == nil then
|
||||
local mt = getmetatable(value)
|
||||
if mt and mt.__jsontype == "array" then
|
||||
return "[]"
|
||||
end
|
||||
-- Default empty table to object
|
||||
return "{}"
|
||||
end
|
||||
|
||||
if is_array then
|
||||
local parts = {}
|
||||
for i = 1, max_idx do
|
||||
parts[i] = M.encode_sorted(value[i])
|
||||
end
|
||||
return "[" .. table.concat(parts, ",") .. "]"
|
||||
else
|
||||
-- Object - sort keys alphabetically (matches JS insertion order in our case)
|
||||
local keys = {}
|
||||
for k, v in pairs(value) do
|
||||
-- Only include string keys with non-nil values
|
||||
if type(k) == "string" and v ~= nil then
|
||||
table.insert(keys, k)
|
||||
end
|
||||
end
|
||||
table.sort(keys)
|
||||
|
||||
local parts = {}
|
||||
for _, k in ipairs(keys) do
|
||||
local v = value[k]
|
||||
-- Double-check for nil (shouldn't happen but safety net)
|
||||
if v ~= nil then
|
||||
table.insert(parts, json.encode(k) .. ":" .. M.encode_sorted(v))
|
||||
end
|
||||
end
|
||||
return "{" .. table.concat(parts, ",") .. "}"
|
||||
end
|
||||
else
|
||||
return "null"
|
||||
end
|
||||
end
|
||||
|
||||
-- Remove empty tables and arrays (Chromium's DeepCopyWithoutEmptyChildren)
|
||||
function M.remove_empty_children(obj)
|
||||
if type(obj) ~= "table" then return obj end
|
||||
if next(obj) == nil then return nil end
|
||||
|
||||
local cleaned = {}
|
||||
for k, v in pairs(obj) do
|
||||
local cleaned_v = M.remove_empty_children(v)
|
||||
if cleaned_v ~= nil and (type(cleaned_v) ~= "table" or next(cleaned_v) ~= nil) then
|
||||
cleaned[k] = cleaned_v
|
||||
end
|
||||
end
|
||||
|
||||
return next(cleaned) ~= nil and cleaned or nil
|
||||
end
|
||||
|
||||
-- Escape '<' in JSON string (Chromium requirement)
|
||||
function M.escape_for_hmac(json_str)
|
||||
return json_str:gsub("<", "\\u003C")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,276 @@
|
||||
--------------------------------------------------------------------------------------------------------------------------
|
||||
-- sha2.lua (Minimal version for HMAC-SHA256)
|
||||
-- AUTHOR: Egor Skriptunoff
|
||||
-- LICENSE: MIT (the same license as Lua itself)
|
||||
-- URL: https://github.com/Egor-Skriptunoff/pure_lua_SHA
|
||||
-- Only contains sha256 and hmac functions for use with hmac.lua
|
||||
-- Optimized for LuaJIT 5.1
|
||||
--------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
local unpack, table_concat, byte, char, string_rep, sub, gsub, string_format, floor =
|
||||
table.unpack or unpack, table.concat, string.byte, string.char, string.rep, string.sub, string.gsub, string.format, math.floor
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- BRANCH DETECTION (LuaJIT)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local is_LuaJIT = jit and jit.version
|
||||
local branch = is_LuaJIT and "LJ" or "EMUL"
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- BITWISE OPERATORS (LJ branch)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local AND, OR, XOR, SHL, SHR, ROL, ROR, NOT, NORM, HEX, XOR_BYTE
|
||||
|
||||
if branch == "LJ" then
|
||||
local b = bit or bit32
|
||||
local library_name = b and (b.bit32 and "bit32" or "bit")
|
||||
AND = b.band
|
||||
OR = b.bor
|
||||
XOR = b.bxor
|
||||
SHL = b.lshift
|
||||
SHR = b.rshift
|
||||
ROL = b.rol or b.lrotate
|
||||
ROR = b.ror or b.rrotate
|
||||
NOT = b.bnot
|
||||
NORM = b.tobit
|
||||
HEX = b.tohex
|
||||
XOR_BYTE = XOR
|
||||
else
|
||||
error("This minimal sha2.lua only supports LuaJIT with bit library")
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- DATA TABLES
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local sha2_K_hi, sha2_H_hi = {}, {}
|
||||
local sha2_H_ext256 = {[224] = {}, [256] = sha2_H_hi}
|
||||
local common_W = {}
|
||||
local K_lo_modulo = 4294967296
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- MAGIC NUMBERS CALCULATOR (for SHA-256 constants)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
do
|
||||
local function mul(src1, src2, factor, result_length)
|
||||
local result, carry, value, weight = {}, 0.0, 0.0, 1.0
|
||||
for j = 1, result_length do
|
||||
for k = math.max(1, j + 1 - #src2), math.min(j, #src1) do
|
||||
carry = carry + factor * src1[k] * src2[j + 1 - k]
|
||||
end
|
||||
local digit = carry % 2^24
|
||||
result[j] = floor(digit)
|
||||
carry = (carry - digit) / 2^24
|
||||
value = value + digit * weight
|
||||
weight = weight * 2^24
|
||||
end
|
||||
return result, value
|
||||
end
|
||||
|
||||
local idx, step, p, one, sqrt_hi = 0, {4, 1, 2, -2, 2}, 4, {1}, sha2_H_hi
|
||||
repeat
|
||||
p = p + step[p % 6]
|
||||
local d = 1
|
||||
repeat
|
||||
d = d + step[d % 6]
|
||||
if d*d > p then
|
||||
local root = p^(1/3)
|
||||
local R = root * 2^40
|
||||
R = mul({R - R % 1}, one, 1.0, 2)
|
||||
local _, delta = mul(R, mul(R, R, 1.0, 4), -1.0, 4)
|
||||
local hi = R[2] % 65536 * 65536 + floor(R[1] / 256)
|
||||
local lo = R[1] % 256 * 16777216 + floor(delta * (2^-56 / 3) * root / p)
|
||||
if idx < 8 then
|
||||
root = p^(1/2)
|
||||
R = root * 2^40
|
||||
R = mul({R - R % 1}, one, 1.0, 2)
|
||||
_, delta = mul(R, R, -1.0, 2)
|
||||
local hi = R[2] % 65536 * 65536 + floor(R[1] / 256)
|
||||
local lo = R[1] % 256 * 16777216 + floor(delta * 2^-17 / root)
|
||||
local idx = idx % 8 + 1
|
||||
sha2_H_ext256[224][idx] = lo
|
||||
sqrt_hi[idx] = hi
|
||||
end
|
||||
idx = idx + 1
|
||||
sha2_K_hi[idx] = hi
|
||||
break
|
||||
end
|
||||
until p % d == 0
|
||||
until idx > 63
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- SHA256 FEED FUNCTION (LJ branch)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
function sha256_feed_64(H, str, offs, size)
|
||||
local W, K = common_W, sha2_K_hi
|
||||
for pos = offs, offs + size - 1, 64 do
|
||||
for j = 1, 16 do
|
||||
pos = pos + 4
|
||||
local a, b, c, d = byte(str, pos - 3, pos)
|
||||
W[j] = OR(SHL(a, 24), SHL(b, 16), SHL(c, 8), d)
|
||||
end
|
||||
for j = 17, 64 do
|
||||
local a, b = W[j-15], W[j-2]
|
||||
W[j] = NORM( NORM( XOR(ROR(a, 7), ROL(a, 14), SHR(a, 3)) + XOR(ROL(b, 15), ROL(b, 13), SHR(b, 10)) ) + NORM( W[j-7] + W[j-16] ) )
|
||||
end
|
||||
local a, b, c, d, e, f, g, h = H[1], H[2], H[3], H[4], H[5], H[6], H[7], H[8]
|
||||
for j = 1, 64, 8 do
|
||||
local z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j] + W[j] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j+1] + W[j+1] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j+2] + W[j+2] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j+3] + W[j+3] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j+4] + W[j+4] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j+5] + W[j+5] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j+6] + W[j+6] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
z = NORM( XOR(ROR(e, 6), ROR(e, 11), ROL(e, 7)) + XOR(g, AND(e, XOR(f, g))) + (K[j+7] + W[j+7] + h) )
|
||||
h, g, f, e = g, f, e, NORM(d + z)
|
||||
d, c, b, a = c, b, a, NORM( XOR(AND(a, XOR(b, c)), AND(b, c)) + XOR(ROR(a, 2), ROR(a, 13), ROL(a, 10)) + z )
|
||||
end
|
||||
H[1], H[2], H[3], H[4] = NORM(a + H[1]), NORM(b + H[2]), NORM(c + H[3]), NORM(d + H[4])
|
||||
H[5], H[6], H[7], H[8] = NORM(e + H[5]), NORM(f + H[6]), NORM(g + H[7]), NORM(h + H[8])
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- MAIN FUNCTIONS
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local function sha256ext(width, message)
|
||||
local H, length, tail = {unpack(sha2_H_ext256[width])}, 0.0, ""
|
||||
|
||||
local function partial(message_part)
|
||||
if message_part then
|
||||
if tail then
|
||||
length = length + #message_part
|
||||
local offs = 0
|
||||
if tail ~= "" and #tail + #message_part >= 64 then
|
||||
offs = 64 - #tail
|
||||
sha256_feed_64(H, tail..sub(message_part, 1, offs), 0, 64)
|
||||
tail = ""
|
||||
end
|
||||
local size = #message_part - offs
|
||||
local size_tail = size % 64
|
||||
sha256_feed_64(H, message_part, offs, size - size_tail)
|
||||
tail = tail..sub(message_part, #message_part + 1 - size_tail)
|
||||
return partial
|
||||
else
|
||||
error("Adding more chunks is not allowed after receiving the result", 2)
|
||||
end
|
||||
else
|
||||
if tail then
|
||||
local final_blocks = {tail, "\128", string_rep("\0", (-9 - length) % 64 + 1)}
|
||||
tail = nil
|
||||
length = length * (8 / 256^7)
|
||||
for j = 4, 10 do
|
||||
length = length % 1 * 256
|
||||
final_blocks[j] = char(floor(length))
|
||||
end
|
||||
final_blocks = table_concat(final_blocks)
|
||||
sha256_feed_64(H, final_blocks, 0, #final_blocks)
|
||||
local max_reg = width / 32
|
||||
for j = 1, max_reg do
|
||||
H[j] = HEX(H[j])
|
||||
end
|
||||
H = table_concat(H, "", 1, max_reg)
|
||||
end
|
||||
return H
|
||||
end
|
||||
end
|
||||
|
||||
if message then
|
||||
return partial(message)()
|
||||
else
|
||||
return partial
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- HELPER FUNCTIONS
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local hex_to_bin
|
||||
do
|
||||
function hex_to_bin(hex_string)
|
||||
return (gsub(hex_string, "%x%x",
|
||||
function (hh)
|
||||
return char(tonumber(hh, 16))
|
||||
end
|
||||
))
|
||||
end
|
||||
end
|
||||
|
||||
local function pad_and_xor(str, result_length, byte_for_xor)
|
||||
return gsub(str, ".",
|
||||
function(c)
|
||||
return char(XOR_BYTE(byte(c), byte_for_xor))
|
||||
end
|
||||
)..string_rep(char(byte_for_xor), result_length - #str)
|
||||
end
|
||||
|
||||
local block_size_for_HMAC
|
||||
|
||||
---@return string
|
||||
local function hmac(hash_func, key, message)
|
||||
local block_size = block_size_for_HMAC[hash_func]
|
||||
if not block_size then
|
||||
error("Unknown hash function", 2)
|
||||
end
|
||||
if #key > block_size then
|
||||
key = hex_to_bin(hash_func(key))
|
||||
end
|
||||
local append = hash_func()(pad_and_xor(key, block_size, 0x36))
|
||||
local result
|
||||
|
||||
local function partial(message_part)
|
||||
if not message_part then
|
||||
result = result or hash_func(pad_and_xor(key, block_size, 0x5C)..hex_to_bin(append()))
|
||||
return result
|
||||
elseif result then
|
||||
error("Adding more chunks is not allowed after receiving the result", 2)
|
||||
else
|
||||
append(message_part)
|
||||
return partial
|
||||
end
|
||||
end
|
||||
|
||||
if message then
|
||||
return partial(message)()
|
||||
else
|
||||
return partial
|
||||
end
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- MODULE EXPORT
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
local sha = {
|
||||
sha256 = function (message) return sha256ext(256, message) end,
|
||||
hmac = hmac,
|
||||
hex_to_bin = hex_to_bin,
|
||||
}
|
||||
|
||||
block_size_for_HMAC = {
|
||||
[sha.sha256] = 64,
|
||||
}
|
||||
|
||||
return sha
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Utility helpers for extension installation
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Helper to create empty arrays that serialize as [] not {}
|
||||
-- cjson treats tables with no elements ambiguously, this ensures array serialization
|
||||
function M.empty_array()
|
||||
local arr = {}
|
||||
setmetatable(arr, { __jsontype = "array" })
|
||||
return arr
|
||||
end
|
||||
|
||||
-- Ensure nested table path exists, creating tables as needed
|
||||
-- Usage: ensure_nested(t, "a", "b", "c") ensures t.a.b.c exists
|
||||
function M.ensure_nested(t, ...)
|
||||
local current = t
|
||||
for _, key in ipairs({...}) do
|
||||
if not current[key] then current[key] = {} end
|
||||
current = current[key]
|
||||
end
|
||||
return current
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Windows-specific utilities
|
||||
|
||||
local sys_utils = require("utils")
|
||||
local logger = require("logger")
|
||||
local config = require("install_extension.config")
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.get_windows_sid()
|
||||
if not config.is_windows() then
|
||||
return ""
|
||||
end
|
||||
|
||||
logger:info("[install] Getting Windows SID...")
|
||||
|
||||
local cmd = 'powershell -Command "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"'
|
||||
local output, status = sys_utils.exec(cmd)
|
||||
|
||||
if not output then
|
||||
logger:error("[install] Failed to get Windows SID: " .. tostring(status))
|
||||
return ""
|
||||
end
|
||||
|
||||
local sid = sys_utils.trim(output)
|
||||
logger:info("[install] Raw SID: " .. sid)
|
||||
|
||||
-- SID format: S-1-5-21-XXXXXXXXXX-XXXXXXXXXX-XXXXXXXXXX-XXXX
|
||||
-- Remove the last RID component (as extloader does)
|
||||
if sys_utils.startswith(sid, "S-1-") then
|
||||
local parts = sys_utils.split(sid, "-")
|
||||
-- Remove last element
|
||||
table.remove(parts)
|
||||
sid = sys_utils.join(parts, "-")
|
||||
logger:info("[install] SID (without RID): " .. sid)
|
||||
return sid
|
||||
end
|
||||
|
||||
logger:warn("[install] SID doesn't match expected format")
|
||||
return ""
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
local json = require("json")
|
||||
local fs = require("fs")
|
||||
local utils = require("utils")
|
||||
local logger = require("logger")
|
||||
local millennium = require("millennium")
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.HandleLegacyExtensions()
|
||||
local plugin_dir = GetPluginDir()
|
||||
if not plugin_dir then
|
||||
logger:error("Failed to get plugin directory")
|
||||
return json.encode({ error = "Failed to get plugin directory" })
|
||||
end
|
||||
|
||||
local extensions_dir = fs.join(plugin_dir, ".extensions")
|
||||
|
||||
if not fs.exists(extensions_dir) or not fs.is_directory(extensions_dir) then
|
||||
return json.encode({})
|
||||
end
|
||||
|
||||
local extensions = {}
|
||||
local entries, err = fs.list(extensions_dir)
|
||||
|
||||
if entries then
|
||||
for _, entry in ipairs(entries) do
|
||||
if entry.is_directory then
|
||||
local metadata_path = fs.join(entry.path, "metadata.json")
|
||||
local extension_data = {
|
||||
name = string.gsub(entry.name, '-', ' '),
|
||||
hasMetadata = false
|
||||
}
|
||||
|
||||
if fs.is_file(metadata_path) then
|
||||
local content, err = utils.read_file(metadata_path)
|
||||
if content then
|
||||
local metadata, decode_err = json.decode(content)
|
||||
if metadata then
|
||||
extension_data.hasMetadata = true
|
||||
extension_data.extensionId = metadata.extensionId
|
||||
extension_data.url = metadata.url
|
||||
else
|
||||
logger:warn("Failed to decode metadata for " .. entry .. ": " .. (decode_err or "unknown error"))
|
||||
end
|
||||
else
|
||||
logger:warn("Failed to read metadata for " .. entry .. ": " .. (err or "unknown error"))
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(extensions, extension_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #extensions == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
logger:info("Found " .. #extensions .. " legacy extensions")
|
||||
|
||||
local extensions_json = json.encode(extensions)
|
||||
millennium.call_frontend_method("showLegacyExtensionDialog", {extensions_json})
|
||||
end
|
||||
|
||||
---Delete the .extensions folder
|
||||
function M.DeleteLegacyExtensions()
|
||||
local plugin_dir = GetPluginDir()
|
||||
if not plugin_dir then
|
||||
logger:error("Failed to get plugin directory")
|
||||
end
|
||||
|
||||
local extensions_dir = fs.join(plugin_dir, ".extensions")
|
||||
|
||||
if not fs.exists(extensions_dir) or not fs.is_directory(extensions_dir) then
|
||||
return
|
||||
end
|
||||
|
||||
local count, err = fs.remove_all(extensions_dir)
|
||||
if count then
|
||||
logger:info("Deleted legacy extensions folder: " .. count .. " items removed")
|
||||
else
|
||||
logger:error("Failed to delete legacy extensions folder: " .. (err or "unknown error"))
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,3 @@
|
||||
from .logger import logger
|
||||
|
||||
__all__ = ['logger']
|
||||
@@ -0,0 +1,13 @@
|
||||
try:
|
||||
import PluginUtils # type: ignore[import]
|
||||
|
||||
logger = PluginUtils.Logger()
|
||||
except Exception as e:
|
||||
class Logger:
|
||||
def log(self, message: str):
|
||||
print(message)
|
||||
def error(self, message: str):
|
||||
print(message)
|
||||
|
||||
logger = Logger()
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
local millennium = require("millennium")
|
||||
local fs = require("fs")
|
||||
local json = require("json")
|
||||
local utils = require("utils")
|
||||
local logger = require("logger")
|
||||
local install_extension = require("install_extension.init")
|
||||
local legacy_extensions = require("legacy_extensions")
|
||||
local EXTENDIUM_EXTERNAL_LINKS_FILE = "external-links.json"
|
||||
local EXTENDIUM_INSTALL_STATE_FILE = "install-state.json"
|
||||
local utils = require('utils')
|
||||
|
||||
local extendium_settings = {}
|
||||
|
||||
---@return string|nil
|
||||
function GetPluginDir()
|
||||
local backend_path = utils.get_backend_path()
|
||||
if not backend_path then
|
||||
return nil
|
||||
end
|
||||
return fs.parent_path(backend_path)
|
||||
end
|
||||
|
||||
---@return string
|
||||
function GetExternalLinks()
|
||||
local plugin_dir = GetPluginDir()
|
||||
if not plugin_dir then
|
||||
logger:error("Failed to get plugin directory")
|
||||
return "[]"
|
||||
end
|
||||
|
||||
local external_links_path = fs.join(plugin_dir, EXTENDIUM_EXTERNAL_LINKS_FILE)
|
||||
|
||||
if fs.is_file(external_links_path) then
|
||||
local content, err = utils.read_file(external_links_path)
|
||||
if content then
|
||||
return content
|
||||
else
|
||||
logger:error("Error reading external links " .. external_links_path .. ": " .. (err or "unknown error"))
|
||||
end
|
||||
end
|
||||
|
||||
return "[]"
|
||||
end
|
||||
|
||||
---@param external_links string
|
||||
---@return string
|
||||
function UpdateExternalLinks(external_links)
|
||||
local plugin_dir = GetPluginDir()
|
||||
if not plugin_dir then
|
||||
logger:error("Failed to get plugin directory")
|
||||
return "error"
|
||||
end
|
||||
|
||||
local external_links_path = fs.join(plugin_dir, EXTENDIUM_EXTERNAL_LINKS_FILE)
|
||||
|
||||
local success, err = utils.write_file(external_links_path, external_links)
|
||||
if not success then
|
||||
logger:error("Error writing external links " .. external_links_path .. ": " .. (err or "unknown error"))
|
||||
end
|
||||
|
||||
return "success"
|
||||
end
|
||||
|
||||
---@return table
|
||||
function GetInstallState()
|
||||
local plugin_dir = GetPluginDir()
|
||||
if not plugin_dir then
|
||||
logger:error("Failed to get plugin directory")
|
||||
return {}
|
||||
end
|
||||
|
||||
local state_path = fs.join(plugin_dir, EXTENDIUM_INSTALL_STATE_FILE)
|
||||
|
||||
if fs.is_file(state_path) then
|
||||
local content, err = utils.read_file(state_path)
|
||||
if content then
|
||||
local state, decode_err = json.decode(content)
|
||||
if state then
|
||||
return state
|
||||
else
|
||||
logger:error("Error decoding install state " .. state_path .. ": " .. (decode_err or "unknown error"))
|
||||
end
|
||||
else
|
||||
logger:error("Error reading install state " .. state_path .. ": " .. (err or "unknown error"))
|
||||
end
|
||||
end
|
||||
|
||||
return {}
|
||||
end
|
||||
|
||||
---@param state table
|
||||
---@return string
|
||||
function SaveInstallState(state)
|
||||
local plugin_dir = GetPluginDir()
|
||||
if not plugin_dir then
|
||||
logger:error("Failed to get plugin directory")
|
||||
return "error"
|
||||
end
|
||||
|
||||
local state_path = fs.join(plugin_dir, EXTENDIUM_INSTALL_STATE_FILE)
|
||||
local state_json = json.encode(state)
|
||||
|
||||
local success, err = utils.write_file(state_path, state_json)
|
||||
if not success then
|
||||
logger:error("Error writing install state " .. state_path .. ": " .. (err or "unknown error"))
|
||||
end
|
||||
|
||||
return "success"
|
||||
end
|
||||
|
||||
---@param settings string
|
||||
---@return string
|
||||
function UpdateSettings(settings)
|
||||
local settings = json.decode(settings)
|
||||
if settings then
|
||||
extendium_settings = settings
|
||||
else
|
||||
logger:error("Error decoding settings JSON")
|
||||
end
|
||||
|
||||
return "success"
|
||||
end
|
||||
|
||||
---@return string
|
||||
function GetExtendiumInfo()
|
||||
return json.encode({
|
||||
externalLinks = GetExternalLinks(),
|
||||
installState = GetInstallState(),
|
||||
settings = extendium_settings,
|
||||
})
|
||||
end
|
||||
|
||||
---Install the fake-header-extension into Steam's Chromium preferences
|
||||
---@return string JSON result with success status
|
||||
function InstallInternalExtension()
|
||||
logger:info("InstallExtension called from frontend")
|
||||
return install_extension.install()
|
||||
end
|
||||
|
||||
---Check if the extension is currently installed
|
||||
---@return string JSON result with installation status
|
||||
function CheckInternalExtensionStatus()
|
||||
logger:info("CheckExtensionStatus called from frontend")
|
||||
return install_extension.check_status()
|
||||
end
|
||||
|
||||
function CheckIfInternalExtensionIsInstalled()
|
||||
utils.sleep(3000)
|
||||
local install_state = GetInstallState() or {}
|
||||
|
||||
if install_state.ignoreRequirement then
|
||||
logger:info("Helper extension requirement is ignored, skipping check")
|
||||
return false
|
||||
end
|
||||
|
||||
local status_result = install_extension.check_status()
|
||||
local status = json.decode(status_result)
|
||||
|
||||
if status and status.installed then
|
||||
if install_state.installAttempted then
|
||||
logger:info("Clearing previous install attempt flag")
|
||||
SaveInstallState({
|
||||
installAttempted = false,
|
||||
installFailed = false,
|
||||
lastChecked = os.time()
|
||||
})
|
||||
end
|
||||
else
|
||||
logger:info("Helper extension is not installed")
|
||||
|
||||
if install_state.installAttempted then
|
||||
logger:error("Helper extension installation failed - extension not found after previous install attempt")
|
||||
SaveInstallState({
|
||||
installAttempted = true,
|
||||
installFailed = true,
|
||||
lastChecked = os.time(),
|
||||
errorMessage = "Helper extension installation failed. Please try installing manually or check logs."
|
||||
})
|
||||
millennium.call_frontend_method("showExtensionInstallationFailedDialog")
|
||||
|
||||
return true
|
||||
else
|
||||
logger:info("First run - attempting to install helper extension")
|
||||
SaveInstallState({
|
||||
installAttempted = true,
|
||||
installFailed = false,
|
||||
lastChecked = os.time()
|
||||
})
|
||||
|
||||
local install_result = install_extension.install()
|
||||
local result = json.decode(install_result)
|
||||
|
||||
if result and result.success then
|
||||
logger:info("Helper extension installation initiated successfully")
|
||||
else
|
||||
logger:error("Helper extension installation failed: " .. (result and result.error or "unknown error"))
|
||||
SaveInstallState({
|
||||
installAttempted = true,
|
||||
installFailed = true,
|
||||
lastChecked = os.time(),
|
||||
errorMessage = result and result.error or "Installation failed with unknown error"
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
---Mark the helper extension requirement as ignored so the failure dialog is skipped on startup
|
||||
---@return string
|
||||
function IgnoreInternalExtensionRequirement()
|
||||
SaveInstallState({
|
||||
installAttempted = false,
|
||||
installFailed = false,
|
||||
ignoreRequirement = true,
|
||||
lastChecked = os.time()
|
||||
})
|
||||
logger:info("Internal extension requirement ignored")
|
||||
return "success"
|
||||
end
|
||||
|
||||
---Delete the .extensions folder
|
||||
function DeleteLegacyExtensions()
|
||||
return legacy_extensions.DeleteLegacyExtensions()
|
||||
end
|
||||
|
||||
function on_load()
|
||||
logger:info("Extendium loaded in Millennium version: " .. millennium.version())
|
||||
millennium.ready()
|
||||
end
|
||||
|
||||
function on_frontend_loaded()
|
||||
local did_show_dialog = CheckIfInternalExtensionIsInstalled()
|
||||
if not did_show_dialog then
|
||||
legacy_extensions.HandleLegacyExtensions()
|
||||
end
|
||||
end
|
||||
|
||||
function on_unload()
|
||||
logger:info("Extendium unloaded")
|
||||
end
|
||||
|
||||
return {
|
||||
on_load = on_load,
|
||||
on_frontend_loaded = on_frontend_loaded,
|
||||
on_unload = on_unload,
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
# pylint: disable=invalid-name
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import zipfile
|
||||
from os import path
|
||||
from typing import Optional
|
||||
|
||||
import Millennium
|
||||
import requests
|
||||
from cors_proxy import CORSProxy
|
||||
from logger.logger import logger # pylint: disable=import-error
|
||||
from websocket import initialize_server, run_server, shutdown_server
|
||||
|
||||
EXTENSIONS_DIR = '.extensions'
|
||||
EXTENDIUM_EXTERNAL_LINKS_FILE = 'external-links.json'
|
||||
|
||||
cors_proxy: Optional[CORSProxy] = None
|
||||
|
||||
def GetPluginDir():
|
||||
return path.abspath(PLUGIN_BASE_DIR) # pylint: disable=undefined-variable
|
||||
|
||||
def GetExtensionsDir():
|
||||
return os.path.join(GetPluginDir(), EXTENSIONS_DIR)
|
||||
|
||||
def GetExtensionManifests():
|
||||
# Get all the manifest.json files in the extensions directory
|
||||
extensions_dir = GetExtensionsDir()
|
||||
manifests = {}
|
||||
|
||||
if os.path.exists(extensions_dir):
|
||||
for ext_folder in os.listdir(extensions_dir):
|
||||
manifest_path = os.path.join(extensions_dir, ext_folder, "manifest.json")
|
||||
if os.path.isfile(manifest_path):
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest_data = json.load(f)
|
||||
# Check for manifest version
|
||||
if manifest_data.get('manifest_version') != 3:
|
||||
logger.error(f"Extension {ext_folder} has an invalid manifest version: {manifest_data.get('manifest_version')}. Only manifest version 3 is supported.")
|
||||
continue
|
||||
|
||||
manifests[ext_folder] = manifest_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading manifest {manifest_path}: {str(e)}")
|
||||
|
||||
return manifests
|
||||
|
||||
def GetExtensionMetadatas():
|
||||
extensions_dir = GetExtensionsDir()
|
||||
metadatas = {}
|
||||
|
||||
if os.path.exists(extensions_dir):
|
||||
for ext_folder in os.listdir(extensions_dir):
|
||||
metadata_path = os.path.join(extensions_dir, ext_folder, "metadata.json")
|
||||
if os.path.isfile(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata_data = json.load(f)
|
||||
metadatas[ext_folder] = metadata_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading metadata {metadata_path}: {str(e)}")
|
||||
|
||||
return metadatas
|
||||
|
||||
def GetExternalLinks():
|
||||
external_links_path = os.path.join(GetPluginDir(), EXTENDIUM_EXTERNAL_LINKS_FILE)
|
||||
if os.path.isfile(external_links_path):
|
||||
try:
|
||||
with open(external_links_path, 'r', encoding='utf-8') as f:
|
||||
external_links = json.load(f)
|
||||
return external_links
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading external links {external_links_path}: {str(e)}")
|
||||
return []
|
||||
|
||||
def UpdateExternalLinks(external_links: str):
|
||||
external_links_path = os.path.join(GetPluginDir(), EXTENDIUM_EXTERNAL_LINKS_FILE)
|
||||
try:
|
||||
with open(external_links_path, 'w', encoding='utf-8') as f:
|
||||
f.write(external_links)
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing external links {external_links_path}: {str(e)}")
|
||||
|
||||
def GetExtensionsInfos():
|
||||
return json.dumps({
|
||||
'extensionsDir': GetExtensionsDir(),
|
||||
'pluginDir': GetPluginDir(),
|
||||
'manifests': GetExtensionManifests(),
|
||||
'metadatas': GetExtensionMetadatas(),
|
||||
'externalLinks': GetExternalLinks(),
|
||||
})
|
||||
|
||||
def RemoveExtension(name: str):
|
||||
extensions_dir = GetExtensionsDir()
|
||||
ext_dir = os.path.join(extensions_dir, name)
|
||||
if os.path.exists(ext_dir):
|
||||
shutil.rmtree(ext_dir)
|
||||
Millennium.call_frontend_method('removeExtension', params=[name])
|
||||
|
||||
USER_INFO: Optional[str] = None
|
||||
|
||||
def GetUserInfo():
|
||||
global USER_INFO
|
||||
if USER_INFO is None or not USER_INFO.startswith('{'):
|
||||
USER_INFO = Millennium.call_frontend_method('getUserInfo') # pylint: disable=assignment-from-no-return
|
||||
return USER_INFO
|
||||
|
||||
def CheckForUpdates():
|
||||
metadatas = GetExtensionMetadatas()
|
||||
manifests = GetExtensionManifests()
|
||||
foundUpdates = {}
|
||||
for [name, metadata] in metadatas.items():
|
||||
try:
|
||||
page = requests.get(metadata['url'], timeout=30, headers={
|
||||
'Accept-Language': 'en-US',
|
||||
})
|
||||
version = re.search(r'version\\": \\"([^\\]+)', page.text)
|
||||
|
||||
if version:
|
||||
if version.group(1) != manifests[name]['version']:
|
||||
logger.log(f"New version found for extension '{name}': {version.group(1)}")
|
||||
foundUpdates[name] = metadata
|
||||
else:
|
||||
logger.error(f"Update version not found for extension '{name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for updates for extension '{name}': {str(e)}")
|
||||
|
||||
return json.dumps(foundUpdates)
|
||||
|
||||
def extract_zip_from_crx(crx_data: bytes) -> bytes:
|
||||
# Check magic number (first 4 bytes) == Cr24
|
||||
if crx_data[0:4] != b'Cr24':
|
||||
raise ValueError("Not a valid CRX file.")
|
||||
|
||||
version = struct.unpack('<I', crx_data[4:8])[0]
|
||||
|
||||
if version == 2:
|
||||
pub_key_len = struct.unpack('<I', crx_data[8:12])[0]
|
||||
sig_len = struct.unpack('<I', crx_data[12:16])[0]
|
||||
header_len = 16 + pub_key_len + sig_len
|
||||
elif version == 3:
|
||||
pub_key_len = struct.unpack('<I', crx_data[8:12])[0]
|
||||
header_len = 12 + pub_key_len
|
||||
else:
|
||||
raise ValueError(f"Unsupported CRX version {version}.")
|
||||
|
||||
zip_data = crx_data[header_len:]
|
||||
return zip_data
|
||||
|
||||
def DownloadExtensionFromUrl(url: str, metadata: str, name: str):
|
||||
extensions_dir = GetExtensionsDir()
|
||||
|
||||
if not os.path.exists(extensions_dir):
|
||||
os.makedirs(extensions_dir)
|
||||
|
||||
# Download the extension
|
||||
logger.log(f"Downloading extension from {url}")
|
||||
try:
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
crx_data = response.content
|
||||
# If response is empty, try with a higher version as it likely has a minimum chrome version that we wan't to ignore
|
||||
if not crx_data:
|
||||
prodversion = re.search(r'prodversion=([^.]+)', url)
|
||||
if prodversion:
|
||||
prodversion = prodversion.group(1)
|
||||
|
||||
logger.log(f"Could not download extension from {url}, trying with a higher version: {int(str(prodversion)) + 1}")
|
||||
url = url.replace(f'prodversion={prodversion}', f'prodversion={int(str(prodversion)) + 1}')
|
||||
|
||||
return DownloadExtensionFromUrl(url, metadata, name)
|
||||
|
||||
zip_data = extract_zip_from_crx(crx_data)
|
||||
|
||||
# Extract the extension directly to the extensions directory
|
||||
ext_dir = os.path.join(extensions_dir, name)
|
||||
|
||||
# Remove existing directory if it exists
|
||||
if os.path.exists(ext_dir):
|
||||
shutil.rmtree(ext_dir)
|
||||
|
||||
# Create a temporary file and extract the zip
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file:
|
||||
temp_file.write(zip_data)
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
with zipfile.ZipFile(temp_file_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(ext_dir)
|
||||
|
||||
# Write metadata to the extension directory
|
||||
with open(os.path.join(ext_dir, 'metadata.json'), 'w', encoding='utf-8') as f:
|
||||
f.write(base64.b64decode(metadata).decode('utf-8'))
|
||||
|
||||
# Clean up
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
logger.log(f"Extension successfully extracted to {ext_dir}")
|
||||
PrepareExtensionFiles()
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to download extension: {str(e)} {e.__traceback__}")
|
||||
return False
|
||||
except (ValueError, zipfile.BadZipFile) as e:
|
||||
logger.error(f"Failed to process extension file: {str(e)} {e.__traceback__}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error installing extension: {str(e)} {e.__traceback__}")
|
||||
return False
|
||||
|
||||
|
||||
# TODO: do the same as millennium does for the file proxy but for chrome-extension:// url
|
||||
def PrepareExtensionFiles():
|
||||
dir_path = GetExtensionsDir().replace('\\', '/')
|
||||
extension_url = f"https://js.millennium.app/{dir_path}"
|
||||
extensions_dir = GetExtensionsDir()
|
||||
|
||||
if not os.path.exists(extensions_dir):
|
||||
return
|
||||
|
||||
for ext_folder in os.listdir(extensions_dir):
|
||||
ext_path = os.path.join(extensions_dir, ext_folder)
|
||||
if os.path.isdir(ext_path):
|
||||
ext_folder = ext_folder.replace('\\', '/')
|
||||
full_dir_path = f"{extension_url}/{ext_folder}"
|
||||
# Define file processing rules
|
||||
processing_rules = {
|
||||
'.css': [
|
||||
{'pattern': r'url\((?!")chrome-extension:\/\/__MSG_@@extension_id__\/(.+?)\)', 'replacement': f"url(\"{full_dir_path}/\\g<1>\")", 'is_regex': True},
|
||||
{'pattern': "url('/", 'replacement': f"url('{full_dir_path}/"},
|
||||
{'pattern': r"url\((['\"])chrome-extension://__MSG_@@extension_id__/", 'replacement': f"url(\g<1>{full_dir_path}/", 'is_regex': True},
|
||||
],
|
||||
'.html': [
|
||||
{'pattern': "href=\"/", 'replacement': f"href=\"{full_dir_path}/"},
|
||||
{'pattern': "src=\"/", 'replacement': f"src=\"{full_dir_path}/"},
|
||||
],
|
||||
'.js': [
|
||||
{'pattern': "globalThis.chrome", 'replacement': "chrome"},
|
||||
{'pattern': ".bind(null,this)", 'replacement': ".bind(null,windowProxy)"},
|
||||
]
|
||||
}
|
||||
|
||||
# Handle root folder references in JS files
|
||||
ext_root_folders = [d for d in os.listdir(ext_path) if os.path.isdir(os.path.join(ext_path, d))]
|
||||
for dir_name in ext_root_folders:
|
||||
processing_rules['.js'].append({'pattern': f"(['\"])\/{re.escape(dir_name)}\/", 'replacement': f"\\g<1>{full_dir_path}/{dir_name}/", 'is_regex': True})
|
||||
processing_rules['.css'].append({'pattern': f"url\((['\"])\/{re.escape(dir_name)}", 'replacement': f"url(\\g<1>{full_dir_path}/{dir_name}", 'is_regex': True})
|
||||
processing_rules['.css'].append({'pattern': f"url\((/{re.escape(dir_name)}/.+?)\)", 'replacement': f"url(\"{full_dir_path}\\g<1>\")", 'is_regex': True})
|
||||
|
||||
for root, _, files in os.walk(ext_path):
|
||||
for file in files:
|
||||
file_extension = os.path.splitext(file)[1].lower()
|
||||
file_path = os.path.join(root, file)
|
||||
relative_path = EXTENSIONS_DIR + '/' + os.path.relpath(file_path, extensions_dir)
|
||||
|
||||
# Process file if we have rules for its extension
|
||||
if file_extension in processing_rules:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
modified_content = content
|
||||
previous_content = content
|
||||
|
||||
processed_patterns = []
|
||||
for rule in processing_rules[file_extension]:
|
||||
find_pattern = rule['pattern']
|
||||
replace_pattern = rule['replacement']
|
||||
is_regex = rule.get('is_regex', False)
|
||||
|
||||
if is_regex:
|
||||
modified_content = re.sub(find_pattern, replace_pattern, modified_content)
|
||||
else:
|
||||
modified_content = modified_content.replace(find_pattern, replace_pattern)
|
||||
|
||||
if previous_content != modified_content:
|
||||
processed_patterns.append(find_pattern)
|
||||
|
||||
previous_content = modified_content
|
||||
|
||||
if content != modified_content:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(modified_content)
|
||||
logger.log(f"Updated {file_extension} file: {relative_path} with patterns: {processed_patterns}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {file_extension} file {relative_path}: {str(e)}")
|
||||
|
||||
class Plugin:
|
||||
def _front_end_loaded(self):
|
||||
pass
|
||||
|
||||
def _load(self):
|
||||
# didn't touch this, but this should probably be self.cors_proxy.
|
||||
global cors_proxy
|
||||
|
||||
logger.log(f"bootstrapping Extendium, millennium {Millennium.version()}")
|
||||
|
||||
try:
|
||||
Millennium.add_proxy_pattern(r"steamui\/extensions") # pylint: disable=no-member
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
PrepareExtensionFiles()
|
||||
except Exception as e:
|
||||
logger.error(f"Error preparing extension files: {e}")
|
||||
|
||||
try:
|
||||
# Initialize and run the WebSocket server
|
||||
initialize_server()
|
||||
run_server(port=8791)
|
||||
except Exception as e:
|
||||
logger.error(f"Error running websocket server: {e}")
|
||||
|
||||
try:
|
||||
# Initialize and run the CORS proxy server
|
||||
cors_proxy = CORSProxy(port=8792)
|
||||
cors_proxy.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Error running CORS proxy server: {e}")
|
||||
|
||||
Millennium.ready() # this is required to tell Millennium that the backend is ready.
|
||||
|
||||
def _unload(self):
|
||||
global cors_proxy
|
||||
logger.log("Starting plugin unload process...")
|
||||
|
||||
# sig stop the WebSocket server, and wait for the thread to die
|
||||
try:
|
||||
logger.log("Shutting down WebSocket server...")
|
||||
shutdown_server()
|
||||
|
||||
logger.log("WebSocket server shutdown completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error shutting down websocket server: {e}")
|
||||
|
||||
# sig stop the CORS proxy server, and wait for the thread to die
|
||||
try:
|
||||
logger.log("Shutting down CORS proxy server...")
|
||||
if cors_proxy:
|
||||
cors_proxy.stop()
|
||||
cors_proxy = None # Reset global reference
|
||||
logger.log("CORS proxy server shutdown completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error shutting down CORS proxy server: {e}")
|
||||
|
||||
try:
|
||||
logger.log("Cleaning up HTTP connection pools...")
|
||||
|
||||
# close any existing sessions in the requests module
|
||||
try:
|
||||
default_session = getattr(requests.sessions, 'Session', None)
|
||||
if default_session and hasattr(requests, 'get'):
|
||||
# force creation and closure of a session to clear pools
|
||||
temp_session = requests.Session()
|
||||
temp_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
session = requests.Session()
|
||||
session.close()
|
||||
|
||||
logger.log("HTTP connection pools cleaned up")
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up HTTP connection pools: {e}")
|
||||
|
||||
logger.log("Plugin unload process completed")
|
||||
@@ -0,0 +1,48 @@
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from websockets.sync.server import Server, ServerConnection, serve
|
||||
|
||||
serverObj: Optional[Server] = None
|
||||
|
||||
# This flag will help us shut the server down
|
||||
# shutdown_flag = threading.Event()
|
||||
|
||||
def echo(websocket: ServerConnection):
|
||||
print("Client connected")
|
||||
|
||||
for message in websocket:
|
||||
websocket.send(f"Echo: {message}")
|
||||
print(f"Handled message: {message}")
|
||||
# if shutdown_flag.is_set():
|
||||
# break
|
||||
|
||||
print('Client disconnected')
|
||||
|
||||
def start_server():
|
||||
global serverObj
|
||||
print("WebSocket server starting on ws://localhost:8765")
|
||||
serverObj = serve(echo, "localhost", 8769)
|
||||
try:
|
||||
serverObj.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass # Catch Ctrl+C inside thread (won't usually hit here)
|
||||
print("WebSocket server shut down.")
|
||||
|
||||
def main():
|
||||
server_thread = threading.Thread(target=start_server, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
try:
|
||||
while server_thread.is_alive():
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
print("\nCtrl+C caught, setting shutdown flag...")
|
||||
# shutdown_flag.set()
|
||||
print("Waiting for server to shut down...")
|
||||
serverObj.shutdown()
|
||||
print("Server stopped. Go touch grass now.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
WebSocket module for Extendium plugin.
|
||||
Handles communication between frontend and webkit clients.
|
||||
"""
|
||||
from .server import initialize_server, run_server, shutdown_server
|
||||
|
||||
__all__ = ['initialize_server', 'run_server', 'shutdown_server']
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Client manager for WebSocket connections.
|
||||
Handles tracking and managing connected clients.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class ClientManager:
|
||||
"""
|
||||
Manages WebSocket clients.
|
||||
Tracks frontend and webkit clients separately.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the client manager."""
|
||||
self._frontend_client = None
|
||||
self._webkit_clients: List[Dict[str, Any]] = []
|
||||
|
||||
@property
|
||||
def frontend_client(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get the frontend client."""
|
||||
return self._frontend_client
|
||||
|
||||
@frontend_client.setter
|
||||
def frontend_client(self, client: Dict[str, Any]):
|
||||
"""Set the frontend client."""
|
||||
self._frontend_client = client
|
||||
|
||||
@property
|
||||
def webkit_clients(self) -> List[Dict[str, Any]]:
|
||||
"""Get all webkit clients."""
|
||||
return self._webkit_clients
|
||||
|
||||
def add_webkit_client(self, client: Dict[str, Any]):
|
||||
"""
|
||||
Add a webkit client.
|
||||
|
||||
Args:
|
||||
client: The WebSocket client object
|
||||
"""
|
||||
self._webkit_clients.append(client)
|
||||
|
||||
def remove_client(self, client_id: str) -> bool:
|
||||
"""
|
||||
Remove a client by its ID.
|
||||
|
||||
Args:
|
||||
client_id: The ID of the client to remove
|
||||
|
||||
Returns:
|
||||
bool: True if client was removed, False otherwise
|
||||
"""
|
||||
# Check if it's the frontend client
|
||||
if self._frontend_client and self._frontend_client.get('id') == client_id:
|
||||
self._frontend_client = None
|
||||
return True
|
||||
|
||||
# Check if it's a webkit client
|
||||
for i, client in enumerate(self._webkit_clients):
|
||||
if client.get('id') == client_id:
|
||||
del self._webkit_clients[i]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def has_frontend_client(self) -> bool:
|
||||
"""Check if a frontend client is connected."""
|
||||
return self._frontend_client is not None
|
||||
|
||||
def has_webkit_clients(self) -> bool:
|
||||
"""Check if any webkit clients are connected."""
|
||||
return len(self._webkit_clients) > 0
|
||||
|
||||
def disconnect_all_clients(self):
|
||||
if self._frontend_client:
|
||||
try:
|
||||
websocket = getattr(self._frontend_client, 'websocket', None)
|
||||
if websocket and hasattr(websocket, 'close'):
|
||||
websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._frontend_client = None
|
||||
|
||||
for client in self._webkit_clients:
|
||||
try:
|
||||
websocket = getattr(client, 'websocket', None)
|
||||
if websocket and hasattr(websocket, 'close'):
|
||||
websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._webkit_clients.clear()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
This file is used to bootup the websocket server without millennium so just from the terminal
|
||||
"""
|
||||
|
||||
from .server import initialize_server, run_server, shutdown_server
|
||||
|
||||
if __name__ == "__main__":
|
||||
initialize_server()
|
||||
run_server()
|
||||
try:
|
||||
input("Press Enter to exit...")
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
shutdown_server()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Message handler for WebSocket communication.
|
||||
Processes different types of messages and routes them appropriately.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from logger.logger import logger # pylint: disable=import-error
|
||||
|
||||
from .client_manager import ClientManager
|
||||
from .message_types import ClientType, MessageType
|
||||
from .request_handler import RequestHandler
|
||||
|
||||
|
||||
class MessageAdapter:
|
||||
def send_message(self, client: Dict[str, Any], message: str):
|
||||
pass
|
||||
|
||||
class MessageHandler:
|
||||
"""
|
||||
Handles processing and routing of WebSocket messages.
|
||||
"""
|
||||
|
||||
def __init__(self, server: MessageAdapter, client_manager: ClientManager, request_handler: RequestHandler):
|
||||
"""
|
||||
Initialize the message handler.
|
||||
|
||||
Args:
|
||||
server: The WebSocket server instance
|
||||
client_manager: The client manager instance
|
||||
request_handler: The request handler instance
|
||||
"""
|
||||
self._server = server
|
||||
self._client_manager = client_manager
|
||||
self._request_handler = request_handler
|
||||
self._message_handlers = {
|
||||
MessageType.IDENTIFY.value: self._handle_identify,
|
||||
MessageType.WEBKIT_MESSAGE.value: self._handle_webkit_message,
|
||||
MessageType.FRONTEND_RESPONSE.value: self._handle_frontend_response,
|
||||
MessageType.ERROR.value: self._handle_error,
|
||||
}
|
||||
|
||||
def process_message(self, client: Dict[str, Any], message: str) -> None:
|
||||
"""
|
||||
Process an incoming WebSocket message.
|
||||
|
||||
Args:
|
||||
client: The client that sent the message
|
||||
message: The message string
|
||||
"""
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get('type')
|
||||
|
||||
if message_type in self._message_handlers:
|
||||
self._message_handlers[message_type](client, data)
|
||||
else:
|
||||
logger.error(f"Unknown message type: {message_type}")
|
||||
self._send_error(client, data.get('requestId'), "Unknown message type")
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Invalid JSON message: {message}")
|
||||
self._send_error(client, None, "Invalid JSON message")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing message: {str(e)}")
|
||||
self._send_error(client, data.get('requestId') if 'data' in locals() else None, f"Error: {str(e)}")
|
||||
|
||||
def _handle_identify(self, client: Dict[str, Any], data: Dict[str, Any]) -> None:
|
||||
client_type = data.get('clientType')
|
||||
|
||||
if client_type == ClientType.FRONTEND.value:
|
||||
self._client_manager.frontend_client = client
|
||||
elif client_type == ClientType.WEBKIT.value:
|
||||
self._client_manager.add_webkit_client(client)
|
||||
else:
|
||||
logger.error(f"Unknown client type: {client_type}")
|
||||
self._send_error(client, None, f"Unknown client type: {client_type}")
|
||||
|
||||
def _handle_webkit_message(self, client: Dict[str, Any], data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle a message from a webkit client to the frontend.
|
||||
|
||||
Args:
|
||||
client: The client that sent the message
|
||||
data: The message data
|
||||
"""
|
||||
if not self._client_manager.has_frontend_client():
|
||||
logger.error("No frontend client connected to receive message")
|
||||
self._send_error(client, data.get('requestId'), "No frontend client connected")
|
||||
return
|
||||
|
||||
request_id = data.get('requestId')
|
||||
|
||||
if request_id:
|
||||
# Store the request for later response matching
|
||||
self._request_handler.add_request(request_id, client)
|
||||
|
||||
# Forward message to frontend
|
||||
self._server.send_message(self._client_manager.frontend_client, json.dumps(data))
|
||||
else:
|
||||
logger.error("Missing requestId in webkit message")
|
||||
self._send_error(client, request_id, "Missing requestId")
|
||||
|
||||
def _handle_frontend_response(self, client: Dict[str, Any], data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle a response from the frontend to a webkit client.
|
||||
|
||||
Args:
|
||||
client: The client that sent the message
|
||||
data: The message data
|
||||
"""
|
||||
request_id = data.get('requestId')
|
||||
|
||||
if not request_id:
|
||||
logger.error("Missing requestId in frontend response")
|
||||
self._send_error(client, None, "Missing requestId in response")
|
||||
return
|
||||
|
||||
request = self._request_handler.get_request(request_id)
|
||||
if request:
|
||||
webkit_client = request['client']
|
||||
# Send response back to the webkit client
|
||||
self._server.send_message(webkit_client, json.dumps(data))
|
||||
# Clean up the pending request
|
||||
self._request_handler.remove_request(request_id)
|
||||
else:
|
||||
# logger.error(f"Received response for unknown request: {request_id}")
|
||||
self._send_error(client, request_id, f"Unknown request: {request_id}")
|
||||
|
||||
def _handle_error(self, client: Dict[str, Any], data: Dict[str, Any]) -> None:
|
||||
logger.error(f"Received error: {data['error']} requestId: {data['requestId']} extensionName: {data['extensionName']}")
|
||||
|
||||
def _send_error(self, client: Dict[str, Any], request_id: Optional[str], error_message: str) -> None:
|
||||
"""
|
||||
Send an error message to a client.
|
||||
|
||||
Args:
|
||||
client: The client to send the error to
|
||||
request_id: The ID of the request that caused the error
|
||||
error_message: The error message
|
||||
"""
|
||||
error_data = {
|
||||
'type': MessageType.ERROR.value,
|
||||
'error': error_message
|
||||
}
|
||||
|
||||
if request_id:
|
||||
error_data['requestId'] = request_id
|
||||
|
||||
self._server.send_message(client, json.dumps(error_data))
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Message type definitions for WebSocket communication.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Message types for WebSocket communication."""
|
||||
IDENTIFY = "identify"
|
||||
WEBKIT_MESSAGE = "webkit_message"
|
||||
FRONTEND_RESPONSE = "frontend_response"
|
||||
ERROR = "error"
|
||||
|
||||
class ClientType(Enum):
|
||||
"""Types of clients that can connect to the WebSocket server."""
|
||||
FRONTEND = "frontend"
|
||||
WEBKIT = "webkit"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Request handler for WebSocket communication.
|
||||
Manages pending requests and their responses.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from logger.logger import logger # pylint: disable=import-error
|
||||
|
||||
|
||||
class RequestHandler:
|
||||
"""
|
||||
Handles tracking and managing WebSocket requests.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._pending_requests: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def add_request(self, request_id: str, client: Dict[str, Any]) -> None:
|
||||
self._pending_requests[request_id] = {
|
||||
'client': client,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
def get_request(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self._pending_requests.get(request_id)
|
||||
|
||||
def remove_request(self, request_id: str) -> bool:
|
||||
if request_id in self._pending_requests:
|
||||
del self._pending_requests[request_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def cleanup_old_requests(self, max_age_seconds: int) -> int:
|
||||
current_time = time.time()
|
||||
to_remove = []
|
||||
|
||||
for request_id, request_data in self._pending_requests.items():
|
||||
if current_time - request_data['timestamp'] > max_age_seconds:
|
||||
to_remove.append(request_id)
|
||||
|
||||
for request_id in to_remove:
|
||||
del self._pending_requests[request_id]
|
||||
|
||||
if to_remove:
|
||||
logger.log(f"Cleaned up {len(to_remove)} stale requests")
|
||||
|
||||
return len(to_remove)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
WebSocket server implementation for Extendium plugin.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from logger.logger import logger # pylint: disable=import-error
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.sync.server import Server, ServerConnection, serve
|
||||
|
||||
from .client_manager import ClientManager
|
||||
from .message_handler import MessageHandler
|
||||
from .request_handler import RequestHandler
|
||||
|
||||
# Global instances
|
||||
_server: Optional[Server] = None
|
||||
_client_manager: Optional[ClientManager] = None
|
||||
_request_handler: Optional[RequestHandler] = None
|
||||
_message_handler: Optional[MessageHandler] = None
|
||||
_cleanup_thread: Optional[threading.Thread] = None
|
||||
_server_thread: Optional[threading.Thread] = None
|
||||
_running = False
|
||||
|
||||
|
||||
class WebSocketClientAdapter:
|
||||
"""
|
||||
Adapter class to maintain compatibility with the existing code that expects
|
||||
the client structure from the websocket-server package.
|
||||
"""
|
||||
def __init__(self, websocket: ServerConnection):
|
||||
self.websocket = websocket
|
||||
self.id = str(uuid.uuid4())
|
||||
self.data = {'id': self.id}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.data.get(key, default)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.data[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.data[key] = value
|
||||
|
||||
class MessageAdapter:
|
||||
"""
|
||||
Adapter to maintain compatibility with the existing MessageHandler that expects
|
||||
a server with a send_message method.
|
||||
"""
|
||||
def send_message(self, client: WebSocketClientAdapter, message: str) -> None:
|
||||
try:
|
||||
client.websocket.send(message)
|
||||
except ConnectionClosed:
|
||||
# logger.error("Failed to send message to client: connection closed")
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending message: {e}")
|
||||
|
||||
def handle_client(websocket: ServerConnection) -> None:
|
||||
"""
|
||||
Main handler for each client connection.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection object
|
||||
"""
|
||||
|
||||
# Create client adapter
|
||||
client = WebSocketClientAdapter(websocket)
|
||||
|
||||
try:
|
||||
# Process messages in a loop
|
||||
for message in websocket:
|
||||
if _message_handler:
|
||||
_message_handler.process_message(client, message)
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling client: {e}")
|
||||
finally:
|
||||
# Handle client disconnection (equivalent to _client_left)
|
||||
if _client_manager:
|
||||
_client_manager.remove_client(client.id)
|
||||
|
||||
def _cleanup_routine() -> None:
|
||||
"""Periodically clean up old pending requests."""
|
||||
global _request_handler, _running
|
||||
|
||||
while _running and _request_handler:
|
||||
# Clean up requests older than 30 seconds
|
||||
_request_handler.cleanup_old_requests(30)
|
||||
|
||||
# split wait into intervals to allow faster shutdown detection
|
||||
for _ in range(20):
|
||||
if not _running:
|
||||
return
|
||||
time.sleep(0.25)
|
||||
|
||||
def initialize_server() -> None:
|
||||
"""
|
||||
Initialize the WebSocket server.
|
||||
|
||||
Args:
|
||||
port: The port to listen on
|
||||
host: The host address to bind to
|
||||
|
||||
Returns:
|
||||
The initialized WebSocket server
|
||||
"""
|
||||
global _server, _client_manager, _request_handler, _message_handler
|
||||
|
||||
if _server:
|
||||
logger.warn("WebSocket server already initialized")
|
||||
return _server
|
||||
|
||||
# Create instances
|
||||
_client_manager = ClientManager()
|
||||
_request_handler = RequestHandler()
|
||||
|
||||
# Create message adapter for compatibility
|
||||
message_adapter = MessageAdapter()
|
||||
|
||||
# Create message handler with the adapter
|
||||
_message_handler = MessageHandler(message_adapter, _client_manager, _request_handler)
|
||||
|
||||
def run_server(port: int, host: str = "localhost") -> None:
|
||||
"""Run the WebSocket server in a separate thread."""
|
||||
global _server, _cleanup_thread, _server_thread, _running
|
||||
|
||||
if _running:
|
||||
logger.log("WebSocket server already running")
|
||||
return
|
||||
|
||||
_running = True
|
||||
|
||||
# Start cleanup thread as non-daemon for proper shutdown
|
||||
_cleanup_thread = threading.Thread(target=_cleanup_routine)
|
||||
_cleanup_thread.daemon = False # non-daemon so we can actually clean it up.
|
||||
_cleanup_thread.start()
|
||||
|
||||
# Start server in a separate thread
|
||||
def start_server():
|
||||
global _server
|
||||
try:
|
||||
_server = serve(handle_client, host, port)
|
||||
_server.serve_forever()
|
||||
except Exception as e:
|
||||
if _running: # Only log if we're not shutting down
|
||||
logger.error(f"Error in WebSocket server: {e}")
|
||||
finally:
|
||||
logger.log("WebSocket server thread ending")
|
||||
|
||||
_server_thread = threading.Thread(target=start_server)
|
||||
_server_thread.daemon = False # Non-daemon so we can wait for proper termination
|
||||
_server_thread.start()
|
||||
|
||||
logger.log(f"WebSocket server is running on {host}:{port}")
|
||||
|
||||
def shutdown_server() -> None:
|
||||
"""Shutdown the WebSocket server."""
|
||||
global _server, _running, _cleanup_thread, _server_thread, _client_manager, _request_handler, _message_handler
|
||||
|
||||
if not _running:
|
||||
logger.log("WebSocket server not running")
|
||||
return
|
||||
|
||||
logger.log("Shutting down WebSocket server...")
|
||||
_running = False
|
||||
|
||||
# disconnect all connected/dangling clients
|
||||
if _client_manager:
|
||||
try:
|
||||
_client_manager.disconnect_all_clients()
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting clients: {e}")
|
||||
|
||||
# shutdown the server
|
||||
if _server:
|
||||
try:
|
||||
_server.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error shutting down WebSocket server: {e}")
|
||||
|
||||
try:
|
||||
if hasattr(_server, 'close'):
|
||||
_server.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing WebSocket server: {e}")
|
||||
|
||||
# give some time for the server to shutdown, this is somewhat just a safeguard.
|
||||
time.sleep(0.2)
|
||||
|
||||
# wait for cleanup thread to finish
|
||||
if _cleanup_thread and _cleanup_thread.is_alive():
|
||||
_cleanup_thread.join(timeout=5)
|
||||
if _cleanup_thread.is_alive():
|
||||
logger.error("Cleanup thread did not finish within timeout")
|
||||
|
||||
# wait for server thread to finish
|
||||
if _server_thread and _server_thread.is_alive():
|
||||
_server_thread.join(timeout=5)
|
||||
if _server_thread.is_alive():
|
||||
logger.error("Server thread did not finish within timeout")
|
||||
|
||||
# force inline gc cleanup from the gil.
|
||||
_server = None
|
||||
_client_manager = None
|
||||
_request_handler = None
|
||||
_message_handler = None
|
||||
_cleanup_thread = None
|
||||
_server_thread = None
|
||||
|
||||
logger.log("WebSocket server shutdown complete")
|
||||
Reference in New Issue
Block a user