adds .local/share/millennium and easyeffects

This commit is contained in:
2026-08-06 18:27:07 +02:00
parent c52d483074
commit 778c0155bd
2581 changed files with 339035 additions and 0 deletions
@@ -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
@@ -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")