From 260d38ede0dcb38379d8f30f99e84b8861b8e9b1 Mon Sep 17 00:00:00 2001 From: piq Date: Mon, 1 Jun 2026 13:33:50 -0300 Subject: [PATCH 01/10] lua port (py files kept as reference, will be removed) --- backend/api_manifest.lua | 162 ++++++++++ backend/auto_update.lua | 126 ++++++++ backend/config.lua | 35 +++ backend/donate_keys.lua | 209 +++++++++++++ backend/downloads.lua | 297 +++++++++++++++++++ backend/fixes.lua | 110 +++++++ backend/http_client.lua | 20 ++ backend/locales/manager.lua | 195 ++++++++++++ backend/main.lua | 554 +++++++++++++++++++++++++++++++++++ backend/paths.lua | 53 ++++ backend/plugin_logger.lua | 21 ++ backend/plugin_utils.lua | 105 +++++++ backend/settings/manager.lua | 252 ++++++++++++++++ backend/settings/options.lua | 116 ++++++++ backend/steam_utils.lua | 106 +++++++ plugin.json | 3 +- 16 files changed, 2363 insertions(+), 1 deletion(-) create mode 100644 backend/api_manifest.lua create mode 100644 backend/auto_update.lua create mode 100644 backend/config.lua create mode 100644 backend/donate_keys.lua create mode 100644 backend/downloads.lua create mode 100644 backend/fixes.lua create mode 100644 backend/http_client.lua create mode 100644 backend/locales/manager.lua create mode 100644 backend/main.lua create mode 100644 backend/paths.lua create mode 100644 backend/plugin_logger.lua create mode 100644 backend/plugin_utils.lua create mode 100644 backend/settings/manager.lua create mode 100644 backend/settings/options.lua create mode 100644 backend/steam_utils.lua diff --git a/backend/api_manifest.lua b/backend/api_manifest.lua new file mode 100644 index 0000000..cad8ceb --- /dev/null +++ b/backend/api_manifest.lua @@ -0,0 +1,162 @@ +local fs = require("fs") +local config = require("config") +local http_client = require("http_client") +local logger = require("plugin_logger") +local utils = require("plugin_utils") +local paths = require("paths") + +local api_manifest = {} + +local _APIS_INIT_DONE = false +local _INIT_APIS_LAST_MESSAGE = "" + +function api_manifest.init_apis() + logger.log("InitApis: invoked") + if _APIS_INIT_DONE then + logger.log("InitApis: already completed this session, skipping") + return { success = true, message = _INIT_APIS_LAST_MESSAGE } + end + + local api_json_path = paths.backend_path(config.API_JSON_FILE) + local message = "" + + if fs.exists(api_json_path) then + logger.log("InitApis: Local file exists -> " .. api_json_path .. "; skipping remote fetch") + else + logger.log("InitApis: Local file not found -> " .. api_json_path) + local manifest_text = "" + + logger.log("InitApis: Fetching manifest from " .. config.API_MANIFEST_URL) + local resp = http_client.get(config.API_MANIFEST_URL, { timeout = 15 }) + if resp and resp.status == 200 and resp.body then + manifest_text = resp.body + logger.log("InitApis: Fetched manifest, length=" .. tostring(#manifest_text)) + else + logger.warn("InitApis: Primary URL failed, trying proxy...") + resp = http_client.get(config.API_MANIFEST_PROXY_URL, { timeout = config.HTTP_PROXY_TIMEOUT_SECONDS }) + if resp and resp.status == 200 and resp.body then + manifest_text = resp.body + logger.log("InitApis: Fetched manifest from proxy, length=" .. tostring(#manifest_text)) + else + logger.warn("InitApis: Proxy also failed") + end + end + + local normalized = "" + if manifest_text ~= "" then + normalized = utils.normalize_manifest_text(manifest_text) + end + + if normalized ~= "" then + utils.write_text(api_json_path, normalized) + local count = utils.count_apis(normalized) + message = "No API's Configured, Loaded " .. tostring(count) .. " Free Ones :D" + logger.log("InitApis: Wrote new api.json with " .. tostring(count) .. " entries") + else + message = "No API's Configured and failed to load free ones" + logger.warn("InitApis: Manifest empty, nothing written") + end + end + + _APIS_INIT_DONE = true + _INIT_APIS_LAST_MESSAGE = message + logger.log("InitApis: completed message=" .. tostring(message)) + return { success = true, message = message } +end + +function api_manifest.get_init_apis_message() + logger.log("InitApis: GetInitApisMessage invoked") + local msg = _INIT_APIS_LAST_MESSAGE or "" + if msg ~= "" then + logger.log("InitApis: delivering queued message -> " .. msg) + end + _INIT_APIS_LAST_MESSAGE = "" + return { success = true, message = msg } +end + +function api_manifest.store_last_message(message) + _INIT_APIS_LAST_MESSAGE = message or "" +end + +function api_manifest.fetch_free_apis_now() + logger.log("LuaTools: FetchFreeApisNow invoked") + local manifest_text = "" + + logger.log("LuaTools: Fetching manifest from " .. config.API_MANIFEST_URL) + local resp = http_client.get(config.API_MANIFEST_URL, { timeout = 15 }) + if resp and resp.status == 200 and resp.body then + manifest_text = resp.body + logger.log("LuaTools: Fetched manifest from primary URL") + else + logger.warn("LuaTools: Primary manifest URL failed, trying proxy...") + resp = http_client.get(config.API_MANIFEST_PROXY_URL, { timeout = config.HTTP_PROXY_TIMEOUT_SECONDS }) + if resp and resp.status == 200 and resp.body then + manifest_text = resp.body + logger.log("LuaTools: Fetched manifest from proxy URL") + else + logger.warn("LuaTools: Proxy manifest URL also failed") + return { success = false, error = "Both URLs failed" } + end + end + + local normalized = "" + if manifest_text ~= "" then + normalized = utils.normalize_manifest_text(manifest_text) + end + + if normalized == "" then + return { success = false, error = "Empty manifest" } + end + + utils.write_text(paths.backend_path(config.API_JSON_FILE), normalized) + local count = utils.count_apis(normalized) + return { success = true, count = count } +end + +function api_manifest.load_api_manifest() + local path = paths.backend_path(config.API_JSON_FILE) + local text = utils.read_text(path) + + local normalized = utils.normalize_manifest_text(text) + if normalized and normalized ~= text and normalized ~= "" then + utils.write_text(path, normalized) + logger.log("LuaTools: Normalized api.json") + text = normalized + end + + local data = utils.read_json(path) + local apis = {} + if data and type(data.api_list) == "table" then + for _, api in ipairs(data.api_list) do + if api.enabled then + table.insert(apis, api) + end + end + end + return apis +end + +function api_manifest.get_api_list() + local success, apis = pcall(api_manifest.load_api_manifest) + if not success then + return { success = false, error = tostring(apis), apis = {} } + end + + local morrenus_api_key = "" + local ok, sm = pcall(require, "settings.manager") + if ok and sm and sm.get_morrenus_api_key then + morrenus_api_key = sm.get_morrenus_api_key() or "" + end + + local api_names = {} + for i, api in ipairs(apis) do + local url = api.url or "" + if not (string.find(url, "") and (not morrenus_api_key or morrenus_api_key == "")) then + table.insert(api_names, { name = api.name or "Unknown", index = i - 1 }) + end + end + + return { success = true, apis = api_names } +end + +return api_manifest diff --git a/backend/auto_update.lua b/backend/auto_update.lua new file mode 100644 index 0000000..b3d3ed7 --- /dev/null +++ b/backend/auto_update.lua @@ -0,0 +1,126 @@ +local m_utils = require("utils") +local fs = require("fs") +local http_client = require("http_client") +local config = require("config") +local logger = require("plugin_logger") +local paths = require("paths") +local utils = require("plugin_utils") +local steam_utils = require("steam_utils") + +local auto_update = {} + +function auto_update.check_for_updates_now() + local cfg_path = paths.backend_path(config.UPDATE_CONFIG_FILE) + local cfg = utils.read_json(cfg_path) + + local latest_version = "" + local zip_url = "" + + local gh_cfg = cfg.github + if gh_cfg then + local owner = gh_cfg.owner or "" + local repo = gh_cfg.repo or "" + local asset_name = gh_cfg.asset_name or "ltsteamplugin.zip" + local tag = gh_cfg.tag or "" + local tag_prefix = gh_cfg.tag_prefix or "" + + local endpoint = "https://api.github.com/repos/" .. owner .. "/" .. repo .. "/releases/latest" + if tag ~= "" then + endpoint = "https://api.github.com/repos/" .. owner .. "/" .. repo .. "/releases/tags/" .. tag + end + + local resp = http_client.get(endpoint, { + headers = { + ["Accept"] = "application/vnd.github+json", + ["User-Agent"] = "LuaTools-Updater" + }, + timeout = 10 + }) + if resp and resp.status == 200 and resp.body then + local data = utils.decode_json(resp.body) + local tag_name = data.tag_name or "" + latest_version = tag_name or data.name or "" + if tag_prefix ~= "" and latest_version:sub(1, #tag_prefix) == tag_prefix then + latest_version = latest_version:sub(#tag_prefix + 1) + end + + for _, asset in ipairs(data.assets or {}) do + if asset.name == asset_name then + zip_url = asset.browser_download_url + break + end + end + if zip_url == "" and tag_name ~= "" then + zip_url = "https://luatools.vercel.app/api/get-plugin/" .. tag_name + end + end + end + + if latest_version == "" or zip_url == "" then + return { success = false, error = "Manifest missing version or zip_url" } + end + + local current_version = utils.get_plugin_version() + + -- Compare version tables component by component (can't use <= on tables in Lua) + local function compare_versions(a, b) + local ta = utils.parse_version(a) + local tb = utils.parse_version(b) + local len = math.max(#ta, #tb) + for i = 1, len do + local ai = ta[i] or 0 + local bi = tb[i] or 0 + if ai < bi then return -1 + elseif ai > bi then return 1 + end + end + return 0 + end + + if compare_versions(latest_version, current_version) <= 0 then + return { success = true, message = "Up-to-date (current " .. current_version .. ")" } + end + + local pending_zip = paths.backend_path(config.UPDATE_PENDING_ZIP) + + local dl_resp = http_client.get(zip_url, { timeout = 30 }) + if dl_resp and dl_resp.status == 200 and dl_resp.body then + m_utils.write_file(pending_zip, dl_resp.body) + + local is_windows = m_utils.getenv("OS") == "Windows_NT" + local cmd + if is_windows then + cmd = 'powershell -Command "Expand-Archive -Force -Path \'' .. pending_zip .. '\' -DestinationPath \'' .. paths.get_plugin_dir() .. '\'"' + else + cmd = 'unzip -o -q "' .. pending_zip .. '" -d "' .. paths.get_plugin_dir() .. '"' + end + m_utils.exec(cmd) + fs.remove(pending_zip) + + local msg = "LuaTools updated to " .. latest_version .. ". Please restart Steam." + return { success = true, message = msg } + end + + return { success = false, error = "Update download failed" } +end + +function auto_update.restart_steam() + local is_windows = m_utils.getenv("OS") == "Windows_NT" + if is_windows then + local script_path = paths.backend_path("restart_steam.cmd") + if fs.exists(script_path) then + m_utils.exec('start /b cmd /C "' .. script_path .. '"') + return true + end + else + m_utils.exec("killall steam && steam &") + return true + end + return false +end + +function auto_update.apply_pending_update_if_any() + return "" +end + +return auto_update diff --git a/backend/config.lua b/backend/config.lua new file mode 100644 index 0000000..ed97d6c --- /dev/null +++ b/backend/config.lua @@ -0,0 +1,35 @@ +local config = { + WEBKIT_DIR_NAME = "LuaTools", + WEB_UI_JS_FILE = "luatools.js", + WEB_UI_ICON_FILE = "luatools-icon.png", + + DEFAULT_HEADERS = { + ["Accept"] = "application/json", + ["X-Requested-With"] = "SteamDB", + ["User-Agent"] = "https://github.com/BossSloth/Steam-SteamDB-extension", + ["Origin"] = "https://github.com/BossSloth/Steam-SteamDB-extension", + ["Sec-Fetch-Dest"] = "empty", + ["Sec-Fetch-Mode"] = "cors", + ["Sec-Fetch-Site"] = "cross-site", + }, + + API_MANIFEST_URL = "https://raw.githubusercontent.com/madoiscool/lt_api_links/refs/heads/main/load_free_manifest_apis", + API_MANIFEST_PROXY_URL = "https://luatools.vercel.app/load_free_manifest_apis", + API_JSON_FILE = "api.json", + + UPDATE_CONFIG_FILE = "update.json", + UPDATE_PENDING_ZIP = "update_pending.zip", + UPDATE_PENDING_INFO = "update_pending.json", + + HTTP_TIMEOUT_SECONDS = 15, + HTTP_PROXY_TIMEOUT_SECONDS = 15, + + UPDATE_CHECK_INTERVAL_SECONDS = 2 * 60 * 60, -- 2 hours + + USER_AGENT = "discord(dot)gg/luatools", + + LOADED_APPS_FILE = "loadedappids.txt", + APPID_LOG_FILE = "appidlogs.txt", +} + +return config diff --git a/backend/donate_keys.lua b/backend/donate_keys.lua new file mode 100644 index 0000000..d829cb0 --- /dev/null +++ b/backend/donate_keys.lua @@ -0,0 +1,209 @@ +local m_utils = require("utils") +local fs = require("fs") +local http_client = require("http_client") +local config = require("config") +local logger = require("plugin_logger") +local paths = require("paths") +local steam_utils = require("steam_utils") +local datetime = require("datetime") + +local DONATED_APPIDS_FILE = paths.backend_path("data/donatedappids.txt") +local DONATION_URL = "http://167.235.229.108/donatekeys/send" + +local function _parse_vdf_simple(content) + local result = {} + local stack = {result} + local current_key = nil + + local pos = 1 + local len = #content + + while pos <= len do + local c = content:sub(pos, pos) + if c == '/' and content:sub(pos+1, pos+1) == '/' then + local nl = content:find("\n", pos) + pos = nl or (len + 1) + elseif c == '"' then + local end_quote = content:find('"', pos + 1) + if end_quote then + local token = content:sub(pos + 1, end_quote - 1) + pos = end_quote + 1 + + if current_key == nil then + current_key = token + else + stack[#stack][current_key] = token + current_key = nil + end + else + break + end + elseif c == '{' then + if current_key then + local new_dict = {} + stack[#stack][current_key] = new_dict + table.insert(stack, new_dict) + current_key = nil + end + pos = pos + 1 + elseif c == '}' then + if #stack > 1 then + table.remove(stack) + end + pos = pos + 1 + else + pos = pos + 1 + end + end + + return result +end + +local function _load_donated_appids() + if not fs.exists(DONATED_APPIDS_FILE) then return {} end + local content = m_utils.read_file(DONATED_APPIDS_FILE) + if not content then return {} end + local set = {} + for line in content:gmatch("[^\r\n]+") do + local t = line:match("^%s*(.-)%s*$") + if t and t ~= "" and not t:match("^DATE:") then + set[t] = true + end + end + return set +end + +local function _check_cache_staleness() + local today = datetime.unix() + + if not fs.exists(DONATED_APPIDS_FILE) then + local dir = fs.parent_path(DONATED_APPIDS_FILE) + if not fs.exists(dir) then fs.create_directories(dir) end + m_utils.write_file(DONATED_APPIDS_FILE, "DATE:" .. tostring(today) .. "\n") + return + end + + local content = m_utils.read_file(DONATED_APPIDS_FILE) + if not content then return end + local first_line = content:match("^[^\r\n]+") or "" + + local wipe = false + if first_line:match("^DATE:") then + local date_str = first_line:sub(6) + local cached_time = tonumber(date_str) + if cached_time then + local diff = today - cached_time + if diff >= (7 * 24 * 60 * 60) then + wipe = true + end + else + wipe = true + end + else + wipe = true + end + + if wipe then + m_utils.write_file(DONATED_APPIDS_FILE, "DATE:" .. tostring(today) .. "\n") + end +end + +local function _save_donated_appids(appids_set) + local sorted = {} + for k in pairs(appids_set) do table.insert(sorted, k) end + table.sort(sorted) + local append_str = "" + for _, appid in ipairs(sorted) do + append_str = append_str .. appid .. "\n" + end + m_utils.append_file(DONATED_APPIDS_FILE, append_str) +end + +local function validate_appid_key_pair(appid, key) + if type(appid) ~= "string" or type(key) ~= "string" then return false end + if not appid:match("^%d+$") or #appid > 10 then return false end + if #key ~= 64 or not key:match("^[a-zA-Z0-9]+$") then return false end + return true +end + +local function parse_config_vdf_decryption_keys(steam_path) + local config_path = fs.join(steam_path, "config", "config.vdf") + if not fs.exists(config_path) then return {} end + + local content = m_utils.read_file(config_path) + if not content then return {} end + + local vdf_data = _parse_vdf_simple(content) + local pairs = {} + + local function find_keys(data) + for k, v in pairs(data) do + if type(v) == "table" then + if v["DecryptionKey"] and type(v["DecryptionKey"]) == "string" then + table.insert(pairs, { appid = tostring(k), key = v["DecryptionKey"] }) + else + find_keys(v) + end + end + end + end + find_keys(vdf_data) + return pairs +end + +local function extract_valid_decryption_keys(steam_path) + if not steam_path or steam_path == "" or not fs.exists(steam_path) then + return {} + end + + local all_pairs = parse_config_vdf_decryption_keys(steam_path) + local valid_pairs = {} + for _, pair in ipairs(all_pairs) do + if validate_appid_key_pair(pair.appid, pair.key) then + table.insert(valid_pairs, pair) + end + end + return valid_pairs +end + +local donate_keys = {} + +function donate_keys.send_donation_keys(pairs_list) + if not pairs_list or #pairs_list == 0 then return false end + _check_cache_staleness() + + local already_donated = _load_donated_appids() + local new_pairs = {} + for _, pair in ipairs(pairs_list) do + if not already_donated[pair.appid] then + table.insert(new_pairs, pair) + end + end + + if #new_pairs == 0 then return true end + + local formatted = {} + local new_appids_set = {} + for _, pair in ipairs(new_pairs) do + table.insert(formatted, pair.appid .. ":" .. pair.key) + new_appids_set[pair.appid] = true + end + local payload = table.concat(formatted, ",") + + local headers = { + ["Content-Type"] = "text/plain", + ["User-Agent"] = config.USER_AGENT + } + + local resp = http_client.post(DONATION_URL, { headers = headers, data = payload, timeout = 15 }) + if resp and resp.status == 200 then + _save_donated_appids(new_appids_set) + return true + end + return false +end + +-- Export utilities for testability if needed +donate_keys.extract_valid_decryption_keys = extract_valid_decryption_keys + +return donate_keys diff --git a/backend/downloads.lua b/backend/downloads.lua new file mode 100644 index 0000000..6805a3b --- /dev/null +++ b/backend/downloads.lua @@ -0,0 +1,297 @@ +local m_utils = require("utils") +local fs = require("fs") +local http_client = require("http_client") +local config = require("config") +local logger = require("plugin_logger") +local paths = require("paths") +local steam_utils = require("steam_utils") +local utils = require("plugin_utils") +local api_manifest = require("api_manifest") +local settings_manager = require("settings.manager") +local cjson = require("json") + +local downloads = {} +local DOWNLOAD_STATE = {} + +local function _set_download_state(appid, update) + if type(appid) == "string" then appid = tonumber(appid) end + if not DOWNLOAD_STATE[appid] then DOWNLOAD_STATE[appid] = {} end + for k, v in pairs(update) do + DOWNLOAD_STATE[appid][k] = v + end +end + +local function _get_download_state(appid) + if type(appid) == "string" then appid = tonumber(appid) end + local state = DOWNLOAD_STATE[appid] or {} + local copy = {} + for k, v in pairs(state) do copy[k] = v end + return copy +end + +function downloads.get_add_status(appid) + if type(appid) == "string" then appid = tonumber(appid) end + + local dest_root = utils.ensure_temp_download_dir() + local state_file = fs.join(dest_root, tostring(appid) .. "_state.json") + + if fs.exists(state_file) then + local content = m_utils.read_file(state_file) + if content and content ~= "" then + local success, data = pcall(cjson.decode, content) + if success and type(data) == "table" and data.status then + _set_download_state(appid, { status = data.status, error = data.error }) + + if data.status == "extracted" then + -- Background script finished! Complete the installation synchronously. + local dest_path = fs.join(dest_root, tostring(appid) .. ".zip") + local extract_dir = fs.join(dest_root, "extracted_" .. tostring(appid)) + local apiName = _get_download_state(appid).currentApi or "Unknown" + + local ok, res = pcall(downloads._finalize_install_lua, appid, extract_dir, dest_path, apiName) + if not ok then + _set_download_state(appid, { status = "failed", error = tostring(res) }) + end + + -- Cleanup background script files + pcall(fs.remove, state_file) + pcall(fs.remove, fs.join(dest_root, tostring(appid) .. "_dl.ps1")) + pcall(fs.remove, fs.join(dest_root, tostring(appid) .. "_dl.sh")) + elseif data.status == "failed" then + pcall(fs.remove, state_file) + end + end + end + end + + return { success = true, state = _get_download_state(appid) } +end + +function downloads._finalize_install_lua(appid, extract_dir, dest_path, api_name) + _set_download_state(appid, { status = "processing" }) + local base_path = steam_utils.detect_steam_install_path() + local target_dir = fs.join(base_path, "config", "stplug-in") + if not fs.exists(target_dir) then fs.create_directories(target_dir) end + + local depot_cache = fs.join(base_path, "depotcache") + if not fs.exists(depot_cache) then fs.create_directories(depot_cache) end + + local target_lua = fs.join(target_dir, tostring(appid) .. ".lua") + local extracted_lua_path = nil + + local success_list, files = pcall(fs.list_recursive, extract_dir) + if success_list and files then + for _, entry in ipairs(files) do + if entry.is_directory then goto continue end + if entry.name:match("%.manifest$") then + local dest_man = fs.join(depot_cache, entry.name) + local content = m_utils.read_file(entry.path) + if content then m_utils.write_file(dest_man, content) end + end + if entry.name == tostring(appid) .. ".lua" then + extracted_lua_path = entry.path + elseif not extracted_lua_path and entry.name:match("^%d+%.lua$") then + extracted_lua_path = entry.path + end + ::continue:: + end + end + + if extracted_lua_path and fs.exists(extracted_lua_path) then + local text = m_utils.read_file(extracted_lua_path) + if text then + local new_lines = {} + for line in text:gmatch("([^\n]*)\n?") do + if line:match("^%s*setManifestid%(") then + line = line:gsub("^(%s*)(setManifestid)", "%1-- %2") + end + table.insert(new_lines, line) + end + if new_lines[#new_lines] == "" then table.remove(new_lines) end + text = table.concat(new_lines, "\n") + m_utils.write_file(target_lua, text) + _set_download_state(appid, { installedPath = target_lua }) + end + end + + pcall(fs.remove_all, extract_dir) + pcall(fs.remove, dest_path) + _set_download_state(appid, { status = "done", success = true, api = api_name }) +end + +local function _launch_async_download(appid, url, dest_path, extract_dir) + local is_windows = m_utils.getenv("OS") == "Windows_NT" + local dest_root = utils.ensure_temp_download_dir() + local state_file = fs.join(dest_root, tostring(appid) .. "_state.json") + + m_utils.write_file(state_file, '{"status": "downloading"}') + if not fs.exists(extract_dir) then fs.create_directories(extract_dir) end + + if is_windows then + local ps1_path = fs.join(paths.get_plugin_dir(), "backend", "scripts", "downloader.ps1") + local cmd = string.format( + 'powershell -WindowStyle Hidden -Command "Start-Process -FilePath powershell -WindowStyle Hidden -ArgumentList \'-ExecutionPolicy Bypass -File \\"%s\\" -Url \\"%s\\" -DestPath \\"%s\\" -ExtractDir \\"%s\\" -StateFile \\"%s\\"\'"', + ps1_path, url, dest_path, extract_dir, state_file + ) + m_utils.exec(cmd) + else + local sh_path = fs.join(paths.get_plugin_dir(), "backend", "scripts", "downloader.sh") + m_utils.exec('chmod +x "' .. sh_path .. '"') + local cmd = string.format( + 'nohup bash "%s" "%s" "%s" "%s" "%s" > /dev/null 2>&1 &', + sh_path, url, dest_path, extract_dir, state_file + ) + m_utils.exec(cmd) + end +end + +function downloads.start_add_via_luatools_from_url(appid, url, apiName) + if type(appid) == "string" then appid = tonumber(appid) end + if not appid then return { success = false, error = "Invalid appid" } end + + logger.log("LuaTools: StartAddViaLuaToolsFromUrl appid=" .. tostring(appid) .. " api=" .. tostring(apiName)) + _set_download_state(appid, { status = "downloading", currentApi = apiName, bytesRead = 0, totalBytes = 0 }) + + local ok, res = pcall(function() + if not url or url == "" then error("Invalid URL provided") end + local dest_root = utils.ensure_temp_download_dir() + local dest_path = fs.join(dest_root, tostring(appid) .. ".zip") + local extract_dir = fs.join(dest_root, "extracted_" .. tostring(appid)) + _launch_async_download(appid, url, dest_path, extract_dir) + end) + + if not ok then + logger.warn("LuaTools: Async Download crashed - " .. tostring(res)) + _set_download_state(appid, { status = "failed", error = tostring(res) }) + return { success = false, error = tostring(res) } + end + + return { success = true } +end + +function downloads.start_add_via_luatools(appid) + if type(appid) == "string" then appid = tonumber(appid) end + if not appid then return { success = false, error = "Invalid appid" } end + + logger.log("LuaTools: StartAddViaLuaTools appid=" .. tostring(appid)) + _set_download_state(appid, { status = "queued", bytesRead = 0, totalBytes = 0 }) + + local apis = api_manifest.load_api_manifest() + if not apis or #apis == 0 then + _set_download_state(appid, { status = "failed", error = "No APIs available" }) + return { success = true } + end + + local dest_root = utils.ensure_temp_download_dir() + local dest_path = fs.join(dest_root, tostring(appid) .. ".zip") + local extract_dir = fs.join(dest_root, "extracted_" .. tostring(appid)) + local morrenus_api_key = settings_manager.get_morrenus_api_key() + + local ok, res = pcall(function() + -- Note: For auto-add we only try the FIRST valid URL without verifying it via a synchronous HTTP request, + -- because verifying it synchronously would defeat the purpose of async downloads. + -- We assume CheckApisForApp already verified availability before user clicked this! + local target_url = nil + local target_name = nil + for _, api in ipairs(apis) do + local name = api.name or "Unknown" + local template = api.url or "" + if string.find(template, "") then + if not morrenus_api_key or morrenus_api_key == "" then goto continue end + template = template:gsub("", morrenus_api_key) + end + target_url = template:gsub("", tostring(appid)) + target_name = name + break + ::continue:: + end + if not target_url then error("Not available on any API") end + + _set_download_state(appid, { status = "downloading", currentApi = target_name }) + _launch_async_download(appid, target_url, dest_path, extract_dir) + end) + + if not ok then + logger.warn("LuaTools: start_add_via_luatools crashed - " .. tostring(res)) + _set_download_state(appid, { status = "failed", error = tostring(res) }) + return { success = false, error = tostring(res) } + end + + return { success = true } +end + +function downloads.check_apis_for_app(appid) + if type(appid) == "string" then appid = tonumber(appid) end + if not appid then return { success = false, error = "Invalid appid" } end + + local apis = api_manifest.load_api_manifest() + if not apis or #apis == 0 then + return { success = true, results = {} } + end + + local results = {} + local morrenus_api_key = settings_manager.get_morrenus_api_key() + + local fast_check_succeeded = false + local fast_check_data = {} + local fast_resp = http_client.get("http://167.235.229.108/check_apis?appid=" .. tostring(appid), { + headers = { ["User-Agent"] = "secretgoonpoon" }, + timeout = 5 + }) + + if fast_resp and fast_resp.status == 200 and fast_resp.body then + local ok, data = pcall(utils.decode_json, fast_resp.body) + if ok and type(data) == "table" then + fast_check_data = data + fast_check_succeeded = true + end + end + + for _, api in ipairs(apis) do + local name = api.name or "Unknown" + local template = api.url or "" + local success_code = tonumber(api.success_code) or 200 + + if string.find(template, "") then + if not morrenus_api_key or morrenus_api_key == "" then + goto continue + end + template = template:gsub("", morrenus_api_key) + end + + local url = template:gsub("", tostring(appid)) + local available = false + + if fast_check_succeeded then + local check_key = (string.lower(name) == "morrenus") and "Sadie (Morrenus)" or name + if fast_check_data[check_key] == "available" then + available = true + end + else + if string.lower(name) == "morrenus" then + local status_url = "https://hubcapmanifest.com/api/v1/status/" .. tostring(appid) .. "?api_key=" .. tostring(morrenus_api_key) + local resp = http_client.get(status_url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if resp and resp.status == success_code then + available = true + end + else + local resp = http_client.get(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if resp and resp.status == success_code then + available = true + end + end + end + + table.insert(results, { + name = name, + available = available, + url = available and url or nil + }) + + ::continue:: + end + + return { success = true, results = results } +end + +return downloads diff --git a/backend/fixes.lua b/backend/fixes.lua new file mode 100644 index 0000000..7381349 --- /dev/null +++ b/backend/fixes.lua @@ -0,0 +1,110 @@ +local m_utils = require("utils") +local fs = require("fs") +local http_client = require("http_client") +local logger = require("plugin_logger") +local utils = require("plugin_utils") +local paths = require("paths") +local cjson = require("json") + +local fixes = {} + +function fixes.check_for_fixes(appid) + if type(appid) == "string" then appid = tonumber(appid) end + local result = { + success = true, + appid = appid, + gameName = "Unknown Game (" .. tostring(appid) .. ")", + genericFix = { status = 0, available = false }, + onlineFix = { status = 0, available = false } + } + + local FIXES_INDEX_URL = "https://index.luatools.work/fixes-index.json" + local resp = http_client.get(FIXES_INDEX_URL, { timeout = 10 }) + if resp and resp.status == 200 and resp.body then + local data = utils.decode_json(resp.body) + if type(data) == "table" then + local generic_url = "https://files.luatools.work/GameBypasses/" .. tostring(appid) .. ".zip" + local online_url = "https://files.luatools.work/OnlineFix1/" .. tostring(appid) .. ".zip" + + local has_generic = false + for _, v in ipairs(data.genericFixes or {}) do if tonumber(v) == appid then has_generic = true break end end + if has_generic then + result.genericFix.status = 200 + result.genericFix.available = true + result.genericFix.url = generic_url + else + result.genericFix.status = 404 + end + + local has_online = false + for _, v in ipairs(data.onlineFixes or {}) do if tonumber(v) == appid then has_online = true break end end + if has_online then + result.onlineFix.status = 200 + result.onlineFix.available = true + result.onlineFix.url = online_url + else + result.onlineFix.status = 404 + end + end + end + + return result +end + +function fixes.apply_game_fix(appid, download_url, install_path, fix_type, game_name) + local dest_root = utils.ensure_temp_download_dir() + local dest_zip = fs.join(dest_root, "fix_" .. tostring(appid) .. ".zip") + local state_file = fs.join(dest_root, "fix_" .. tostring(appid) .. "_state.json") + + logger.log("LuaTools: Applying fix to " .. tostring(install_path)) + m_utils.write_file(state_file, '{"status": "downloading"}') + + local is_windows = m_utils.getenv("OS") == "Windows_NT" + if is_windows then + local ps1_path = fs.join(paths.get_plugin_dir(), "backend", "scripts", "downloader.ps1") + local cmd = string.format( + 'powershell -WindowStyle Hidden -Command "Start-Process -FilePath powershell -WindowStyle Hidden -ArgumentList \'-ExecutionPolicy Bypass -File \\"%s\\" -Url \\"%s\\" -DestPath \\"%s\\" -ExtractDir \\"%s\\" -StateFile \\"%s\\"\'"', + ps1_path, download_url, dest_zip, install_path, state_file + ) + m_utils.exec(cmd) + else + local sh_path = fs.join(paths.get_plugin_dir(), "backend", "scripts", "downloader.sh") + m_utils.exec('chmod +x "' .. sh_path .. '"') + local cmd = string.format( + 'nohup bash "%s" "%s" "%s" "%s" "%s" > /dev/null 2>&1 &', + sh_path, download_url, dest_zip, install_path, state_file + ) + m_utils.exec(cmd) + end + + return { success = true } +end + +function fixes.get_apply_status(appid) + local dest_root = utils.ensure_temp_download_dir() + local state_file = fs.join(dest_root, "fix_" .. tostring(appid) .. "_state.json") + local dest_zip = fs.join(dest_root, "fix_" .. tostring(appid) .. ".zip") + + if not fs.exists(state_file) then + return { success = true, state = { status = "done" } } + end + + local content = m_utils.read_file(state_file) + if content and content ~= "" then + local success, data = pcall(cjson.decode, content) + if success and type(data) == "table" and data.status then + if data.status == "extracted" then + data.status = "done" + pcall(fs.remove, state_file) + pcall(fs.remove, dest_zip) + elseif data.status == "failed" then + pcall(fs.remove, state_file) + end + return { success = true, state = data } + end + end + + return { success = true, state = { status = "downloading" } } +end + +return fixes diff --git a/backend/http_client.lua b/backend/http_client.lua new file mode 100644 index 0000000..97b5afe --- /dev/null +++ b/backend/http_client.lua @@ -0,0 +1,20 @@ +local m_http = require("http") +local config = require("config") + +local http_client = {} + +function http_client.get(url, options) + options = options or {} + options.timeout = options.timeout or config.HTTP_TIMEOUT_SECONDS + return m_http.get(url, options) +end + +function http_client.post(url, options) + options = options or {} + options.timeout = options.timeout or config.HTTP_TIMEOUT_SECONDS + local data = options.data + options.data = nil + return m_http.post(url, data, options) +end + +return http_client diff --git a/backend/locales/manager.lua b/backend/locales/manager.lua new file mode 100644 index 0000000..f5543d7 --- /dev/null +++ b/backend/locales/manager.lua @@ -0,0 +1,195 @@ +local fs = require("fs") +local utils = require("plugin_utils") +local paths = require("paths") +local logger = require("plugin_logger") + +local DEFAULT_LOCALE = "en" +local PLACEHOLDER_VALUE = "translation missing" +local LOCALES_DIR = paths.backend_path("locales") + +local function _ensure_locales_dir() + if not fs.exists(LOCALES_DIR) then + fs.create_directories(LOCALES_DIR) + end +end + +local function _locale_path(locale) + return fs.join(LOCALES_DIR, locale .. ".json") +end + +local function _read_locale_file(locale) + local path = _locale_path(locale) + if not fs.exists(path) then + return {}, {} + end + local data = utils.read_json(path) + if not data then return {}, {} end + + local meta = data._meta or {} + local strings = data.strings + + if type(strings) ~= "table" then + strings = {} + for k, v in pairs(data) do + if k ~= "_meta" and type(v) == "string" then + strings[k] = v + end + end + end + return meta, strings +end + +local function _write_locale_file(locale, meta, strings) + _ensure_locales_dir() + local data = { + _meta = meta or {}, + strings = strings or {} + } + data._meta.code = locale + utils.write_json(_locale_path(locale), data) +end + +local function _normalise_value(value) + if not value then return nil end + local stripped = tostring(value):match("^%s*(.-)%s*$") + if stripped == "" or stripped:lower() == PLACEHOLDER_VALUE then + return nil + end + return tostring(value) +end + +local LocaleManager = {} +LocaleManager.__index = LocaleManager + +function LocaleManager.new() + local self = setmetatable({}, LocaleManager) + self._locales = {} + self._english_strings = {} + self._english_meta = {} + self:refresh() + return self +end + +function LocaleManager:refresh() + _ensure_locales_dir() + local meta, strings = _read_locale_file(DEFAULT_LOCALE) + if not strings or next(strings) == nil then + logger.warn("LuaTools: Default locale en.json is empty or missing.") + strings = {} + end + + self._english_meta = meta + self._english_meta.code = DEFAULT_LOCALE + self._english_strings = {} + for k, v in pairs(strings) do self._english_strings[k] = v end + self._locales = {} + + local success, files = pcall(fs.list, LOCALES_DIR) + if success and files then + for _, entry in ipairs(files) do + local filename = entry.name + if filename:match("%.json$") then + local locale_code = filename:sub(1, -6) + local l_meta, l_strings = _read_locale_file(locale_code) + + if locale_code ~= DEFAULT_LOCALE then + local updated = false + for key, _ in pairs(self._english_strings) do + if not l_strings[key] then + l_strings[key] = PLACEHOLDER_VALUE + updated = true + end + end + if updated then + _write_locale_file(locale_code, l_meta, l_strings) + end + end + + local merged_strings = {} + for key, english_value in pairs(self._english_strings) do + local candidate = l_strings[key] + local normalised = _normalise_value(candidate) + if normalised and locale_code ~= DEFAULT_LOCALE then + merged_strings[key] = normalised + else + local fallback = _normalise_value(english_value) + merged_strings[key] = fallback or PLACEHOLDER_VALUE + end + end + + l_meta.code = locale_code + local name = l_meta.name or l_meta.nativeName or locale_code + l_meta.name = name + l_meta.nativeName = l_meta.nativeName or name + + self._locales[locale_code] = { + meta = l_meta, + strings = merged_strings, + raw = l_strings + } + end + end + end + + if not self._locales[DEFAULT_LOCALE] then + local def_strings = {} + for k, v in pairs(self._english_strings) do + def_strings[k] = _normalise_value(v) or PLACEHOLDER_VALUE + end + self._locales[DEFAULT_LOCALE] = { + meta = self._english_meta, + strings = def_strings, + raw = self._english_strings + } + end +end + +function LocaleManager:available_locales() + local locales = {} + for code, payload in pairs(self._locales) do + local meta = payload.meta or {} + table.insert(locales, { + code = code, + name = meta.name or code, + nativeName = meta.nativeName or meta.name or code + }) + end + table.sort(locales, function(a, b) return a.code < b.code end) + return locales +end + +function LocaleManager:get_locale_strings(locale) + local payload = self._locales[locale] or self._locales[DEFAULT_LOCALE] + local strings = payload and payload.strings or {} + local result = {} + for k, v in pairs(strings) do result[k] = v end + return result +end + +function LocaleManager:translate(key, locale) + if not key then return PLACEHOLDER_VALUE end + local payload = self._locales[locale] + if payload and payload.strings and payload.strings[key] then + return payload.strings[key] + end + payload = self._locales[DEFAULT_LOCALE] + if payload and payload.strings and payload.strings[key] then + return payload.strings[key] + end + return PLACEHOLDER_VALUE +end + +local manager_instance = nil +local function get_locale_manager() + if not manager_instance then + manager_instance = LocaleManager.new() + end + return manager_instance +end + +return { + DEFAULT_LOCALE = DEFAULT_LOCALE, + PLACEHOLDER_VALUE = PLACEHOLDER_VALUE, + LOCALES_DIR = LOCALES_DIR, + get_locale_manager = get_locale_manager +} diff --git a/backend/main.lua b/backend/main.lua new file mode 100644 index 0000000..b5194b1 --- /dev/null +++ b/backend/main.lua @@ -0,0 +1,554 @@ +-- LuaTools backend main.lua +-- All exported functions return JSON-encoded strings, mirroring the Python backend's json.dumps() returns. +-- This is required because Millennium's Lua bridge does not deep-serialize nested Lua tables. + +local cjson = require("json") +local m_utils = require("utils") +local logger = require("plugin_logger") +local millennium = require("millennium") +local fs = require("fs") +local http_client = require("http_client") +local paths = require("paths") +local steam_utils = require("steam_utils") +local utils = require("plugin_utils") +local locales_mod = require("locales.manager") + +local api_manifest = require("api_manifest") +local downloads = require("downloads") +local fixes = require("fixes") +local settings_manager = require("settings.manager") +local auto_update = require("auto_update") + +-- ── Helpers ────────────────────────────────────────────────────────────────── + +--- Safely encode a Lua table to a JSON string (same as Python json.dumps). +local function json_ok(data) + local ok, s = pcall(cjson.encode, data) + if ok then return s end + logger.warn("json_ok encode failed: " .. tostring(s)) + return '{"success":false,"error":"serialization error"}' +end + +local function json_err(msg) + return json_ok({ success = false, error = tostring(msg) }) +end + +-- ── Webkit file management ─────────────────────────────────────────────────── + +local function copy_webkit_files() + local steam_dir = steam_utils.detect_steam_install_path() + if not steam_dir or steam_dir == "" then return end + + local target_webkit_dir = fs.join(steam_dir, "steamui", "webkit") + if not fs.exists(target_webkit_dir) then + fs.create_directories(target_webkit_dir) + end + + local public_dir = fs.join(paths.get_plugin_dir(), "public") + + local src_js = fs.join(public_dir, "luatools.js") + local dst_js = fs.join(target_webkit_dir, "luatools.js") + if fs.exists(src_js) then + local content = m_utils.read_file(src_js) + if content then m_utils.write_file(dst_js, content) end + end + + local src_css = fs.join(public_dir, "steamdb-webkit.css") + local dst_css = fs.join(target_webkit_dir, "steamdb-webkit.css") + if fs.exists(src_css) then + local content = m_utils.read_file(src_css) + if content then m_utils.write_file(dst_css, content) end + end +end + +local function inject_webkit_files() + millennium.add_browser_css("webkit/steamdb-webkit.css") + millennium.add_browser_js("webkit/luatools.js") +end + +-- ── Lifecycle ──────────────────────────────────────────────────────────────── + +local function on_load() + logger.log("Bootstrapping LuaTools plugin, millennium " .. millennium.version()) + steam_utils.detect_steam_install_path() + utils.ensure_temp_download_dir() + + local ok_s, err_s = pcall(settings_manager.init_settings) + if not ok_s then logger.warn("settings init failed: " .. tostring(err_s)) end + + local ok_u, upd_msg = pcall(auto_update.apply_pending_update_if_any) + if ok_u and upd_msg and upd_msg ~= "" then + api_manifest.store_last_message(upd_msg) + end + + copy_webkit_files() + inject_webkit_files() + + local res = api_manifest.init_apis() + logger.log("InitApis (boot) result: " .. tostring(res.message or "")) + + millennium.ready() + + local keys = {} + for k, v in pairs(millennium) do table.insert(keys, k .. ":" .. type(v)) end + logger.warn("MILLENNIUM KEYS: " .. table.concat(keys, ", ")) +end + +local function on_unload() + logger.log("unloading LuaTools plugin") +end + +local function on_frontend_loaded() + logger.log("Frontend loaded") + copy_webkit_files() +end + +-- ── Logger (called as "Logger.log" from JS) ────────────────────────────────── + +Logger = {} + +function Logger.log(message) + local msg = type(message) == "table" and tostring(message.message or "") or tostring(message or "") + logger.log("[Frontend] " .. msg) + return json_ok({ success = true }) +end + +function Logger.warn(message) + local msg = type(message) == "table" and tostring(message.message or "") or tostring(message or "") + logger.warn("[Frontend] " .. msg) + return json_ok({ success = true }) +end + +function Logger.error(message) + local msg = type(message) == "table" and tostring(message.message or "") or tostring(message or "") + logger.error("[Frontend] " .. msg) + return json_ok({ success = true }) +end + +-- Millennium looks up "Logger.log" as a dotted global key +_G["Logger.log"] = Logger.log +_G["Logger.warn"] = Logger.warn +_G["Logger.error"] = Logger.error + +-- ── Exported API Methods ───────────────────────────────────────────────────── +-- Every function returns a JSON string, matching the Python backend exactly. + +function GetPluginDir() + return paths.get_plugin_dir() -- plain string, matches Python +end + +function InitApis() + local ok, res = pcall(api_manifest.init_apis) + if not ok then return json_err(res) end + return json_ok(res) +end + +function GetInitApisMessage() + local ok, res = pcall(api_manifest.get_init_apis_message) + if not ok then return json_err(res) end + return json_ok(res) +end + +function FetchFreeApisNow() + local ok, res = pcall(api_manifest.fetch_free_apis_now) + if not ok then return json_err(res) end + return json_ok(res) +end + +function CheckForUpdatesNow() + local ok, res = pcall(auto_update.check_for_updates_now) + if not ok then + logger.warn("CheckForUpdatesNow failed: " .. tostring(res)) + return json_err(res) + end + return json_ok(res) +end + +function RestartSteam() + local ok, success = pcall(auto_update.restart_steam) + if ok and success then + return json_ok({ success = true }) + end + return json_ok({ success = false, error = "Failed to restart Steam" }) +end + +function HasLuaToolsForApp(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, exists = pcall(steam_utils.has_lua_for_app, tonumber(appid)) + if not ok then return json_err(exists) end + return json_ok({ success = true, exists = exists == true }) +end + +function StartAddViaLuaTools(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, res = pcall(downloads.start_add_via_luatools, tonumber(appid)) + if not ok then return json_err(res) end + return json_ok(res) +end + +function GetAddViaLuaToolsStatus(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, res = pcall(downloads.get_add_status, tonumber(appid)) + if not ok then return json_err(res) end + return json_ok(res) +end + +function GetApiList() + local ok, res = pcall(api_manifest.get_api_list) + if not ok then return json_err(res) end + return json_ok(res) +end + +function CancelAddViaLuaTools(appid) + -- No-op cancel stub; download is synchronous in Lua + return json_ok({ success = true }) +end + +function CheckApisForApp(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, res = pcall(downloads.check_apis_for_app, tonumber(appid)) + if not ok then return json_err(res) end + + -- Ensure empty arrays encode as [] and not {} + if res and type(res.results) == "table" and #res.results == 0 then + -- Serialize manually or inject cjson.empty_array + local success_json = res.success and "true" or "false" + return '{"success":' .. success_json .. ',"results":[]}' + end + + return json_ok(res) +end + +function GetMorrenusStats(api_key, force_refresh) + if type(api_key) == "table" then + force_refresh = api_key.force_refresh + api_key = api_key.api_key + end + api_key = tostring(api_key or "") + if api_key == "" then return json_err("api_key required") end + local endpoint = "https://hubcapmanifest.com/api/v1/user/stats?api_key=" .. api_key + local ok, resp = pcall(http_client.get, endpoint, { timeout = 10 }) + if ok and resp and resp.status == 200 then + return resp.body -- already JSON string + end + return json_err("request failed") +end + +function StartAddViaLuaToolsFromUrl(apiName, appid, contentScriptQuery, url) + -- Millennium's IPC bridge sorts JS object keys alphabetically and passes their values as positional arguments. + -- The JS passes: { apiName: ..., appid: ..., contentScriptQuery: "", url: ... } + -- So the Lua signature MUST be (apiName, appid, contentScriptQuery, url) + + logger.log("StartAddViaLuaToolsFromUrl CALLED: appid=" .. tostring(appid) .. ", url=" .. tostring(url) .. ", apiName=" .. tostring(apiName)) + + local ok, res = pcall(downloads.start_add_via_luatools_from_url, appid, url, apiName) + if not ok then + logger.warn("StartAddViaLuaToolsFromUrl CRASHED inside pcall: " .. tostring(res)) + return json_err(res) + end + + return json_ok(res) +end + +function GetIconDataUrl() + -- Python read an icon file from the public dir and base64-encoded it + local icon_path = fs.join(paths.get_plugin_dir(), "public", "luatools-icon.png") + if fs.exists(icon_path) then + local content = m_utils.read_file(icon_path) + if content then + return json_ok({ success = true, dataUrl = "data:image/png;base64," .. (m_utils.base64_encode and m_utils.base64_encode(content) or "") }) + end + end + return json_ok({ success = false, error = "icon not found" }) +end + +function GetGamesDatabase() + local ok, res = pcall(function() + local db_path = paths.backend_path("data/applist.json") + if fs.exists(db_path) then + local data = utils.read_json(db_path) + return { success = true, apps = data.apps or data or {} } + end + return { success = true, apps = {} } + end) + if not ok then return json_err(res) end + return json_ok(res) +end + +function ReadLoadedApps() + local ok, res = pcall(function() + local log_path = paths.backend_path("loadedappids.txt") + local apps = {} + if fs.exists(log_path) then + local text = utils.read_text(log_path) + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + local appid = tonumber(line:match("^%s*(%d+)%s*$")) + if appid then table.insert(apps, appid) end + end + end + return { success = true, apps = apps } + end) + if not ok then return json_err(res) end + return json_ok(res) +end + +function DismissLoadedApps() + local ok, err = pcall(function() + local log_path = paths.backend_path("loadedappids.txt") + if fs.exists(log_path) then + m_utils.write_file(log_path, "") + end + end) + if not ok then return json_err(err) end + return json_ok({ success = true }) +end + +function DeleteLuaToolsForApp(appid) + if type(appid) == "table" then appid = appid.appid end + local base = steam_utils.detect_steam_install_path() + local target_dir = fs.join(base, "config", "stplug-in") + local candidates = { + fs.join(target_dir, tostring(appid) .. ".lua"), + fs.join(target_dir, tostring(appid) .. ".lua.disabled"), + } + local deleted = {} + for _, p in ipairs(candidates) do + if fs.exists(p) then + pcall(fs.remove, p) + table.insert(deleted, p) + end + end + return json_ok({ success = true, deleted = deleted, count = #deleted }) +end + +function CheckForFixes(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, res = pcall(fixes.check_for_fixes, tonumber(appid)) + if not ok then return json_err(res) end + return json_ok(res) +end + +function ApplyGameFix(appid, contentScriptQuery, downloadUrl, fixType, gameName, installPath) + -- Millennium's IPC bridge sorts JS object keys alphabetically and passes their values as positional arguments. + -- The JS passes: { appid, contentScriptQuery, downloadUrl, fixType, gameName, installPath } + -- So the Lua signature MUST be (appid, contentScriptQuery, downloadUrl, fixType, gameName, installPath) + + local ok, res = pcall(fixes.apply_game_fix, + tonumber(appid), tostring(downloadUrl or ""), + tostring(installPath or ""), tostring(fixType or ""), tostring(gameName or "")) + if not ok then + logger.warn("ApplyGameFix CRASHED: " .. tostring(res)) + return json_err(res) + end + return json_ok(res) +end + +function GetApplyFixStatus(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, res = pcall(fixes.get_apply_status, tonumber(appid)) + if not ok then return json_err(res) end + return json_ok(res) +end + +function CancelApplyFix(appid) + return json_ok({ success = true }) +end + +function UninstallFix(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, res = pcall(fixes.uninstall_fix, tonumber(appid)) + if not ok then return json_err(res) end + return json_ok(res) +end + +function UnFixGame(appid, installPath, fixDate) + if type(appid) == "table" then + installPath = appid.installPath; fixDate = appid.fixDate; appid = appid.appid + end + -- Stub - unfix logic not yet ported + return json_ok({ success = false, error = "Not yet implemented" }) +end + +function GetUnfixStatus(appid) + return json_ok({ success = true, state = { status = "done" } }) +end + +function GetInstalledFixes() + return json_ok({ success = true, fixes = {} }) +end + +function GetInstalledLuaScripts() + local ok, res = pcall(function() + local base = steam_utils.detect_steam_install_path() + local target_dir = fs.join(base, "config", "stplug-in") + local scripts = {} + local ok2, files = pcall(fs.list, target_dir) + if ok2 and files then + for _, entry in ipairs(files) do + local name = entry.name or "" + if name:match("%.lua$") or name:match("%.lua%.disabled$") then + local aid = name:match("^(%d+)%.") + if aid then + table.insert(scripts, { + appid = tonumber(aid), + gameName = "Unknown Game (" .. aid .. ")", + filename = name, + isDisabled = name:match("%.disabled$") ~= nil, + path = entry.path or "" + }) + end + end + end + end + return { success = true, scripts = scripts } + end) + if not ok then return json_err(res) end + return json_ok(res) +end + +function GetGameInstallPath(appid) + if type(appid) == "table" then appid = appid.appid end + local ok, res = pcall(steam_utils.get_game_install_path_response, tonumber(appid)) + if not ok then return json_err(res) end + return json_ok(res) +end + +function OpenGameFolder(path) + if type(path) == "table" then path = path.path end + local ok, success = pcall(steam_utils.open_game_folder, tostring(path or "")) + if ok and success then + return json_ok({ success = true }) + end + return json_ok({ success = false, error = "Failed to open path" }) +end + +function OpenExternalUrl(url) + if type(url) == "table" then url = url.url end + url = tostring(url or "") + if not (url:sub(1,7) == "http://" or url:sub(1,8) == "https://") then + return json_err("Invalid URL") + end + local is_win = (m_utils.getenv("OS") or ""):find("Windows") ~= nil + if is_win then + pcall(m_utils.exec, 'start "" "' .. url .. '"') + else + pcall(m_utils.exec, 'xdg-open "' .. url .. '"') + end + return json_ok({ success = true }) +end + +function GetSettingsConfig() + local ok, payload = pcall(settings_manager.get_settings_payload) + if not ok then + logger.warn("GetSettingsConfig failed: " .. tostring(payload)) + return json_err(payload) + end + return json_ok({ + success = true, + schemaVersion = payload.version, + schema = payload.schema or {}, + values = payload.values or {}, + language = payload.language, + locales = payload.locales or {}, + translations = payload.translations or {} + }) +end + +function GetThemes() + return json_ok({ success = true, themes = {} }) +end + +function ApplySettingsChanges(changes) + -- Millennium may pass the argument as a JSON string rather than a decoded table. + -- Mirror the Python version's parsing logic exactly. + local payload = nil + + if type(changes) == "string" and changes ~= "" then + -- Try to decode the JSON string + local ok, decoded = pcall(cjson.decode, changes) + if not ok then + logger.warn("ApplySettingsChanges: failed to parse changes string") + return json_err("Invalid JSON payload") + end + -- Unwrap nested wrappers the JS bridge sometimes adds + if type(decoded) == "table" and decoded.changes then + payload = decoded.changes + elseif type(decoded) == "table" and type(decoded.changesJson) == "string" then + local ok2, inner = pcall(cjson.decode, decoded.changesJson) + if ok2 then payload = inner else return json_err("Invalid JSON payload") end + else + payload = decoded + end + elseif type(changes) == "table" then + -- Already a decoded table – handle wrapper keys + if changes.changes then + payload = changes.changes + elseif type(changes.changesJson) == "string" then + local ok2, inner = pcall(cjson.decode, changes.changesJson) + if ok2 then payload = inner else return json_err("Invalid JSON payload") end + else + payload = changes + end + else + payload = {} + end + + if payload == nil then payload = {} end + + if type(payload) ~= "table" then + logger.warn("ApplySettingsChanges: payload is not a table: " .. tostring(payload)) + return json_err("Invalid payload format") + end + + logger.log("ApplySettingsChanges payload: " .. (pcall(cjson.encode, payload) and cjson.encode(payload) or "?")) + + local ok, res = pcall(settings_manager.apply_settings_changes, payload) + if not ok then + logger.warn("ApplySettingsChanges failed: " .. tostring(res)) + return json_err(res) + end + return json_ok(res) +end + +function GetAvailableLocales() + local ok, locs = pcall(settings_manager.get_available_locales) + if not ok then return json_err(locs) end + return json_ok({ success = true, locales = locs }) +end + +function GetTranslations(language) + -- Handle both {language="en"} table and plain string argument + if type(language) == "table" then + language = language.language or language.lang + end + language = tostring(language or locales_mod.DEFAULT_LOCALE) + + local ok, strings = pcall(function() + return locales_mod.get_locale_manager():get_locale_strings(language) + end) + if not ok then + logger.warn("GetTranslations failed: " .. tostring(strings)) + return json_err(strings) + end + + -- Frontend expects: { success, strings:{...}, language, locales:[...] } + local ok2, locs = pcall(settings_manager.get_available_locales) + return json_ok({ + success = true, + strings = strings or {}, + language = language, + locales = ok2 and locs or {} + }) +end + +function GetAvailableThemes() + return json_ok({ success = true, themes = {} }) +end + +-- ── Return lifecycle table ──────────────────────────────────────────────────── + +return { + on_load = on_load, + on_unload = on_unload, + on_frontend_loaded = on_frontend_loaded, +} diff --git a/backend/paths.lua b/backend/paths.lua new file mode 100644 index 0000000..12fc205 --- /dev/null +++ b/backend/paths.lua @@ -0,0 +1,53 @@ +local fs = require("fs") +local m_utils = require("utils") + +local paths = {} + +-- Fallback logic for when Millennium hasn't set the env var +local function get_current_file_path() + local info = debug.getinfo(2, "S") + if info and info.source and info.source:sub(1, 1) == "@" then + return info.source:sub(2) + end + return fs.current_path() +end + +local backend_dir = nil +local plugin_dir = nil + +function paths.get_backend_dir() + if backend_dir then return backend_dir end + + local be_path = m_utils.get_backend_path() + if be_path and be_path ~= "" then + backend_dir = fs.absolute(be_path) + return backend_dir + end + + local file_path = get_current_file_path() + local dir = file_path:match("(.*[/\\])") + if dir then + dir = dir:sub(1, -2) + else + dir = "." + end + backend_dir = fs.absolute(dir) + return backend_dir +end + +function paths.get_plugin_dir() + if plugin_dir then return plugin_dir end + local bdir = paths.get_backend_dir() + plugin_dir = fs.absolute(fs.join(bdir, "..")) + return plugin_dir +end + +function paths.backend_path(filename) + return fs.join(paths.get_backend_dir(), filename) +end + +function paths.public_path(filename) + return fs.join(paths.get_plugin_dir(), "public", filename) +end + +return paths diff --git a/backend/plugin_logger.lua b/backend/plugin_logger.lua new file mode 100644 index 0000000..b3387f6 --- /dev/null +++ b/backend/plugin_logger.lua @@ -0,0 +1,21 @@ +local m_logger = require("logger") + +local logger = {} + +function logger.log(msg) + m_logger:info(tostring(msg)) +end + +function logger.warn(msg) + m_logger:warn(tostring(msg)) +end + +function logger.error(msg) + m_logger:error(tostring(msg)) +end + +function logger.info(msg) + m_logger:info(tostring(msg)) +end + +return logger diff --git a/backend/plugin_utils.lua b/backend/plugin_utils.lua new file mode 100644 index 0000000..1ec1296 --- /dev/null +++ b/backend/plugin_utils.lua @@ -0,0 +1,105 @@ +local m_utils = require("utils") +local fs = require("fs") +local cjson = require("json") +local paths = require("paths") +local logger = require("plugin_logger") + +local utils = {} + +function utils.read_text(path) + return m_utils.read_file(path) or "" +end + +function utils.write_text(path, text) + m_utils.write_file(path, text) +end + +function utils.read_json(path) + local content = utils.read_text(path) + if content == "" then return {} end + local success, data = pcall(cjson.decode, content) + if success then + return data + end + return {} +end + +function utils.decode_json(text) + if not text or text == "" then return {} end + local success, data = pcall(cjson.decode, text) + if success then return data else return {} end +end + +function utils.write_json(path, data) + local success, content = pcall(cjson.encode, data) + if not success then + logger.warn("write_json failed to encode JSON for " .. tostring(path)) + return false + end + m_utils.write_file(path, content) + return true +end + +function utils.count_apis(text) + if not text or text == "" then return 0 end + local success, data = pcall(cjson.decode, text) + if success and type(data) == "table" and type(data.api_list) == "table" then + local count = 0 + for _ in pairs(data.api_list) do count = count + 1 end + return count + end + -- Fallback simple string match count for '"name"' + local _, count = text:gsub('"name"', '"name"') + return count +end + +function utils.normalize_manifest_text(text) + local content = text or "" + -- remove whitespace + content = content:match("^%s*(.-)%s*$") + if content == "" then return content end + + content = content:gsub(",%s*%]", "]") + content = content:gsub(",%s*}%s*$", "}") + + if content:sub(1, 10) == '"api_list"' or content:sub(1, 10) == "'api_list'" or content:sub(1, 8) == "api_list" then + if content:sub(1, 1) ~= "{" then + content = "{" .. content + end + if content:sub(-1) ~= "}" then + -- remove trailing commas + content = content:gsub(",$", "") .. "}" + end + end + + local success = pcall(cjson.decode, content) + if success then + return content + end + return text +end + +function utils.parse_version(version) + local parts = {} + for part in string.gmatch(tostring(version), "%d+") do + table.insert(parts, tonumber(part)) + end + if #parts == 0 then return {0} end + return parts +end + +function utils.get_plugin_version() + local plugin_json_path = fs.join(paths.get_plugin_dir(), "plugin.json") + local data = utils.read_json(plugin_json_path) + return tostring(data.version or "0") +end + +function utils.ensure_temp_download_dir() + local root = paths.backend_path("temp_dl") + if not fs.exists(root) then + fs.create_directories(root) + end + return root +end + +return utils diff --git a/backend/settings/manager.lua b/backend/settings/manager.lua new file mode 100644 index 0000000..2770819 --- /dev/null +++ b/backend/settings/manager.lua @@ -0,0 +1,252 @@ +local fs = require("fs") +local cjson = require("json") +local paths = require("paths") +local logger = require("plugin_logger") +local utils = require("plugin_utils") +local locales = require("locales.manager") +local options = require("settings.options") + +local SCHEMA_VERSION = 1 +local SETTINGS_FILE = paths.backend_path("data/settings.json") + +local _SETTINGS_CACHE = nil + +-- Simple placeholder since we can't easily read registry in Millennium Lua securely +local function _detect_steam_language() + return "en" +end + +local function _available_locale_codes() + local manager = locales.get_locale_manager() + local avail = manager:available_locales() + if not avail or #avail == 0 then + return {{code = locales.DEFAULT_LOCALE, name = "English", nativeName = "English"}} + end + return avail +end + +local function _ensure_language_valid(values) + local general = values.general + local changed = false + if type(general) ~= "table" then + general = {} + values.general = general + changed = true + end + + local available_codes = {} + for _, loc in ipairs(_available_locale_codes()) do + available_codes[loc.code] = true + end + available_codes[locales.DEFAULT_LOCALE] = true + + local current_language = general.language + if not available_codes[current_language] then + general.language = locales.DEFAULT_LOCALE + changed = true + end + return changed +end + +local function _available_theme_files() + local themes = {} + + local themes_json_path = fs.join(paths.get_plugin_dir(), "public", "themes", "themes.json") + if fs.exists(themes_json_path) then + local success, data = pcall(cjson.decode, utils.read_text(themes_json_path)) + if success and type(data) == "table" then + for _, item in ipairs(data) do + if type(item) == "table" and item.value then + table.insert(themes, {value = tostring(item.value), label = tostring(item.label or item.value)}) + end + end + end + end + + if #themes == 0 then + local themes_dir = fs.join(paths.get_plugin_dir(), "public", "themes") + if fs.exists(themes_dir) then + local success, files = pcall(fs.list, themes_dir) + if success and files then + for _, entry in ipairs(files) do + local filename = entry.name + if filename:match("%.css$") then + local theme_name = filename:sub(1, -5) + local display_name = theme_name:gsub("^%l", string.upper) + table.insert(themes, {value = theme_name, label = display_name}) + end + end + end + end + end + + if #themes == 0 then + themes = { + {value = "original", label = "Original"}, + {value = "dark", label = "Dark"}, + {value = "light", label = "Light"} + } + end + + return themes +end + +local function _inject_locale_choices(schema) + local locale_choices = {} + for _, loc in ipairs(_available_locale_codes()) do + table.insert(locale_choices, { + value = loc.code, + label = loc.nativeName or loc.name or loc.code + }) + end + local theme_choices = _available_theme_files() + + for _, group in ipairs(schema) do + if group.key == "general" then + for _, opt in ipairs(group.options or {}) do + if opt.key == "language" then + opt.choices = locale_choices + opt.metadata = opt.metadata or {} + opt.metadata.dynamicChoices = "locales" + elseif opt.key == "theme" then + opt.choices = theme_choices + opt.metadata = opt.metadata or {} + opt.metadata.dynamicChoices = "themes" + end + end + end + end + return schema +end + +local function _load_settings_file() + if not fs.exists(SETTINGS_FILE) then return {} end + local data = utils.read_json(SETTINGS_FILE) + return data or {} +end + +local function _write_settings_file(data) + local dir = fs.parent_path(SETTINGS_FILE) + if not fs.exists(dir) then fs.create_directories(dir) end + utils.write_json(SETTINGS_FILE, data) +end + +local function _persist_values(values) + local payload = {version = SCHEMA_VERSION, values = values} + _write_settings_file(payload) + _SETTINGS_CACHE = utils.read_json(SETTINGS_FILE).values or values +end + +local manager = {} + +function manager._load_settings_cache() + if _SETTINGS_CACHE then return _SETTINGS_CACHE end + local raw_data = _load_settings_file() + local version = raw_data.version or 0 + local values = raw_data.values + + local first_launch = (values == nil) + local merged_values = options.merge_defaults_with_values(values) + + if first_launch then + local detected = _detect_steam_language() + if detected then + merged_values.general = merged_values.general or {} + merged_values.general.language = detected + end + end + + if version ~= SCHEMA_VERSION or type(values) ~= "table" then + _write_settings_file({version = SCHEMA_VERSION, values = merged_values}) + end + + _SETTINGS_CACHE = merged_values + return merged_values +end + +function manager._get_values_locked() + local values = manager._load_settings_cache() + if type(values) ~= "table" then values = {} end + if _ensure_language_valid(values) then + _persist_values(values) + end + return values +end + +function manager.init_settings() + manager._load_settings_cache() +end + +function manager.get_settings_state() + local values = manager._get_values_locked() + return { + version = SCHEMA_VERSION, + values = values + } +end + +function manager.get_current_language() + local values = manager._get_values_locked() + local general = values.general or {} + if general.useSteamLanguage ~= false then + local detected = _detect_steam_language() + if detected then return detected end + end + return tostring(general.language or locales.DEFAULT_LOCALE) +end + +function manager.get_morrenus_api_key() + local values = manager._get_values_locked() + local general = values.general or {} + return tostring(general.morrenusApiKey or "") +end + +function manager.get_available_locales() + return _available_locale_codes() +end + +function manager.get_settings_payload() + local values = manager._get_values_locked() + local schema = _inject_locale_choices(options.get_settings_schema()) + local avail_locales = manager.get_available_locales() + local language = manager.get_current_language() + local translations = locales.get_locale_manager():get_locale_strings(language) + + return { + version = SCHEMA_VERSION, + values = values, + schema = schema, + language = language, + locales = avail_locales, + translations = translations + } +end + +function manager.apply_settings_changes(changes) + if type(changes) ~= "table" then return {success = false, error = "Invalid payload"} end + local current = manager._get_values_locked() + local updated = options.merge_defaults_with_values(current) + + for group_key, options_changes in pairs(changes) do + if type(options_changes) == "table" and updated[group_key] then + for option_key, value in pairs(options_changes) do + updated[group_key][option_key] = value + end + end + end + + _ensure_language_valid(updated) + _persist_values(updated) + + local language = updated.general and updated.general.language or locales.DEFAULT_LOCALE + local translations = locales.get_locale_manager():get_locale_strings(language) + + return { + success = true, + values = updated, + language = language, + translations = translations + } +end + +return manager diff --git a/backend/settings/options.lua b/backend/settings/options.lua new file mode 100644 index 0000000..9c4bd11 --- /dev/null +++ b/backend/settings/options.lua @@ -0,0 +1,116 @@ +local options = {} + +options.SETTINGS_GROUPS = { + { + key = "general", + label = "General", + description = "Global LuaTools preferences.", + options = { + { + key = "useSteamLanguage", + label = "Use Steam Language", + option_type = "toggle", + description = "Use the Steam client's language for LuaTools.", + default = true, + metadata = {yesLabel = "Yes", noLabel = "No"} + }, + { + key = "language", + label = "Language", + option_type = "select", + description = "Choose the language used by LuaTools.", + default = "en", + metadata = {dynamicChoices = "locales"} + }, + { + key = "donateKeys", + label = "Donate Keys", + option_type = "toggle", + description = "Allow LuaTools to donate spare Steam keys. (placeholder option)", + default = true, + metadata = {yesLabel = "Yes", noLabel = "No"} + }, + { + key = "theme", + label = "Theme", + option_type = "select", + description = "Choose the color theme for LuaTools interface.", + default = "original", + metadata = {dynamicChoices = "themes"} + }, + { + key = "fastDownload", + label = "Fast Download", + option_type = "toggle", + description = "Automatically choose the first available source when adding a game.", + default = true, + metadata = {yesLabel = "Yes", noLabel = "No"} + }, + { + key = "morrenusApiKey", + label = "Morrenus API Key", + option_type = "text", + description = "API Key required to use Sadie Source. Get from hubcapmanifest.com", + default = "", + metadata = {placeholder = "Enter your API key..."} + } + } + } +} + +function options.get_settings_schema() + local schema = {} + for _, group in ipairs(options.SETTINGS_GROUPS) do + local group_options = {} + for _, opt in ipairs(group.options) do + table.insert(group_options, { + key = opt.key, + label = opt.label, + type = opt.option_type, + description = opt.description, + default = opt.default, + choices = opt.choices or {}, + requiresRestart = opt.requires_restart or false, + metadata = opt.metadata or {} + }) + end + table.insert(schema, { + key = group.key, + label = group.label, + description = group.description, + options = group_options + }) + end + return schema +end + +function options.get_default_settings_values() + local defaults = {} + for _, group in ipairs(options.SETTINGS_GROUPS) do + local group_defaults = {} + for _, opt in ipairs(group.options) do + group_defaults[opt.key] = opt.default + end + defaults[group.key] = group_defaults + end + return defaults +end + +function options.merge_defaults_with_values(values) + local merged = type(values) == "table" and values or {} + local defaults = options.get_default_settings_values() + + for group_key, group_defaults in pairs(defaults) do + local existing_group = merged[group_key] + if type(existing_group) ~= "table" then + existing_group = {} + end + local merged_group = {} + for k, v in pairs(group_defaults) do merged_group[k] = v end + for k, v in pairs(existing_group) do merged_group[k] = v end + merged[group_key] = merged_group + end + return merged +end + +return options diff --git a/backend/steam_utils.lua b/backend/steam_utils.lua new file mode 100644 index 0000000..3062a67 --- /dev/null +++ b/backend/steam_utils.lua @@ -0,0 +1,106 @@ +local m_utils = require("utils") +local millennium = require("millennium") +local fs = require("fs") +local logger = require("plugin_logger") +local paths = require("paths") + +local steam_utils = {} + +local STEAM_INSTALL_PATH = nil + +function steam_utils.detect_steam_install_path() + if STEAM_INSTALL_PATH then return STEAM_INSTALL_PATH end + local success, path = pcall(millennium.steam_path) + if success and path then + STEAM_INSTALL_PATH = path + logger.log("LuaTools: Steam install path set to " .. tostring(STEAM_INSTALL_PATH)) + return STEAM_INSTALL_PATH + end + return "" +end + +function steam_utils.has_lua_for_app(appid) + local base_path = steam_utils.detect_steam_install_path() + if not base_path or base_path == "" then return false end + + local stplug_path = fs.join(base_path, "config", "stplug-in") + local lua_file = fs.join(stplug_path, tostring(appid) .. ".lua") + local disabled_file = fs.join(stplug_path, tostring(appid) .. ".lua.disabled") + + return fs.exists(lua_file) or fs.exists(disabled_file) +end + +function steam_utils.get_game_install_path_response(appid) + appid = tostring(appid) + local steam_path = steam_utils.detect_steam_install_path() + if not steam_path or steam_path == "" then + return { success = false, error = "Could not find Steam installation path" } + end + + local library_vdf_path = fs.join(steam_path, "config", "libraryfolders.vdf") + if not fs.exists(library_vdf_path) then + return { success = false, error = "Could not find libraryfolders.vdf" } + end + + local vdf_content = m_utils.read_file(library_vdf_path) + if not vdf_content then + return { success = false, error = "Failed to read libraryfolders.vdf" } + end + + local all_library_paths = {} + for path in vdf_content:gmatch('"path"%s+"([^"]+)"') do + path = path:gsub("\\\\", "\\") + table.insert(all_library_paths, path) + end + + local library_path = nil + local appmanifest_path = nil + + for _, lib_path in ipairs(all_library_paths) do + local candidate = fs.join(lib_path, "steamapps", "appmanifest_" .. appid .. ".acf") + if fs.exists(candidate) then + library_path = lib_path + appmanifest_path = candidate + break + end + end + + if not library_path or not appmanifest_path then + return { success = false, error = "menu.error.notInstalled" } + end + + local manifest_content = m_utils.read_file(appmanifest_path) + if not manifest_content then + return { success = false, error = "Failed to parse appmanifest" } + end + + local install_dir = manifest_content:match('"installdir"%s+"([^"]+)"') + if not install_dir then + return { success = false, error = "Install directory not found" } + end + + local full_install_path = fs.join(library_path, "steamapps", "common", install_dir) + if not fs.exists(full_install_path) then + return { success = false, error = "Game directory not found" } + end + + return { + success = true, + installPath = full_install_path, + installDir = install_dir, + libraryPath = library_path, + path = full_install_path + } +end + +function steam_utils.open_game_folder(path) + if not path or path == "" or not fs.exists(path) then return false end + + -- In Windows, explorer accepts backslashes + path = path:gsub("/", "\\") + local cmd = 'explorer "' .. path .. '"' + m_utils.exec(cmd) + return true +end + +return steam_utils diff --git a/plugin.json b/plugin.json index b0087ad..0edac9a 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,8 @@ "name": "luatools", "common_name": "LuaTools", "description": "LuaTools Steam Plugin!", - "version": "7.2.2", + "version": "8.0.0", + "backendType": "lua", "include": [ "public" ] From de5b0eafb42a3743fbaffee7a9df38a96af42f1b Mon Sep 17 00:00:00 2001 From: piq Date: Mon, 1 Jun 2026 15:25:17 -0300 Subject: [PATCH 02/10] lua polish --- backend/api.json | 62 +- backend/api_manifest.lua | 41 ++ backend/api_manifest.py | 202 ------ backend/auto_update.py | 352 ----------- backend/config.lua | 2 +- backend/config.py | 34 - backend/donate_keys.py | 283 --------- backend/downloads.lua | 55 +- backend/downloads.py | 1200 ------------------------------------ backend/fixes.py | 715 --------------------- backend/http_client.lua | 12 + backend/http_client.py | 47 -- backend/locales/pt-BR.json | 456 +++++++------- backend/logger.py | 19 - backend/main.lua | 99 ++- backend/main.py | 526 ---------------- backend/paths.py | 25 - backend/plugin_utils.lua | 5 + backend/steam_utils.py | 250 -------- backend/update.json | 2 +- backend/utils.py | 121 ---- public/luatools.js | 243 +++++++- requirements.txt | 1 - 23 files changed, 673 insertions(+), 4079 deletions(-) delete mode 100644 backend/api_manifest.py delete mode 100644 backend/auto_update.py delete mode 100644 backend/config.py delete mode 100644 backend/donate_keys.py delete mode 100644 backend/downloads.py delete mode 100644 backend/fixes.py delete mode 100644 backend/http_client.py delete mode 100644 backend/logger.py delete mode 100644 backend/main.py delete mode 100644 backend/paths.py delete mode 100644 backend/steam_utils.py delete mode 100644 backend/utils.py delete mode 100644 requirements.txt diff --git a/backend/api.json b/backend/api.json index acc3073..8e01f8e 100644 --- a/backend/api.json +++ b/backend/api.json @@ -1,30 +1,32 @@ -{"api_list": [ - { - "name": "Morrenus", - "url": "https://hubcapmanifest.com/api/v1/manifest/?api_key=", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - }, - { - "name": "Ryuu", - "url": "http://167.235.229.108/", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - }, - { - "name": "TwentyTwo Cloud", - "url": "https://api.twentytwocloud.com/download?appid=", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - }, - { - "name": "Sushi", - "url": "https://raw.githubusercontent.com/sushi-dev55-alt/sushitools-games-repo-alt/refs/heads/main/.zip", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - } - ]} \ No newline at end of file +{ + "api_list": [ + { + "name": "Morrenus", + "url": "https://hubcapmanifest.com/api/v1/manifest/?api_key=", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + }, + { + "name": "Ryuu", + "url": "http://167.235.229.108/", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + }, + { + "name": "TwentyTwo Cloud", + "url": "https://api.twentytwocloud.com/download?appid=", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + }, + { + "name": "Sushi", + "url": "https://raw.githubusercontent.com/sushi-dev55-alt/sushitools-games-repo-alt/refs/heads/main/.zip", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + } + ] +} diff --git a/backend/api_manifest.lua b/backend/api_manifest.lua index cad8ceb..e3b908d 100644 --- a/backend/api_manifest.lua +++ b/backend/api_manifest.lua @@ -136,6 +136,47 @@ function api_manifest.load_api_manifest() return apis end +function api_manifest.add_custom_api(payload) + if not payload or type(payload.name) ~= "string" or type(payload.url) ~= "string" then + return { success = false, error = "Invalid payload: name and url are required" } + end + + local path = paths.backend_path(config.API_JSON_FILE) + local text = "" + if fs.exists(path) then + text = utils.read_text(path) + end + + local data = { api_list = {} } + if text ~= "" then + local ok, parsed = pcall(utils.decode_json, text) + if ok and type(parsed) == "table" and type(parsed.api_list) == "table" then + data = parsed + end + end + + local new_api = { + name = payload.name, + url = payload.url, + success_code = payload.success_code or 200, + unavailable_code = payload.unavailable_code or 404, + enabled = true + } + + if payload.api_key and payload.api_key ~= "" then + new_api.api_key = payload.api_key + end + + table.insert(data.api_list, new_api) + + local new_text = utils.encode_json(data) + local formatted = utils.normalize_manifest_text(new_text) + utils.write_text(path, formatted) + + logger.log("LuaTools: Added custom API: " .. payload.name) + return { success = true } +end + function api_manifest.get_api_list() local success, apis = pcall(api_manifest.load_api_manifest) if not success then diff --git a/backend/api_manifest.py b/backend/api_manifest.py deleted file mode 100644 index bc7d9b0..0000000 --- a/backend/api_manifest.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Management of the LuaTools API manifest (free API list).""" - -from __future__ import annotations - -import json -import os -from typing import Any, Dict, List - -from config import ( - API_JSON_FILE, - API_MANIFEST_PROXY_URL, - API_MANIFEST_URL, - HTTP_PROXY_TIMEOUT_SECONDS, -) -from http_client import ensure_http_client, get_http_client -from logger import logger -from utils import ( - backend_path, - count_apis, - normalize_manifest_text, - read_text, - write_text, -) -from settings.manager import get_morrenus_api_key - -_APIS_INIT_DONE = False -_INIT_APIS_LAST_MESSAGE = "" - - -def init_apis(content_script_query: str = "") -> str: - """Initialise the free API manifest if it has not been loaded yet.""" - global _APIS_INIT_DONE, _INIT_APIS_LAST_MESSAGE - logger.log("InitApis: invoked") - if _APIS_INIT_DONE: - logger.log("InitApis: already completed this session, skipping") - return json.dumps({"success": True, "message": _INIT_APIS_LAST_MESSAGE}) - - client = ensure_http_client("InitApis") - api_json_path = backend_path(API_JSON_FILE) - message = "" - - if os.path.exists(api_json_path): - logger.log(f"InitApis: Local file exists -> {api_json_path}; skipping remote fetch") - else: - logger.log(f"InitApis: Local file not found -> {api_json_path}") - manifest_text = "" - try: - # Try primary URL first - try: - logger.log(f"InitApis: Fetching manifest from {API_MANIFEST_URL}") - resp = client.get(API_MANIFEST_URL) - logger.log(f"InitApis: Manifest response: status={resp.status_code}") - resp.raise_for_status() - manifest_text = resp.text - logger.log( - f"InitApis: Fetched manifest, status={resp.status_code}, length={len(manifest_text)}" - ) - except Exception as primary_err: - logger.warn(f"InitApis: Primary URL failed ({primary_err}), trying proxy...") - try: - logger.log(f"InitApis: Fetching manifest from proxy {API_MANIFEST_PROXY_URL}") - resp = client.get(API_MANIFEST_PROXY_URL, timeout=HTTP_PROXY_TIMEOUT_SECONDS) - logger.log(f"InitApis: Proxy manifest response: status={resp.status_code}") - resp.raise_for_status() - manifest_text = resp.text - logger.log( - f"InitApis: Fetched manifest from proxy, status={resp.status_code}, length={len(manifest_text)}" - ) - except Exception as proxy_err: - logger.warn(f"InitApis: Proxy also failed: {proxy_err}") - raise primary_err - except Exception as fetch_err: - logger.warn(f"InitApis: Failed to fetch free API manifest: {fetch_err}") - - normalized = normalize_manifest_text(manifest_text) if manifest_text else "" - if normalized: - write_text(api_json_path, normalized) - count = count_apis(normalized) - message = f"No API's Configured, Loaded {count} Free Ones :D" - logger.log(f"InitApis: Wrote new api.json with {count} entries") - else: - message = "No API's Configured and failed to load free ones" - logger.warn("InitApis: Manifest empty, nothing written") - - _APIS_INIT_DONE = True - _INIT_APIS_LAST_MESSAGE = message - logger.log(f'InitApis: completed message="{message}"') - return json.dumps({"success": True, "message": message}) - - -def get_init_apis_message(content_script_query: str = "") -> str: - """Return and clear the last InitApis message.""" - global _INIT_APIS_LAST_MESSAGE - logger.log("InitApis: GetInitApisMessage invoked") - msg = _INIT_APIS_LAST_MESSAGE or "" - if msg: - logger.log(f"InitApis: delivering queued message -> {msg}") - _INIT_APIS_LAST_MESSAGE = "" - return json.dumps({"success": True, "message": msg}) - - -def store_last_message(message: str) -> None: - """Allow other subsystems to push a message shown on next InitApis poll.""" - global _INIT_APIS_LAST_MESSAGE - _INIT_APIS_LAST_MESSAGE = message or "" - - -def fetch_free_apis_now(content_script_query: str = "") -> str: - """Force refresh of the free API manifest.""" - client = ensure_http_client("LuaTools: FetchFreeApisNow") - try: - logger.log("LuaTools: FetchFreeApisNow invoked") - manifest_text = "" - - try: - logger.log(f"LuaTools: Fetching manifest from {API_MANIFEST_URL}") - resp = client.get(API_MANIFEST_URL, follow_redirects=True) - logger.log(f"LuaTools: Manifest response: status={resp.status_code}") - resp.raise_for_status() - manifest_text = resp.text - logger.log("LuaTools: Fetched manifest from primary URL") - except Exception as primary_err: - logger.warn(f"LuaTools: Primary manifest URL failed ({primary_err}), trying proxy...") - try: - logger.log(f"LuaTools: Fetching manifest from proxy {API_MANIFEST_PROXY_URL}") - resp = client.get( - API_MANIFEST_PROXY_URL, - follow_redirects=True, - timeout=HTTP_PROXY_TIMEOUT_SECONDS, - ) - logger.log(f"LuaTools: Proxy manifest response: status={resp.status_code}") - resp.raise_for_status() - manifest_text = resp.text - logger.log("LuaTools: Fetched manifest from proxy URL") - except Exception as proxy_err: - logger.warn(f"LuaTools: Proxy manifest URL also failed: {proxy_err}") - return json.dumps( - {"success": False, "error": f"Both URLs failed: {primary_err}, {proxy_err}"} - ) - - normalized = normalize_manifest_text(manifest_text) if manifest_text else "" - if not normalized: - return json.dumps({"success": False, "error": "Empty manifest"}) - - write_text(backend_path(API_JSON_FILE), normalized) - try: - data = json.loads(normalized) - count = len([entry for entry in data.get("api_list", [])]) - except Exception: - count = normalized.count('"name"') - - return json.dumps({"success": True, "count": count}) - except Exception as exc: - logger.warn(f"LuaTools: FetchFreeApisNow failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def load_api_manifest() -> List[Dict[str, Any]]: - """Return the list of enabled APIs from api.json.""" - path = backend_path(API_JSON_FILE) - text = read_text(path) - - normalized = normalize_manifest_text(text) - if normalized and normalized != text: - try: - write_text(path, normalized) - logger.log("LuaTools: Normalized api.json") - except Exception: - pass - text = normalized - - try: - data = json.loads(text or "{}") - apis = data.get("api_list", []) - return [api for api in apis if api.get("enabled", False)] - except Exception as exc: - logger.error(f"LuaTools: Failed to parse api.json: {exc}") - return [] - - -def get_api_list(content_script_query: str = "") -> str: - """Return the list of enabled API names for the frontend. - - APIs requiring are hidden if the Morrenus API key is not configured. - """ - try: - apis = load_api_manifest() - morrenus_api_key = get_morrenus_api_key() - - api_names = [] - for i, api in enumerate(apis): - url = api.get("url", "") - # Skip APIs requiring Morrenus API key if key is not set - if "" in url and not morrenus_api_key: - continue - api_names.append({"name": api.get("name", "Unknown"), "index": i}) - - return json.dumps({"success": True, "apis": api_names}) - except Exception as exc: - logger.error(f"LuaTools: Failed to get API list: {exc}") - return json.dumps({"success": False, "error": str(exc), "apis": []}) - diff --git a/backend/auto_update.py b/backend/auto_update.py deleted file mode 100644 index b4b8abe..0000000 --- a/backend/auto_update.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Auto-update utilities for the LuaTools backend.""" - -from __future__ import annotations - -import json -import os -import subprocess -import threading -import time -import zipfile -from typing import Any, Dict, Optional - -from api_manifest import store_last_message -from config import ( - UPDATE_CHECK_INTERVAL_SECONDS, - UPDATE_CONFIG_FILE, - UPDATE_PENDING_INFO, - UPDATE_PENDING_ZIP, -) -from http_client import ensure_http_client, get_http_client -from logger import logger -from paths import backend_path, get_plugin_dir -from steam_utils import detect_steam_install_path -from utils import ( - get_plugin_version, - parse_version, - read_json, - write_json, -) - -_UPDATE_CHECK_THREAD: Optional[threading.Thread] = None - - -def apply_pending_update_if_any() -> str: - """Extract a pending update zip if present. Returns a message or empty string.""" - pending_zip = backend_path(UPDATE_PENDING_ZIP) - pending_info = backend_path(UPDATE_PENDING_INFO) - if not os.path.exists(pending_zip): - return "" - - try: - logger.log(f"AutoUpdate: Applying pending update from {pending_zip}") - with zipfile.ZipFile(pending_zip, "r") as archive: - archive.extractall(get_plugin_dir()) - try: - os.remove(pending_zip) - except Exception: - pass - - info = read_json(pending_info) - try: - os.remove(pending_info) - except Exception: - pass - - new_version = str(info.get("version", "")) if isinstance(info, dict) else "" - if new_version: - return f"LuaTools updated to {new_version}. Please restart Steam." - return "LuaTools update applied. Please restart Steam." - except Exception as exc: - logger.warn(f"AutoUpdate: Failed to apply pending update: {exc}") - return "" - - -def _fetch_github_latest(cfg: Dict[str, Any]) -> Dict[str, Any]: - owner = str(cfg.get("owner", "")).strip() - repo = str(cfg.get("repo", "")).strip() - asset_name = str(cfg.get("asset_name", "ltsteamplugin.zip")).strip() - tag = str(cfg.get("tag", "")).strip() - tag_prefix = str(cfg.get("tag_prefix", "")).strip() - token = str(cfg.get("token", "")).strip() - - if not owner or not repo: - logger.warn("AutoUpdate: github config missing owner or repo") - return {} - - client = ensure_http_client("AutoUpdate: GitHub") - endpoint = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - if tag: - endpoint = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}" - - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": "LuaTools-Updater", - } - if token: - headers["Authorization"] = f"Bearer {token}" - - data: Optional[Dict[str, Any]] = None - tag_name = "" - - # Primary GitHub API - try: - logger.log(f"AutoUpdate: Fetching GitHub release from {endpoint}") - resp = client.get(endpoint, headers=headers, follow_redirects=True) - logger.log(f"AutoUpdate: GitHub API response: status={resp.status_code}") - resp.raise_for_status() - data = resp.json() - tag_name = str(data.get("tag_name", "")).strip() - logger.log("AutoUpdate: GitHub API request successful") - except Exception as api_err: - logger.warn(f"AutoUpdate: GitHub API failed ({api_err}), trying proxy...") - try: - proxy_url = "https://luatools.vercel.app/api/github-latest" - logger.log(f"AutoUpdate: Fetching GitHub release from proxy {proxy_url}") - resp = client.get(proxy_url, follow_redirects=True, timeout=15) - logger.log(f"AutoUpdate: Proxy GitHub API response: status={resp.status_code}") - resp.raise_for_status() - data = resp.json() - tag_name = str(data.get("tag_name", "")).strip() - logger.log("AutoUpdate: Proxy GitHub request successful") - except Exception as proxy_err: - logger.warn(f"AutoUpdate: Proxy request failed ({proxy_err})") - return {} - - if not data: - return {} - - version = tag_name or str(data.get("name", "")).strip() - if tag_prefix and version.startswith(tag_prefix): - version = version[len(tag_prefix) :] - - zip_url = "" - - try: - assets = data.get("assets", []) - if isinstance(assets, list): - for asset in assets: - a_name = str(asset.get("name", "")).strip() - if a_name == asset_name: - zip_url = str(asset.get("browser_download_url", "")).strip() - break - except Exception: - pass - - if not zip_url and tag_name: - zip_url = f"https://luatools.vercel.app/api/get-plugin/{tag_name}" - logger.log(f"AutoUpdate: Using proxy download URL: {zip_url}") - - if not zip_url: - logger.warn("AutoUpdate: No download URL found") - return {} - - return {"version": version, "zip_url": zip_url} - - -def _download_and_extract_update(zip_url: str, pending_zip: str) -> bool: - client = ensure_http_client("AutoUpdate: download") - try: - logger.log(f"AutoUpdate: Downloading {zip_url} -> {pending_zip}") - with client.stream("GET", zip_url, follow_redirects=True) as response: - logger.log(f"AutoUpdate: Update download response: status={response.status_code}") - response.raise_for_status() - with open(pending_zip, "wb") as output: - for chunk in response.iter_bytes(): - if chunk: - output.write(chunk) - return True - except Exception as exc: - logger.warn(f"AutoUpdate: Failed to download update: {exc}") - return False - - -def check_for_update_once() -> str: - """Check remote manifest (if configured) and download a newer version. - Returns a message for the user if an update was downloaded/applied.""" - client = ensure_http_client("AutoUpdate") - cfg_path = backend_path(UPDATE_CONFIG_FILE) - cfg = read_json(cfg_path) - - latest_version = "" - zip_url = "" - - gh_cfg = cfg.get("github") - if isinstance(gh_cfg, dict): - manifest = _fetch_github_latest(gh_cfg) - latest_version = str(manifest.get("version", "")).strip() - zip_url = str(manifest.get("zip_url", "")).strip() - else: - manifest_url = str(cfg.get("manifest_url", "")).strip() - if not manifest_url: - return "" - try: - logger.log(f"AutoUpdate: Fetching manifest {manifest_url}") - resp = client.get(manifest_url, follow_redirects=True) - resp.raise_for_status() - manifest = resp.json() - latest_version = str(manifest.get("version", "")).strip() - zip_url = str(manifest.get("zip_url", "")).strip() - except Exception as exc: - logger.warn(f"AutoUpdate: Failed to fetch manifest: {exc}") - return "" - - if not latest_version or not zip_url: - logger.warn("AutoUpdate: Manifest missing version or zip_url") - return "" - - current_version = get_plugin_version() - if parse_version(latest_version) <= parse_version(current_version): - logger.log( - f"AutoUpdate: Up-to-date (current {current_version}, latest {latest_version})" - ) - return "" - - pending_zip = backend_path(UPDATE_PENDING_ZIP) - pending_info = backend_path(UPDATE_PENDING_INFO) - - if not _download_and_extract_update(zip_url, pending_zip): - return "" - - # Attempt to extract immediately - try: - with zipfile.ZipFile(pending_zip, "r") as archive: - archive.extractall(get_plugin_dir()) - try: - os.remove(pending_zip) - except Exception: - pass - logger.log("AutoUpdate: Update extracted; will take effect after restart") - return f"LuaTools updated to {latest_version}. Please restart Steam." - except Exception as extract_err: - logger.warn( - f"AutoUpdate: Extraction failed, will apply on next start: {extract_err}" - ) - write_json(pending_info, {"version": latest_version, "zip_url": zip_url}) - logger.log("AutoUpdate: Update downloaded and queued for apply on next start") - return f"Update {latest_version} downloaded. Restart Steam to apply." - - -def _periodic_update_check_worker(): - while True: - try: - time.sleep(UPDATE_CHECK_INTERVAL_SECONDS) - logger.log("AutoUpdate: Running periodic background check...") - message = check_for_update_once() - if message: - store_last_message(message) - logger.log(f"AutoUpdate: Periodic check found update: {message}") - except Exception as exc: - logger.warn(f"AutoUpdate: Periodic check failed: {exc}") - - -def _start_periodic_update_checks(): - global _UPDATE_CHECK_THREAD - if _UPDATE_CHECK_THREAD is None or not _UPDATE_CHECK_THREAD.is_alive(): - _UPDATE_CHECK_THREAD = threading.Thread( - target=_periodic_update_check_worker, daemon=True - ) - _UPDATE_CHECK_THREAD.start() - logger.log( - f"AutoUpdate: Started periodic update check thread (every {UPDATE_CHECK_INTERVAL_SECONDS / 3600} hours)" - ) - - -def _check_and_donate_keys() -> None: - """Check donateKeys setting and send keys if enabled.""" - try: - from donate_keys import extract_valid_decryption_keys, send_donation_keys - from settings.manager import _get_values_locked - - values = _get_values_locked() - general = values.get("general", {}) - donate_keys_enabled = general.get("donateKeys", False) - - if not donate_keys_enabled: - return - - steam_path = detect_steam_install_path() - if not steam_path: - logger.warn("LuaTools: Cannot donate keys - Steam path not found") - return - - pairs = extract_valid_decryption_keys(steam_path) - if pairs: - send_donation_keys(pairs) - else: - logger.log("LuaTools: No valid keys found to donate") - - except Exception as exc: - logger.warn(f"LuaTools: Donate keys check failed: {exc}") - - -def _start_initial_check_worker(): - try: - message = check_for_update_once() - if message: - store_last_message(message) - logger.log( - f"AutoUpdate: Initial check found update: {message}. Auto-restarting Steam..." - ) - time.sleep(2) - restart_steam_internal() - else: - _start_periodic_update_checks() - - # Check and donate keys after update check completes - _check_and_donate_keys() - except Exception as exc: - logger.warn(f"AutoUpdate: background check failed: {exc}") - try: - _start_periodic_update_checks() - except Exception: - pass - - -def start_auto_update_background_check() -> None: - """Kick off the initial check in a background thread.""" - threading.Thread(target=_start_initial_check_worker, daemon=True).start() - - -def restart_steam_internal() -> bool: - """Internal helper used to restart Steam via bundled script.""" - script_path = backend_path("restart_steam.cmd") - if not os.path.exists(script_path): - logger.error(f"LuaTools: restart script not found: {script_path}") - return False - try: - CREATE_NO_WINDOW = 0x08000000 - subprocess.Popen(["cmd", "/C", script_path], creationflags=CREATE_NO_WINDOW) - logger.log("LuaTools: Restart script launched (hidden)") - return True - except Exception as exc: - logger.error(f"LuaTools: Failed to launch restart script: {exc}") - return False - - -def restart_steam() -> bool: - """Public method exposed to the frontend.""" - return restart_steam_internal() - - -def check_for_updates_now() -> Dict[str, Any]: - """Expose a synchronous update check for the frontend.""" - try: - message = check_for_update_once() - if message: - store_last_message(message) - return {"success": True, "message": message} - except Exception as exc: - logger.warn(f"LuaTools: CheckForUpdatesNow failed: {exc}") - return {"success": False, "error": str(exc)} - - -__all__ = [ - "apply_pending_update_if_any", - "check_for_update_once", - "check_for_updates_now", - "restart_steam", - "restart_steam_internal", - "start_auto_update_background_check", -] - diff --git a/backend/config.lua b/backend/config.lua index ed97d6c..b743737 100644 --- a/backend/config.lua +++ b/backend/config.lua @@ -26,7 +26,7 @@ local config = { UPDATE_CHECK_INTERVAL_SECONDS = 2 * 60 * 60, -- 2 hours - USER_AGENT = "discord(dot)gg/luatools", + USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", LOADED_APPS_FILE = "loadedappids.txt", APPID_LOG_FILE = "appidlogs.txt", diff --git a/backend/config.py b/backend/config.py deleted file mode 100644 index 60844f5..0000000 --- a/backend/config.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Central configuration constants for the LuaTools backend.""" - -WEBKIT_DIR_NAME = "LuaTools" -WEB_UI_JS_FILE = "luatools.js" -WEB_UI_ICON_FILE = "luatools-icon.png" - -DEFAULT_HEADERS = { - "Accept": "application/json", - "X-Requested-With": "SteamDB", - "User-Agent": "https://github.com/BossSloth/Steam-SteamDB-extension", - "Origin": "https://github.com/BossSloth/Steam-SteamDB-extension", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "cross-site", -} - -API_MANIFEST_URL = "https://raw.githubusercontent.com/madoiscool/lt_api_links/refs/heads/main/load_free_manifest_apis" -API_MANIFEST_PROXY_URL = "https://luatools.vercel.app/load_free_manifest_apis" -API_JSON_FILE = "api.json" - -UPDATE_CONFIG_FILE = "update.json" -UPDATE_PENDING_ZIP = "update_pending.zip" -UPDATE_PENDING_INFO = "update_pending.json" - -HTTP_TIMEOUT_SECONDS = 15 -HTTP_PROXY_TIMEOUT_SECONDS = 15 - -UPDATE_CHECK_INTERVAL_SECONDS = 2 * 60 * 60 # 2 hours - -USER_AGENT = "discord(dot)gg/luatools" - -LOADED_APPS_FILE = "loadedappids.txt" -APPID_LOG_FILE = "appidlogs.txt" - diff --git a/backend/donate_keys.py b/backend/donate_keys.py deleted file mode 100644 index 9368dbb..0000000 --- a/backend/donate_keys.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Donate keys functionality for LuaTools backend.""" - -from __future__ import annotations - -import os -import re -from datetime import date, timedelta -from typing import List, Tuple - -from config import USER_AGENT -from http_client import get_http_client -from logger import logger - -# Import private VDF parser - it's used internally for config.vdf parsing -from steam_utils import _parse_vdf_simple # type: ignore - -DONATED_APPIDS_FILE = os.path.join(os.path.dirname(__file__), "data", "donatedappids.txt") -DONATION_URL = "http://167.235.229.108/donatekeys/send" -DONATION_HEADERS = { - "Content-Type": "text/plain", - "User-Agent": USER_AGENT, -} - - -def _load_donated_appids() -> set: - """Load the set of already-donated app IDs from the cache file.""" - if not os.path.exists(DONATED_APPIDS_FILE): - return set() - try: - with open(DONATED_APPIDS_FILE, "r", encoding="utf-8") as f: - # Filter out the DATE: line if it exists - return {line.strip() for line in f if line.strip() and not line.startswith("DATE:")} - except Exception as exc: - logger.warn(f"LuaTools: Failed to read donated appids cache: {exc}") - return set() - - -def _check_cache_staleness() -> None: - """Check if the cache is older than 7 days and wipe it if so.""" - today = date.today() - - if not os.path.exists(DONATED_APPIDS_FILE): - # Initialize with today's date if it doesn't exist - os.makedirs(os.path.dirname(DONATED_APPIDS_FILE), exist_ok=True) - try: - with open(DONATED_APPIDS_FILE, "w", encoding="utf-8") as f: - f.write(f"DATE:{today.isoformat()}\n") - except Exception as exc: - logger.warn(f"LuaTools: Failed to initialize donated appids cache: {exc}") - return - - try: - with open(DONATED_APPIDS_FILE, "r", encoding="utf-8") as f: - first_line = f.readline().strip() - - if first_line.startswith("DATE:"): - try: - date_str = first_line.split("DATE:")[1] - cached_date = date.fromisoformat(date_str) - if today - cached_date >= timedelta(days=7): - logger.log( - f"LuaTools: Cache is {today - cached_date} old (since {cached_date}), " - "wiping." - ) - with open(DONATED_APPIDS_FILE, "w", encoding="utf-8") as f: - f.write(f"DATE:{today.isoformat()}\n") - except (ValueError, IndexError): - # Invalid date format, treat as stale - logger.log("LuaTools: Cache date format invalid, wiping.") - with open(DONATED_APPIDS_FILE, "w", encoding="utf-8") as f: - f.write(f"DATE:{today.isoformat()}\n") - else: - # File exists but no date header, treat as stale - logger.log("LuaTools: Cache missing date header, wiping.") - with open(DONATED_APPIDS_FILE, "w", encoding="utf-8") as f: - f.write(f"DATE:{today.isoformat()}\n") - except Exception as exc: - logger.warn(f"LuaTools: Failed to check cache staleness: {exc}") - - -def _save_donated_appids(appids: set) -> None: - """Append newly donated app IDs to the cache file.""" - try: - with open(DONATED_APPIDS_FILE, "a", encoding="utf-8") as f: - for appid in sorted(appids): - f.write(appid + "\n") - except Exception as exc: - logger.warn(f"LuaTools: Failed to save donated appids cache: {exc}") - - -def validate_appid_key_pair(appid: str, key: str) -> bool: - """ - Validate appid and decryption key pair. - - AppID rules: - - Must be numeric only (digits 0-9) - - Maximum 10 digits - - Decryption key rules: - - Exactly 64 characters - - Alphanumeric only (a-z, A-Z, 0-9) - - Returns True if both are valid, False otherwise. - """ - if not isinstance(appid, str) or not isinstance(key, str): - return False - - # Validate AppID: numeric only, max 10 digits - if not appid.isdigit(): - return False - if len(appid) > 10: - return False - - # Validate decryption key: exactly 64 chars, alphanumeric only - if len(key) != 64: - return False - if not re.match(r"^[a-zA-Z0-9]+$", key): - return False - - return True - - -def parse_config_vdf_decryption_keys(steam_path: str) -> List[Tuple[str, str]]: - """ - Parse config.vdf to extract appid and decryption key pairs. - - Args: - steam_path: Steam installation path - - Returns: - List of (appid, decryption_key) tuples - """ - config_path = os.path.join(steam_path, "config", "config.vdf") - - if not os.path.exists(config_path): - logger.warn(f"LuaTools: config.vdf not found at {config_path}") - return [] - - try: - with open(config_path, "r", encoding="utf-8") as handle: - vdf_content = handle.read() - except Exception as exc: - logger.warn(f"LuaTools: Failed to read config.vdf: {exc}") - return [] - - try: - vdf_data = _parse_vdf_simple(vdf_content) - except Exception as exc: - logger.warn(f"LuaTools: Failed to parse config.vdf: {exc}") - return [] - - pairs: List[Tuple[str, str]] = [] - - def find_decryption_keys(data: dict, path: str = "") -> None: - """Recursively search for appid entries with DecryptionKey.""" - for key, value in data.items(): - if not isinstance(value, dict): - continue - - # Check if this entry has a DecryptionKey - decryption_key = value.get("DecryptionKey") - if isinstance(decryption_key, str): - # This looks like an appid entry with a decryption key - appid = str(key).strip() - key_value = str(decryption_key).strip() - - if appid and key_value: - pairs.append((appid, key_value)) - else: - # Recursively search nested dictionaries - find_decryption_keys(value, f"{path}.{key}" if path else key) - - # Search recursively through the VDF structure - find_decryption_keys(vdf_data) - - return pairs - - -def extract_valid_decryption_keys(steam_path: str) -> List[Tuple[str, str]]: - """ - Extract and validate decryption keys from config.vdf. - - Args: - steam_path: Steam installation path - - Returns: - List of valid (appid, decryption_key) tuples - """ - if not steam_path or not os.path.exists(steam_path): - logger.warn(f"LuaTools: Invalid Steam path for donate keys: {steam_path}") - return [] - - logger.log("LuaTools: Starting donate keys extraction...") - - all_pairs = parse_config_vdf_decryption_keys(steam_path) - valid_pairs: List[Tuple[str, str]] = [] - - for appid, key in all_pairs: - if validate_appid_key_pair(appid, key): - valid_pairs.append((appid, key)) - else: - logger.log( - f"LuaTools: Invalid appid/key pair skipped: appid={appid!r}, " - f"key_len={len(key)}, key_valid={bool(re.match(r'^[a-zA-Z0-9]+$', key))}" - ) - - logger.log(f"LuaTools: Found {len(valid_pairs)} valid decryption key pairs") - return valid_pairs - - -def format_keys_for_donation(pairs: List[Tuple[str, str]]) -> str: - """ - Format appid/key pairs for donation request. - - Format: "appid:key,appid:key" - - Args: - pairs: List of (appid, key) tuples - - Returns: - Formatted string - """ - formatted_pairs = [f"{appid}:{key}" for appid, key in pairs] - return ",".join(formatted_pairs) - - -def send_donation_keys(pairs: List[Tuple[str, str]]) -> bool: - """ - Send donation keys to the donation endpoint. - - Filters out already-donated app IDs using a local cache. - Only sends new pairs and records them on success. - - Args: - pairs: List of (appid, key) tuples - - Returns: - True if request succeeded (200 response), False otherwise - """ - if not pairs: - logger.log("LuaTools: No keys to donate") - return False - - # Check for cache staleness and re-donate if necessary - _check_cache_staleness() - - already_donated = _load_donated_appids() - new_pairs = [(appid, key) for appid, key in pairs if appid not in already_donated] - - if not new_pairs: - logger.log( - f"LuaTools: All {len(pairs)} keys already donated, skipping request" - ) - return True - - try: - formatted_data = format_keys_for_donation(new_pairs) - client = get_http_client() - - logger.log( - f"LuaTools: Sending {len(new_pairs)} new appid/key pairs " - f"({len(pairs) - len(new_pairs)} already donated, skipped)" - ) - - response = client.post( - DONATION_URL, - headers=DONATION_HEADERS, - content=formatted_data, - ) - - status_code = response.status_code - logger.log(f"LuaTools: Donated AppIDs : {len(new_pairs)} - Resp : {status_code}") - - if status_code == 200: - _save_donated_appids({appid for appid, _ in new_pairs}) - return True - else: - logger.log(f"LuaTools: Donation request status : {status_code}") - return False - - except Exception as exc: - logger.log(f"LuaTools: Failed to send donation keys: {exc}") - return False diff --git a/backend/downloads.lua b/backend/downloads.lua index 6805a3b..1950aa8 100644 --- a/backend/downloads.lua +++ b/backend/downloads.lua @@ -196,13 +196,43 @@ function downloads.start_add_via_luatools(appid) for _, api in ipairs(apis) do local name = api.name or "Unknown" local template = api.url or "" + local success_code = tonumber(api.success_code) or 200 + if string.find(template, "") then if not morrenus_api_key or morrenus_api_key == "" then goto continue end template = template:gsub("", morrenus_api_key) end - target_url = template:gsub("", tostring(appid)) - target_name = name - break + if string.find(template, "") then + if not api.api_key or api.api_key == "" then goto continue end + template = template:gsub("", api.api_key) + end + + local url = template:gsub("", tostring(appid)) + + local success = false + if string.lower(name) == "morrenus" then + local status_url = "https://hubcapmanifest.com/api/v1/status/" .. tostring(appid) .. "?api_key=" .. tostring(morrenus_api_key) + local s_resp = http_client.get(status_url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if s_resp and s_resp.status == success_code then + success = true + end + else + local resp = http_client.head(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if resp and resp.status == success_code then + success = true + else + local get_resp = http_client.get(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if get_resp and get_resp.status == success_code then + success = true + end + end + end + + if success then + target_url = url + target_name = name + break + end ::continue:: end if not target_url then error("Not available on any API") end @@ -258,6 +288,12 @@ function downloads.check_apis_for_app(appid) end template = template:gsub("", morrenus_api_key) end + if string.find(template, "") then + if not api.api_key or api.api_key == "" then + goto continue + end + template = template:gsub("", api.api_key) + end local url = template:gsub("", tostring(appid)) local available = false @@ -275,8 +311,19 @@ function downloads.check_apis_for_app(appid) available = true end else - local resp = http_client.get(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + local success = false + local resp = http_client.head(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) if resp and resp.status == success_code then + success = true + else + -- Fallback to GET if HEAD fails + local get_resp = http_client.get(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if get_resp and get_resp.status == success_code then + success = true + end + end + + if success then available = true end end diff --git a/backend/downloads.py b/backend/downloads.py deleted file mode 100644 index 174b431..0000000 --- a/backend/downloads.py +++ /dev/null @@ -1,1200 +0,0 @@ -"""Handling of LuaTools add/download flows and related utilities.""" - -from __future__ import annotations - -import base64 -import json -import os -import re -import threading -import time -import datetime -from typing import Any, Dict - -import Millennium # type: ignore - -from api_manifest import load_api_manifest -from settings.manager import get_morrenus_api_key -from config import ( - APPID_LOG_FILE, - LOADED_APPS_FILE, - USER_AGENT, - WEBKIT_DIR_NAME, - WEB_UI_ICON_FILE, - WEB_UI_JS_FILE, -) -from http_client import ensure_http_client -import httpx -from logger import logger -from paths import backend_path, public_path -from steam_utils import detect_steam_install_path, has_lua_for_app -from utils import count_apis, ensure_temp_download_dir, normalize_manifest_text, read_text, write_text - -DOWNLOAD_STATE: Dict[int, Dict[str, Any]] = {} -DOWNLOAD_LOCK = threading.Lock() - -# Cache for app names and infos to avoid repeated API calls -APP_NAME_CACHE: Dict[int, str] = {} -APP_NAME_CACHE_LOCK = threading.Lock() - -APP_INFO_CACHE: Dict = {} -APP_INFO_CACHE_LOCK = threading.Lock() - -# Rate limiting for Steam API calls -LAST_API_CALL_TIME = 0 -API_CALL_MIN_INTERVAL = 0.3 # 300ms between calls to avoid 429 errors - -# In-memory applist for fallback app name lookup -APPLIST_DATA: Dict[int, str] = {} -APPLIST_LOADED = False -APPLIST_LOCK = threading.Lock() -APPLIST_FILE_NAME = "all-appids.json" -APPLIST_URL = "https://applist.morrenus.xyz/" -APPLIST_DOWNLOAD_TIMEOUT = 300 # 5 minutes for large file - -GAMES_DB_FILE_NAME = "games.json" -GAMES_DB_URL = "https://toolsdb.piqseu.cc/games.json" - -# In-memory games database cache and lock (defined to avoid undefined variable) -GAMES_DB_DATA: Dict[int, Any] = {} -GAMES_DB_LOADED = False -GAMES_DB_LOCK = threading.Lock() - - -def _set_download_state(appid: int, update: dict) -> None: - with DOWNLOAD_LOCK: - state = DOWNLOAD_STATE.get(appid) or {} - state.update(update) - DOWNLOAD_STATE[appid] = state - - -def _get_download_state(appid: int) -> dict: - with DOWNLOAD_LOCK: - return DOWNLOAD_STATE.get(appid, {}).copy() - - -def _loaded_apps_path() -> str: - return backend_path(LOADED_APPS_FILE) - - -def _appid_log_path() -> str: - return backend_path(APPID_LOG_FILE) - - -def _fetch_app_name(appid: int) -> str: - """Fetch app name with rate limiting and caching. - - Fallback order: - 1. In-memory cache - 2. Applist file (in-memory) - checked before web requests - 3. Steam API (web request as final resort) - """ - global LAST_API_CALL_TIME - - # Check cache first - with APP_NAME_CACHE_LOCK: - if appid in APP_NAME_CACHE: - cached = APP_NAME_CACHE[appid] - if cached: # Only return if not empty - return cached - - # Check applist file before making web requests - applist_name = _get_app_name_from_applist(appid) - if applist_name: - # Cache the result from applist - with APP_NAME_CACHE_LOCK: - APP_NAME_CACHE[appid] = applist_name - return applist_name - - # Steam API as final resort (web request) - # Rate limiting: calculate wait time and update timestamp atomically - with APP_NAME_CACHE_LOCK: - time_since_last_call = time.time() - LAST_API_CALL_TIME - sleep_time = API_CALL_MIN_INTERVAL - time_since_last_call if time_since_last_call < API_CALL_MIN_INTERVAL else 0 - # Update timestamp now to reserve this slot (prevents race condition) - LAST_API_CALL_TIME = time.time() + sleep_time - - if sleep_time > 0: - time.sleep(sleep_time) - - client = ensure_http_client("LuaTools: _fetch_app_name") - try: - url = f"https://store.steampowered.com/api/appdetails?appids={appid}" - logger.log(f"LuaTools: Fetching app name for {appid} from Steam API") - resp = client.get(url, follow_redirects=True, timeout=10) - logger.log(f"LuaTools: Steam API response for {appid}: status={resp.status_code}") - resp.raise_for_status() - data = resp.json() - entry = data.get(str(appid)) or {} - if isinstance(entry, dict): - inner = entry.get("data") or {} - name = inner.get("name") - if isinstance(name, str) and name.strip(): - name = name.strip() - # Cache the result - with APP_NAME_CACHE_LOCK: - APP_NAME_CACHE[appid] = name - return name - except Exception as exc: - logger.warn(f"LuaTools: _fetch_app_name failed for {appid}: {exc}") - - # Cache empty result to avoid repeated failed attempts - with APP_NAME_CACHE_LOCK: - APP_NAME_CACHE[appid] = "" - return "" - - -def _fetch_app_info(appid: int) -> dict: - """Fetch app info from steamcmd with caching. - - Fallback order: - 1. In-memory cache - 2. Steamcmd.net (request) - """ - global LAST_API_CALL_TIME - - # Check cache first - with APP_INFO_CACHE_LOCK: - if appid in APP_INFO_CACHE: - cached = APP_INFO_CACHE[appid] - if cached: # Only return if not empty - return cached - - client = ensure_http_client("LuaTools: _fetch_app_info") - try: - url = f"https://api.steamcmd.net/v1/info/{appid}" - logger.log(f"LuaTools: Fetching app info for {appid} from steamcmd.net") - resp = client.get(url, follow_redirects=True, timeout=10) - logger.log(f"LuaTools: steamcmd.net response for {appid}: status={resp.status_code}") - resp.raise_for_status() - - data = resp.json().get("data", {}) - - if isinstance(data, dict): - root = data.get(str(appid), {}) - - depots = root.get("depots", {}) - extended = root.get("extended", {}) - - output = { - "workshop_depot": depots.get("workshopdepot", 0), - "dlc_list": extended.get("listofdlc", "") - } - - # Cache the result - with APP_INFO_CACHE_LOCK: - APP_INFO_CACHE[appid] = output - return output - except Exception as exc: - logger.warn(f"LuaTools: _fetch_app_info failed for {appid}: {exc}") - - # Cache empty result to avoid repeated failed attempts - with APP_INFO_CACHE_LOCK: - APP_INFO_CACHE[appid] = {} - return {} - - -def _append_loaded_app(appid: int, name: str) -> None: - try: - path = _loaded_apps_path() - lines = [] - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as handle: - lines = handle.read().splitlines() - prefix = f"{appid}:" - lines = [line for line in lines if not line.startswith(prefix)] - lines.append(f"{appid}:{name}") - with open(path, "w", encoding="utf-8") as handle: - handle.write("\n".join(lines) + "\n") - except Exception as exc: - logger.warn(f"LuaTools: _append_loaded_app failed for {appid}: {exc}") - - -def _remove_loaded_app(appid: int) -> None: - try: - path = _loaded_apps_path() - if not os.path.exists(path): - return - with open(path, "r", encoding="utf-8") as handle: - lines = handle.read().splitlines() - prefix = f"{appid}:" - new_lines = [line for line in lines if not line.startswith(prefix)] - if len(new_lines) != len(lines): - with open(path, "w", encoding="utf-8") as handle: - handle.write("\n".join(new_lines) + ("\n" if new_lines else "")) - except Exception as exc: - logger.warn(f"LuaTools: _remove_loaded_app failed for {appid}: {exc}") - - -def _log_appid_event(action: str, appid: int, name: str) -> None: - try: - stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - line = f"[{action}] {appid} - {name} - {stamp}\n" - with open(_appid_log_path(), "a", encoding="utf-8") as handle: - handle.write(line) - except Exception as exc: - logger.warn(f"LuaTools: _log_appid_event failed: {exc}") - - -def _preload_app_names_cache() -> None: - """Pre-load all app names from loaded_apps, appidlogs, and applist files into memory cache.""" - # First, load from appidlogs.txt (historical records) - try: - log_path = _appid_log_path() - if os.path.exists(log_path): - with open(log_path, "r", encoding="utf-8") as handle: - for line in handle.read().splitlines(): - # Format: [ACTION - API_NAME] appid - name - timestamp - # Example: [ADDED - Sadie] 945360 - Among Us - 2024-01-15 14:05:04 - # Or: [REMOVED] appid - name - timestamp - if "]" in line and " - " in line: - try: - # Extract content after the first ']' - parts = line.split("]", 1) - if len(parts) < 2: - continue - - content = parts[1].strip() - # Split by " - " to get: appid, name, timestamp (max 3 parts) - content_parts = content.split(" - ", 2) - - if len(content_parts) >= 2: - appid_str = content_parts[0].strip() - name = content_parts[1].strip() - - # Try to parse appid - appid = int(appid_str) - - # Skip "Unknown Game" or "UNKNOWN" entries - if name and not name.startswith("Unknown") and not name.startswith("UNKNOWN"): - with APP_NAME_CACHE_LOCK: - APP_NAME_CACHE[appid] = name - except (ValueError, IndexError): - continue - except Exception as exc: - logger.warn(f"LuaTools: _preload_app_names_cache from logs failed: {exc}") - - # Then, load from loaded_apps.txt (current state - overrides log if present) - try: - path = _loaded_apps_path() - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as handle: - for line in handle.read().splitlines(): - if ":" in line: - parts = line.split(":", 1) - try: - appid = int(parts[0].strip()) - name = parts[1].strip() - if name: - with APP_NAME_CACHE_LOCK: - APP_NAME_CACHE[appid] = name - except (ValueError, IndexError): - continue - except Exception as exc: - logger.warn(f"LuaTools: _preload_app_names_cache from loaded_apps failed: {exc}") - - # Finally, load from applist file (as fallback source - doesn't override existing cache) - # This ensures applist is available for lookups without web requests - try: - _load_applist_into_memory() - except Exception as exc: - logger.warn(f"LuaTools: _preload_app_names_cache from applist failed: {exc}") - - -def _get_loaded_app_name(appid: int) -> str: - """Get app name from loadedappids.txt, with applist as fallback.""" - try: - path = _loaded_apps_path() - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as handle: - for line in handle.read().splitlines(): - if line.startswith(f"{appid}:"): - name = line.split(":", 1)[1].strip() - if name: - return name - except Exception: - pass - - # Fallback to applist if not found in loadedappids.txt - return _get_app_name_from_applist(appid) - - -def _applist_file_path() -> str: - """Get the path to the applist JSON file.""" - temp_dir = ensure_temp_download_dir() - return os.path.join(temp_dir, APPLIST_FILE_NAME) - - -def _load_applist_into_memory() -> None: - """Load the applist JSON file into memory for fast lookups.""" - global APPLIST_DATA, APPLIST_LOADED - - with APPLIST_LOCK: - if APPLIST_LOADED: - return - - file_path = _applist_file_path() - if not os.path.exists(file_path): - logger.log("LuaTools: Applist file not found, skipping load") - APPLIST_LOADED = True # Mark as loaded to avoid repeated checks - return - - try: - logger.log("LuaTools: Loading applist into memory...") - with open(file_path, "r", encoding="utf-8") as handle: - data = json.load(handle) - - if isinstance(data, list): - count = 0 - for entry in data: - if isinstance(entry, dict): - appid = entry.get("appid") - name = entry.get("name") - if appid and name and isinstance(name, str) and name.strip(): - APPLIST_DATA[int(appid)] = name.strip() - count += 1 - logger.log(f"LuaTools: Loaded {count} app names from applist into memory") - else: - logger.warn("LuaTools: Applist file has invalid format (expected array)") - - APPLIST_LOADED = True - except Exception as exc: - logger.warn(f"LuaTools: Failed to load applist into memory: {exc}") - APPLIST_LOADED = True # Mark as loaded to avoid repeated failed attempts - - -def _get_app_name_from_applist(appid: int) -> str: - """Get app name from in-memory applist.""" - # Ensure applist is loaded - if not APPLIST_LOADED: - _load_applist_into_memory() - - with APPLIST_LOCK: - return APPLIST_DATA.get(int(appid), "") - - -def _ensure_applist_file() -> None: - """Download the applist file if it doesn't exist.""" - file_path = _applist_file_path() - - if os.path.exists(file_path): - logger.log("LuaTools: Applist file already exists, skipping download") - return - - logger.log("LuaTools: Applist file not found, downloading...") - client = ensure_http_client("LuaTools: DownloadApplist") - - try: - logger.log(f"LuaTools: Downloading applist from {APPLIST_URL}") - resp = client.get(APPLIST_URL, follow_redirects=True, timeout=APPLIST_DOWNLOAD_TIMEOUT) - logger.log(f"LuaTools: Applist download response: status={resp.status_code}") - resp.raise_for_status() - - # Validate JSON format before saving - try: - data = resp.json() - if not isinstance(data, list): - logger.warn("LuaTools: Downloaded applist has invalid format (expected array)") - return - except json.JSONDecodeError as exc: - logger.warn(f"LuaTools: Downloaded applist is not valid JSON: {exc}") - return - - # Save to file - with open(file_path, "w", encoding="utf-8") as handle: - json.dump(data, handle) - - logger.log(f"LuaTools: Successfully downloaded and saved applist file ({len(data)} entries)") - except Exception as exc: - logger.warn(f"LuaTools: Failed to download applist file: {exc}") - - -def init_applist() -> None: - """Initialize the applist system: download if needed, then load into memory.""" - try: - _ensure_applist_file() - _load_applist_into_memory() - except Exception as exc: - logger.warn(f"LuaTools: Applist initialization failed: {exc}") - - -def _games_db_file_path() -> str: - """Get the path to the games database JSON file.""" - temp_dir = ensure_temp_download_dir() - return os.path.join(temp_dir, GAMES_DB_FILE_NAME) - - -def _load_games_db_into_memory() -> None: - """Load the games database JSON file into memory.""" - global GAMES_DB_DATA, GAMES_DB_LOADED - - with GAMES_DB_LOCK: - if GAMES_DB_LOADED: - return - - file_path = _games_db_file_path() - if not os.path.exists(file_path): - logger.log("LuaTools: Games DB file not found, skipping load") - GAMES_DB_LOADED = True - return - - try: - logger.log("LuaTools: Loading Games DB into memory...") - with open(file_path, "r", encoding="utf-8") as handle: - GAMES_DB_DATA = json.load(handle) - - logger.log(f"LuaTools: Loaded Games DB ({len(GAMES_DB_DATA)} entries)") - GAMES_DB_LOADED = True - except Exception as exc: - logger.warn(f"LuaTools: Failed to load Games DB: {exc}") - GAMES_DB_LOADED = True - - -GAMES_DB_CACHE_MAX_AGE_SECONDS = 24 * 60 * 60 # 24 hours - - -def _is_games_db_cache_stale() -> bool: - """Check if the games database cache file is older than 24 hours.""" - file_path = _games_db_file_path() - if not os.path.exists(file_path): - return True - try: - file_mtime = os.path.getmtime(file_path) - age_seconds = time.time() - file_mtime - return age_seconds > GAMES_DB_CACHE_MAX_AGE_SECONDS - except Exception: - return True - - -def _ensure_games_db_file() -> None: - """Download the games database file if missing or stale (older than 24 hours).""" - file_path = _games_db_file_path() - - # Skip download if file exists and is fresh - if os.path.exists(file_path) and not _is_games_db_cache_stale(): - logger.log("LuaTools: Games DB cache is fresh, skipping download") - return - - logger.log("LuaTools: Downloading Games DB (cache missing or stale)...") - client = ensure_http_client("LuaTools: DownloadGamesDB") - - try: - logger.log(f"LuaTools: Downloading Games DB from {GAMES_DB_URL}") - resp = client.get(GAMES_DB_URL, follow_redirects=True, timeout=60) - logger.log(f"LuaTools: Games DB download response: status={resp.status_code}") - resp.raise_for_status() - - data = resp.json() - - with open(file_path, "w", encoding="utf-8") as handle: - json.dump(data, handle) - - logger.log(f"LuaTools: Successfully downloaded Games DB") - except Exception as exc: - logger.warn(f"LuaTools: Failed to download Games DB: {exc}") - - -def init_games_db() -> None: - """Initialize the games database: download if needed, then load into memory.""" - try: - _ensure_games_db_file() - _load_games_db_into_memory() - except Exception as exc: - logger.warn(f"LuaTools: Games DB initialization failed: {exc}") - - -def get_games_database() -> str: - """Get the games database as JSON string.""" - if not GAMES_DB_LOADED: - init_games_db() - - with GAMES_DB_LOCK: - return json.dumps(GAMES_DB_DATA) - - -def fetch_app_name(appid: int) -> str: - return _fetch_app_name(appid) - -def fetch_app_info(appid: int) -> str: - return json.dumps(_fetch_app_info(appid)) - - - -def _process_and_install_lua(appid: int, zip_path: str) -> None: - """Process downloaded zip and install lua file into stplug-in directory.""" - import zipfile - - if _is_download_cancelled(appid): - raise RuntimeError("cancelled") - - base_path = detect_steam_install_path() or Millennium.steam_path() - target_dir = os.path.join(base_path or "", "config", "stplug-in") - os.makedirs(target_dir, exist_ok=True) - - with zipfile.ZipFile(zip_path, "r") as archive: - names = archive.namelist() - - try: - depotcache_dir = os.path.join(base_path or "", "depotcache") - os.makedirs(depotcache_dir, exist_ok=True) - for name in names: - try: - if _is_download_cancelled(appid): - raise RuntimeError("cancelled") - if name.lower().endswith(".manifest"): - pure = os.path.basename(name) - data = archive.read(name) - out_path = os.path.join(depotcache_dir, pure) - with open(out_path, "wb") as manifest_file: - manifest_file.write(data) - logger.log(f"LuaTools: Extracted manifest -> {out_path}") - except Exception as manifest_exc: - logger.warn(f"LuaTools: Failed to extract manifest {name}: {manifest_exc}") - except Exception as depot_exc: - logger.warn(f"LuaTools: depotcache extraction failed: {depot_exc}") - - candidates = [] - for name in names: - pure = os.path.basename(name) - if re.fullmatch(r"\d+\.lua", pure): - candidates.append(name) - - if _is_download_cancelled(appid): - raise RuntimeError("cancelled") - - chosen = None - preferred = f"{appid}.lua" - for name in candidates: - if os.path.basename(name) == preferred: - chosen = name - break - if chosen is None and candidates: - chosen = candidates[0] - if not chosen: - raise RuntimeError("No numeric .lua file found in zip") - - data = archive.read(chosen) - try: - text = data.decode("utf-8") - except Exception: - text = data.decode("utf-8", errors="replace") - - processed_lines = [] - depots = { "ids": [] , "lines": {} } - for line in text.splitlines(True): - if re.match(r"^\s*setManifestid\(", line) and not re.match(r"^\s*--", line): - line = re.sub(r"^(\s*)", r"\1--", line) - processed_lines.append(line) - if re.match(r"^\s*addappid\(", line) and not re.match(r"^\s*--", line): - if (m := re.search(r"\d+", line)): - id = m.group() - - depots["ids"].append(id) - if id not in depots["lines"]: - depots["lines"][id] = [] - depots["lines"][id] = line - - processed_text = "".join(processed_lines) - - _set_download_state(appid, {"status": "installing"}) - dest_file = os.path.join(target_dir, f"{appid}.lua") - if _is_download_cancelled(appid): - raise RuntimeError("cancelled") - with open(dest_file, "w", encoding="utf-8") as output: - output.write(processed_text) - logger.log(f"LuaTools: Installed lua -> {dest_file}") - _set_download_state(appid, {"installedPath": dest_file}) - - # Check .lua content - try: - if _is_download_cancelled(appid): - raise RuntimeError("cancelled") - - info = _fetch_app_info(appid) - - # Workshop presence - work_depot = str(info.get("workshop_depot", 0)) - if work_depot == "0": - workshop_result = "No workshop for the game" - else: - # Checking if mentionned in addappid lines + if it includes a decryption key - if work_depot in depots["ids"] and re.search(rf",\d+,[\"']", depots["lines"][work_depot].replace(" ", "")): - workshop_result = "Included" - else: - workshop_result = "Missing" - - # Dlc listing - dlc_result = { "included": [], "missing": [] } - if info.get("dlc_list", "") != "": - dlcs = info["dlc_list"].split(",") - - for dlc in dlcs: - if dlc in depots["ids"]: - dlc_result["included"].append(int(dlc)) - else: - dlc_result["missing"].append(int(dlc)) - - _set_download_state(appid, { - "status": "done", - "contentCheckResult": { - "workshop": workshop_result, - "dlc": dlc_result - } - }) - - - except Exception as exc: - logger.error(f"LuaTools: Error checking lua for app {appid}: {exc}") - _set_download_state(appid, {"status": "done"}) - - try: - os.remove(zip_path) - except Exception: - try: - for _ in range(3): - time.sleep(0.2) - try: - os.remove(zip_path) - break - except Exception: - continue - except Exception: - pass - - -def _is_download_cancelled(appid: int) -> bool: - try: - return _get_download_state(appid).get("status") == "cancelled" - except Exception: - return False - - -def _download_zip_for_app(appid: int): - client = ensure_http_client("LuaTools: download") - apis = load_api_manifest() - if not apis: - logger.warn("LuaTools: No enabled APIs in manifest") - _set_download_state(appid, {"status": "failed", "error": "No APIs available"}) - return - - dest_root = ensure_temp_download_dir() - dest_path = os.path.join(dest_root, f"{appid}.zip") - _set_download_state( - appid, - {"status": "checking", "currentApi": None, "bytesRead": 0, "totalBytes": 0, "dest": dest_path, "apiErrors": {}}, - ) - - # Get Morrenus API key for URL replacement - morrenus_api_key = get_morrenus_api_key() - - for api in apis: - name = api.get("name", "Unknown") - template = api.get("url", "") - success_code = int(api.get("success_code", 200)) - unavailable_code = int(api.get("unavailable_code", 404)) - - # Check if URL requires Morrenus API key - if "" in template: - if not morrenus_api_key: - # Skip this API silently if key is not set - logger.log(f"LuaTools: Skipping API '{name}' - Morrenus API key not configured") - continue - # Replace the placeholder with the actual key - template = template.replace("", morrenus_api_key) - - url = template.replace("", str(appid)) - _set_download_state( - appid, {"status": "checking", "currentApi": name, "bytesRead": 0, "totalBytes": 0} - ) - logger.log(f"LuaTools: Trying API '{name}'") - try: - headers = {"User-Agent": USER_AGENT} - if _is_download_cancelled(appid): - logger.log(f"LuaTools: Download cancelled before contacting API '{name}'") - return - with client.stream("GET", url, headers=headers, follow_redirects=True) as resp: - code = resp.status_code - logger.log(f"LuaTools: API '{name}' status={code}") - if code == unavailable_code: - continue - if code != success_code: - # Track error code for this API - state = _get_download_state(appid) - api_errors = state.get("apiErrors", {}) - api_errors[name] = {"type": "error", "code": code} - _set_download_state(appid, {"apiErrors": api_errors}) - continue - total = int(resp.headers.get("Content-Length", "0") or "0") - _set_download_state(appid, {"status": "downloading", "bytesRead": 0, "totalBytes": total}) - with open(dest_path, "wb") as output: - for chunk in resp.iter_bytes(): - if not chunk: - continue - if _is_download_cancelled(appid): - logger.log(f"LuaTools: Download cancelled mid-stream for appid={appid}") - raise RuntimeError("cancelled") - output.write(chunk) - state = _get_download_state(appid) - read = int(state.get("bytesRead", 0)) + len(chunk) - _set_download_state(appid, {"bytesRead": read}) - if _is_download_cancelled(appid): - logger.log(f"LuaTools: Download cancelled after writing chunk for appid={appid}") - raise RuntimeError("cancelled") - logger.log(f"LuaTools: Download complete -> {dest_path}") - - if _is_download_cancelled(appid): - logger.log(f"LuaTools: Download marked cancelled after completion for appid={appid}") - raise RuntimeError("cancelled") - - try: - with open(dest_path, "rb") as fh: - magic = fh.read(4) - if magic not in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"): - file_size = os.path.getsize(dest_path) - with open(dest_path, "rb") as check_f: - preview = check_f.read(512) - content_preview = preview[:100].decode("utf-8", errors="ignore") - logger.warn( - f"LuaTools: API '{name}' returned non-zip file (magic={magic.hex()}, size={file_size}, preview={content_preview[:50]})" - ) - try: - os.remove(dest_path) - except Exception: - pass - continue - except FileNotFoundError: - logger.warn("LuaTools: Downloaded file not found after download") - continue - except Exception as validation_exc: - logger.warn(f"LuaTools: File validation failed for API '{name}': {validation_exc}") - try: - os.remove(dest_path) - except Exception: - pass - continue - - try: - if _is_download_cancelled(appid): - logger.log(f"LuaTools: Processing aborted due to cancellation for appid={appid}") - raise RuntimeError("cancelled") - _set_download_state(appid, {"status": "processing"}) - _process_and_install_lua(appid, dest_path) - if _is_download_cancelled(appid): - logger.log(f"LuaTools: Installation complete but marked cancelled for appid={appid}") - raise RuntimeError("cancelled") - try: - fetched_name = _fetch_app_name(appid) or f"UNKNOWN ({appid})" - _append_loaded_app(appid, fetched_name) - _log_appid_event(f"ADDED - {name}", appid, fetched_name) - except Exception: - pass - _set_download_state(appid, {"status": "done", "success": True, "api": name}) - return - except Exception as install_exc: - if isinstance(install_exc, RuntimeError) and str(install_exc) == "cancelled": - try: - if os.path.exists(dest_path): - os.remove(dest_path) - except Exception: - pass - logger.log(f"LuaTools: Cancelled download cleanup complete for appid={appid}") - return - logger.warn(f"LuaTools: Processing failed -> {install_exc}") - _set_download_state( - appid, {"status": "failed", "error": f"Processing failed: {install_exc}"} - ) - try: - os.remove(dest_path) - except Exception: - pass - return - except RuntimeError as cancel_exc: - if str(cancel_exc) == "cancelled": - try: - if os.path.exists(dest_path): - os.remove(dest_path) - except Exception: - pass - logger.log(f"LuaTools: Download cancelled and cleaned up for appid={appid}") - return - logger.warn(f"LuaTools: Runtime error during download for appid={appid}: {cancel_exc}") - _set_download_state(appid, {"status": "failed", "error": str(cancel_exc)}) - return - except Exception as err: - logger.warn(f"LuaTools: API '{name}' failed with error: {err}") - # Track error for this API - check if it's a timeout - error_type = "timeout" if isinstance(err, (httpx.TimeoutException, httpx.ReadTimeout, httpx.ConnectTimeout)) else "error" - error_code = None - if isinstance(err, httpx.HTTPStatusError): - error_code = err.response.status_code if err.response else None - elif hasattr(err, "response") and err.response: - error_code = err.response.status_code - - state = _get_download_state(appid) - api_errors = state.get("apiErrors", {}) - if error_type == "timeout": - api_errors[name] = {"type": "timeout"} - else: - api_errors[name] = {"type": "error", "code": error_code} - _set_download_state(appid, {"apiErrors": api_errors}) - continue - - _set_download_state(appid, {"status": "failed", "error": "Not available on any API"}) - - -def check_apis_for_app(appid: int) -> str: - """Check all enabled APIs for a specific appid and return their availability.""" - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - client = ensure_http_client("LuaTools: check_apis") - apis = load_api_manifest() - if not apis: - return json.dumps({"success": True, "results": []}) - - results = [] - morrenus_api_key = get_morrenus_api_key() - - fast_check_succeeded = False - fast_check_data = {} - - try: - fast_resp = client.get( - f"http://167.235.229.108/check_apis?appid={appid}", - headers={"User-Agent": "secretgoonpoon"}, - timeout=5, - follow_redirects=True - ) - if fast_resp.status_code == 200: - fast_check_data = fast_resp.json() - fast_check_succeeded = isinstance(fast_check_data, dict) - except Exception as exc: - logger.warn(f"LuaTools: Fast API check failed: {exc}") - - # Use a small timeout for availability check - headers = {"User-Agent": USER_AGENT} - - for api in apis: - name = api.get("name", "Unknown") - template = api.get("url", "") - success_code = int(api.get("success_code", 200)) - - if "" in template: - if not morrenus_api_key: - continue - template = template.replace("", morrenus_api_key) - - url = template.replace("", str(appid)) - available = False - - if fast_check_succeeded: - check_key = "Sadie (Morrenus)" if name.lower() == "morrenus" else name - if fast_check_data.get(check_key) == "available": - available = True - else: - try: - if name.lower() == "morrenus": - status_url = f"https://hubcapmanifest.com/api/v1/status/{appid}?api_key={morrenus_api_key}" - resp = client.get(status_url, headers=headers, follow_redirects=True, timeout=5) - if resp.status_code == success_code: - available = True - else: - # We use HEAD for fast checking if possible, fallback to small GET - resp = client.head(url, headers=headers, follow_redirects=True, timeout=5) - if resp.status_code == success_code: - available = True - elif resp.status_code == 405: # Method Not Allowed - some APIs don't like HEAD - resp = client.get(url, headers=headers, follow_redirects=True, timeout=5) - if resp.status_code == success_code: - available = True - except Exception: - pass - - results.append({ - "name": name, - "available": available, - "url": url if available else None - }) - - return json.dumps({"success": True, "results": results}) - - -def _download_zip_from_url(appid: int, url: str, api_name: str): - """Internal worker to download from a specific URL.""" - client = ensure_http_client("LuaTools: download_direct") - dest_root = ensure_temp_download_dir() - dest_path = os.path.join(dest_root, f"{appid}.zip") - - _set_download_state(appid, {"status": "downloading", "currentApi": api_name, "bytesRead": 0, "totalBytes": 0, "dest": dest_path}) - - try: - headers = {"User-Agent": USER_AGENT} - with client.stream("GET", url, headers=headers, follow_redirects=True) as resp: - resp.raise_for_status() - total = int(resp.headers.get("Content-Length", "0") or "0") - _set_download_state(appid, {"totalBytes": total}) - - with open(dest_path, "wb") as output: - for chunk in resp.iter_bytes(): - if _is_download_cancelled(appid): - raise RuntimeError("cancelled") - output.write(chunk) - state = _get_download_state(appid) - read = int(state.get("bytesRead", 0)) + len(chunk) - _set_download_state(appid, {"bytesRead": read}) - - _set_download_state(appid, {"status": "processing"}) - _process_and_install_lua(appid, dest_path) - - fetched_name = _fetch_app_name(appid) or f"UNKNOWN ({appid})" - _append_loaded_app(appid, fetched_name) - _log_appid_event(f"ADDED - {api_name}", appid, fetched_name) - - _set_download_state(appid, {"status": "done", "success": True, "api": api_name}) - - except Exception as exc: - if str(exc) == "cancelled": - if os.path.exists(dest_path): - os.remove(dest_path) - _set_download_state(appid, {"status": "cancelled", "error": "Cancelled by user"}) - else: - logger.warn(f"LuaTools: _download_zip_from_url failed: {exc}") - _set_download_state(appid, {"status": "failed", "error": str(exc)}) - - -def start_add_via_luatools_from_url(appid: int, url: str, api_name: str) -> str: - """Initiate a download from a specific URL selected by the user.""" - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - _set_download_state(appid, {"status": "queued", "bytesRead": 0, "totalBytes": 0, "error": None}) - thread = threading.Thread(target=_download_zip_from_url, args=(appid, url, api_name), daemon=True) - thread.start() - return json.dumps({"success": True}) - - -def start_add_via_luatools(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - logger.log(f"LuaTools: StartAddViaLuaTools appid={appid}") - _set_download_state(appid, {"status": "queued", "bytesRead": 0, "totalBytes": 0}) - thread = threading.Thread(target=_download_zip_for_app, args=(appid,), daemon=True) - thread.start() - return json.dumps({"success": True}) - - -def get_add_status(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - state = _get_download_state(appid) - return json.dumps({"success": True, "state": state}) - - -def read_loaded_apps() -> str: - try: - path = _loaded_apps_path() - entries = [] - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as handle: - for line in handle.read().splitlines(): - if ":" in line: - appid_str, name = line.split(":", 1) - appid_str = appid_str.strip() - name = name.strip() - if appid_str.isdigit() and name: - entries.append({"appid": int(appid_str), "name": name}) - return json.dumps({"success": True, "apps": entries}) - except Exception as exc: - return json.dumps({"success": False, "error": str(exc)}) - - -def dismiss_loaded_apps() -> str: - try: - path = _loaded_apps_path() - if os.path.exists(path): - os.remove(path) - return json.dumps({"success": True}) - except Exception as exc: - return json.dumps({"success": False, "error": str(exc)}) - - -def delete_luatools_for_app(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - base = detect_steam_install_path() or Millennium.steam_path() - target_dir = os.path.join(base or "", "config", "stplug-in") - paths = [ - os.path.join(target_dir, f"{appid}.lua"), - os.path.join(target_dir, f"{appid}.lua.disabled"), - ] - deleted = [] - for path in paths: - try: - if os.path.exists(path): - os.remove(path) - deleted.append(path) - except Exception as exc: - logger.warn(f"LuaTools: Failed to delete {path}: {exc}") - try: - name = _get_loaded_app_name(appid) or _fetch_app_name(appid) or f"UNKNOWN ({appid})" - _remove_loaded_app(appid) - if deleted: - _log_appid_event("REMOVED", appid, name) - except Exception: - pass - return json.dumps({"success": True, "deleted": deleted, "count": len(deleted)}) - - -def get_icon_data_url() -> str: - try: - steam_ui_path = os.path.join(Millennium.steam_path(), "steamui", WEBKIT_DIR_NAME) - icon_path = os.path.join(steam_ui_path, WEB_UI_ICON_FILE) - if not os.path.exists(icon_path): - icon_path = public_path(WEB_UI_ICON_FILE) - with open(icon_path, "rb") as handle: - data = handle.read() - b64 = base64.b64encode(data).decode("ascii") - return json.dumps({"success": True, "dataUrl": f"data:image/png;base64,{b64}"}) - except Exception as exc: - logger.warn(f"LuaTools: GetIconDataUrl failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def has_luatools_for_app(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - exists = has_lua_for_app(appid) - return json.dumps({"success": True, "exists": exists}) - - -def cancel_add_via_luatools(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - state = _get_download_state(appid) - if not state or state.get("status") in {"done", "failed"}: - return json.dumps({"success": True, "message": "Nothing to cancel"}) - - _set_download_state(appid, {"status": "cancelled", "error": "Cancelled by user"}) - logger.log(f"LuaTools: Cancellation requested for appid={appid}") - return json.dumps({"success": True}) - - -def get_installed_lua_scripts() -> str: - """Get list of all installed Lua scripts from stplug-in directory.""" - try: - # Pre-load app names cache from file to avoid API calls - _preload_app_names_cache() - - base_path = detect_steam_install_path() or Millennium.steam_path() - if not base_path: - return json.dumps({"success": False, "error": "Could not find Steam installation path"}) - - target_dir = os.path.join(base_path, "config", "stplug-in") - if not os.path.exists(target_dir): - return json.dumps({"success": True, "scripts": []}) - - installed_scripts = [] - - try: - for filename in os.listdir(target_dir): - # Match both enabled (.lua) and disabled (.lua.disabled) scripts - if filename.endswith(".lua") or filename.endswith(".lua.disabled"): - try: - # Extract appid from filename - appid_str = filename.replace(".lua.disabled", "").replace(".lua", "") - appid = int(appid_str) - - # Check if it's disabled - is_disabled = filename.endswith(".lua.disabled") - - # Try to get game name from cache (no API calls during listing) - game_name = "" - with APP_NAME_CACHE_LOCK: - game_name = APP_NAME_CACHE.get(appid, "") - - # Fallback to loaded_apps file if not in cache - # (_get_loaded_app_name also checks applist as fallback) - if not game_name: - game_name = _get_loaded_app_name(appid) - - # Only use "Unknown Game" as last resort - don't fetch from API - if not game_name: - game_name = f"Unknown Game ({appid})" - - # Get file stats - file_path = os.path.join(target_dir, filename) - file_stat = os.stat(file_path) - file_size = file_stat.st_size - - # Format date - modified_time = datetime.datetime.fromtimestamp(file_stat.st_mtime) - formatted_date = modified_time.strftime("%Y-%m-%d %H:%M:%S") - - script_info = { - "appid": appid, - "gameName": game_name, - "filename": filename, - "isDisabled": is_disabled, - "fileSize": file_size, - "modifiedDate": formatted_date, - "path": file_path - } - - installed_scripts.append(script_info) - - except ValueError: - # Not a numeric filename, skip - continue - except Exception as exc: - logger.warn(f"LuaTools: Failed to process Lua file {filename}: {exc}") - continue - - except Exception as exc: - logger.warn(f"LuaTools: Failed to scan stplug-in directory: {exc}") - return json.dumps({"success": False, "error": f"Failed to scan directory: {str(exc)}"}) - - # Sort by appid - installed_scripts.sort(key=lambda x: x["appid"]) - - return json.dumps({"success": True, "scripts": installed_scripts}) - - except Exception as exc: - logger.warn(f"LuaTools: Failed to get installed Lua scripts: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -__all__ = [ - "cancel_add_via_luatools", - "delete_luatools_for_app", - "dismiss_loaded_apps", - "fetch_app_name", - "fetch_app_info", - "get_add_status", - "get_games_database", - "get_icon_data_url", - "get_installed_lua_scripts", - "has_luatools_for_app", - "init_applist", - "init_games_db", - "read_loaded_apps", - "start_add_via_luatools", - "check_apis_for_app", - "start_add_via_luatools_from_url", -] diff --git a/backend/fixes.py b/backend/fixes.py deleted file mode 100644 index 433656f..0000000 --- a/backend/fixes.py +++ /dev/null @@ -1,715 +0,0 @@ -"""Game fix lookup, application, and removal logic.""" - -from __future__ import annotations - -import json -import os -import threading -import time -import zipfile -from datetime import datetime -from typing import Any, Dict, Optional, Set - -from downloads import fetch_app_name -from http_client import ensure_http_client -from logger import logger -from utils import ensure_temp_download_dir -from steam_utils import get_game_install_path_response - -FIX_DOWNLOAD_STATE: Dict[int, Dict[str, Any]] = {} -FIX_DOWNLOAD_LOCK = threading.Lock() -UNFIX_STATE: Dict[int, Dict[str, Any]] = {} -UNFIX_LOCK = threading.Lock() - -# ── Fixes index cache (fetched once at startup, cached for session) ────── -FIXES_INDEX_URL = "https://index.luatools.work/fixes-index.json" -_fixes_index_lock = threading.Lock() -_fixes_index_cache: Optional[Dict] = None - - -def _fetch_fixes_index() -> Optional[Dict]: - """Fetch and cache the fixes index JSON. Returns None on failure.""" - global _fixes_index_cache - - with _fixes_index_lock: - if _fixes_index_cache is not None: - return _fixes_index_cache - - # Fetch outside the lock to avoid blocking other threads - try: - client = ensure_http_client("LuaTools: FixesIndex") - resp = client.get(FIXES_INDEX_URL, follow_redirects=True, timeout=10) - if resp.status_code == 200: - data = resp.json() - generic_set = set(data.get("genericFixes", [])) - online_set = set(data.get("onlineFixes", [])) - index = {"generic": generic_set, "online": online_set} - with _fixes_index_lock: - _fixes_index_cache = index - logger.log(f"LuaTools: Fixes index loaded ({len(generic_set)} generic, {len(online_set)} online)") - return index - else: - logger.warn(f"LuaTools: Fixes index fetch returned {resp.status_code}") - except Exception as exc: - logger.warn(f"LuaTools: Failed to fetch fixes index: {exc}") - - return None - - -def init_fixes_index() -> None: - """Pre-fetch fixes index at startup. Called once during Plugin._load().""" - _fetch_fixes_index() - - -def _is_safe_path(base_path: str, target_path: str) -> bool: - """Check if target_path stays within base_path (prevents path traversal attacks).""" - abs_base = os.path.abspath(base_path) - abs_target = os.path.abspath(os.path.join(base_path, target_path)) - return abs_target.startswith(abs_base + os.sep) or abs_target == abs_base - - -def _set_fix_download_state(appid: int, update: dict) -> None: - with FIX_DOWNLOAD_LOCK: - state = FIX_DOWNLOAD_STATE.get(appid) or {} - state.update(update) - FIX_DOWNLOAD_STATE[appid] = state - - -def _get_fix_download_state(appid: int) -> dict: - with FIX_DOWNLOAD_LOCK: - return FIX_DOWNLOAD_STATE.get(appid, {}).copy() - - -def _set_unfix_state(appid: int, update: dict) -> None: - with UNFIX_LOCK: - state = UNFIX_STATE.get(appid) or {} - state.update(update) - UNFIX_STATE[appid] = state - - -def _get_unfix_state(appid: int) -> dict: - with UNFIX_LOCK: - return UNFIX_STATE.get(appid, {}).copy() - - -def check_for_fixes(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - result = { - "success": True, - "appid": appid, - "gameName": "", - "genericFix": {"status": 0, "available": False}, - "onlineFix": {"status": 0, "available": False}, - } - - try: - result["gameName"] = fetch_app_name(appid) or f"Unknown Game ({appid})" - except Exception as exc: - logger.warn(f"LuaTools: Failed to fetch game name for {appid}: {exc}") - result["gameName"] = f"Unknown Game ({appid})" - - # Use the cached fixes index instead of HEAD requests to R2 - index = _fetch_fixes_index() - if index is not None: - generic_url = f"https://files.luatools.work/GameBypasses/{appid}.zip" - online_url = f"https://files.luatools.work/OnlineFix1/{appid}.zip" - - if appid in index["generic"]: - result["genericFix"]["status"] = 200 - result["genericFix"]["available"] = True - result["genericFix"]["url"] = generic_url - else: - result["genericFix"]["status"] = 404 - - if appid in index["online"]: - result["onlineFix"]["status"] = 200 - result["onlineFix"]["available"] = True - result["onlineFix"]["url"] = online_url - else: - result["onlineFix"]["status"] = 404 - - logger.log(f"LuaTools: Fix check for {appid} via index: generic={appid in index['generic']}, online={appid in index['online']}") - else: - # Fallback: HEAD requests if index is unavailable - logger.warn(f"LuaTools: Fixes index unavailable, falling back to HEAD requests for {appid}") - client = ensure_http_client("LuaTools: CheckForFixes") - try: - generic_url = f"https://files.luatools.work/GameBypasses/{appid}.zip" - resp = client.head(generic_url, follow_redirects=True, timeout=10) - result["genericFix"]["status"] = resp.status_code - result["genericFix"]["available"] = resp.status_code == 200 - if resp.status_code == 200: - result["genericFix"]["url"] = generic_url - except Exception as exc: - logger.warn(f"LuaTools: Generic fix check failed for {appid}: {exc}") - - try: - online_url = f"https://files.luatools.work/OnlineFix1/{appid}.zip" - resp = client.head(online_url, follow_redirects=True, timeout=10) - result["onlineFix"]["status"] = resp.status_code - result["onlineFix"]["available"] = resp.status_code == 200 - if resp.status_code == 200: - result["onlineFix"]["url"] = online_url - except Exception as exc: - logger.warn(f"LuaTools: Online-fix check failed for {appid}: {exc}") - - return json.dumps(result) - - -def _download_and_extract_fix(appid: int, download_url: str, install_path: str, fix_type: str, game_name: str = ""): - client = ensure_http_client("LuaTools: fix download") - try: - dest_root = ensure_temp_download_dir() - dest_zip = os.path.join(dest_root, f"fix_{appid}.zip") - _set_fix_download_state(appid, {"status": "downloading", "bytesRead": 0, "totalBytes": 0, "error": None}) - - logger.log(f"LuaTools: Downloading {fix_type} from {download_url}") - - with client.stream("GET", download_url, follow_redirects=True, timeout=30) as resp: - logger.log(f"LuaTools: Fix download response for {appid}: status={resp.status_code}") - resp.raise_for_status() - total = int(resp.headers.get("Content-Length", "0") or "0") - _set_fix_download_state(appid, {"totalBytes": total}) - - with open(dest_zip, "wb") as output: - for chunk in resp.iter_bytes(): - if not chunk: - continue - state = _get_fix_download_state(appid) - if state.get("status") == "cancelled": - logger.log(f"LuaTools: Fix download cancelled before writing chunk for {appid}") - raise RuntimeError("cancelled") - output.write(chunk) - read = int(state.get("bytesRead", 0)) + len(chunk) - _set_fix_download_state(appid, {"bytesRead": read}) - if _get_fix_download_state(appid).get("status") == "cancelled": - logger.log(f"LuaTools: Fix download cancelled for {appid}") - raise RuntimeError("cancelled") - - logger.log(f"LuaTools: Download complete, extracting to {install_path}") - _set_fix_download_state(appid, {"status": "extracting"}) - - extracted_files = [] - with zipfile.ZipFile(dest_zip, "r") as archive: - all_names = archive.namelist() - appid_folder = f"{appid}/" - - top_level_entries = set() - for name in all_names: - parts = name.split("/") - if parts[0]: - top_level_entries.add(parts[0]) - if _get_fix_download_state(appid).get("status") == "cancelled": - logger.log(f"LuaTools: Fix extraction cancelled before start for {appid}") - raise RuntimeError("cancelled") - - if len(top_level_entries) == 1 and appid_folder.rstrip("/") in top_level_entries: - logger.log(f"LuaTools: Found single folder {appid} in zip, extracting its contents") - for member in archive.namelist(): - if member.startswith(appid_folder) and member != appid_folder: - target_path = member[len(appid_folder):] - if not target_path: - continue - # Validate path doesn't escape install directory (prevent path traversal) - if not _is_safe_path(install_path, target_path): - logger.warn(f"LuaTools: Skipping potentially unsafe path in zip: {member}") - continue - source = archive.open(member) - target = os.path.join(install_path, target_path) - os.makedirs(os.path.dirname(target), exist_ok=True) - if not member.endswith("/"): - with open(target, "wb") as output: - output.write(source.read()) - extracted_files.append(target_path.replace("\\", "/")) - source.close() - if _get_fix_download_state(appid).get("status") == "cancelled": - logger.log(f"LuaTools: Fix extraction cancelled mid-process for {appid}") - raise RuntimeError("cancelled") - else: - logger.log(f"LuaTools: Extracting all zip contents to {install_path}") - for member in archive.namelist(): - if member.endswith("/"): - continue - # Validate path doesn't escape install directory (prevent path traversal) - if not _is_safe_path(install_path, member): - logger.warn(f"LuaTools: Skipping potentially unsafe path in zip: {member}") - continue - archive.extract(member, install_path) - extracted_files.append(member.replace("\\", "/")) - if _get_fix_download_state(appid).get("status") == "cancelled": - logger.log(f"LuaTools: Fix extraction cancelled mid-process for {appid}") - raise RuntimeError("cancelled") - - if _get_fix_download_state(appid).get("status") == "cancelled": - logger.log(f"LuaTools: Fix cancelled after extraction for {appid}") - raise RuntimeError("cancelled") - - ini_relative_path = None - for rel_path in extracted_files: - if rel_path.replace("\\", "/").lower().endswith("unsteam.ini"): - ini_relative_path = rel_path - break - - if fix_type.lower() == "online fix (unsteam)": - try: - if ini_relative_path: - ini_full_path = os.path.join(install_path, ini_relative_path.replace("/", os.sep)) - if os.path.exists(ini_full_path): - with open(ini_full_path, "r", encoding="utf-8", errors="ignore") as ini_file: - contents = ini_file.read() - updated_contents = contents.replace("", str(appid)) - if updated_contents != contents: - with open(ini_full_path, "w", encoding="utf-8") as ini_file: - ini_file.write(updated_contents) - logger.log(f"LuaTools: Updated unsteam.ini with appid {appid}") - else: - logger.log("LuaTools: unsteam.ini did not contain placeholder or was already updated") - else: - logger.warn(f"LuaTools: Expected unsteam.ini at {ini_full_path} but file not found") - else: - logger.warn("LuaTools: Extracted files do not include unsteam.ini for Online Fix (Unsteam)") - except Exception as exc: - logger.warn(f"LuaTools: Failed to update unsteam.ini: {exc}") - - log_file_path = os.path.join(install_path, f"luatools-fix-log-{appid}.log") - try: - # Read existing log to preserve previous fixes - existing_content = "" - if os.path.exists(log_file_path): - try: - with open(log_file_path, "r", encoding="utf-8") as log_file: - existing_content = log_file.read() - except Exception: - pass - - # Append new fix entry - with open(log_file_path, "w", encoding="utf-8") as log_file: - # Write existing content first - if existing_content: - log_file.write(existing_content) - if not existing_content.endswith("\n"): - log_file.write("\n") - log_file.write("\n---\n\n") # Separator between fixes - - # Write new fix entry - log_file.write(f'[FIX]\n') - log_file.write(f'Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n') - log_file.write(f'Game: {game_name or f"Unknown Game ({appid})"}\n') - log_file.write(f"Fix Type: {fix_type}\n") - log_file.write(f"Download URL: {download_url}\n") - log_file.write("Files:\n") - for file_path in extracted_files: - log_file.write(f"{file_path}\n") - log_file.write("[/FIX]\n") - - logger.log(f"LuaTools: Appended fix log at {log_file_path} with {len(extracted_files)} files") - except Exception as exc: - logger.warn(f"LuaTools: Failed to create fix log file: {exc}") - - logger.log(f"LuaTools: {fix_type} applied successfully to {install_path}") - _set_fix_download_state(appid, {"status": "done", "success": True}) - - try: - os.remove(dest_zip) - except Exception: - pass - - except Exception as exc: - if str(exc) == "cancelled": - try: - if os.path.exists(dest_zip): - os.remove(dest_zip) - except Exception: - pass - _set_fix_download_state(appid, {"status": "cancelled", "success": False, "error": "Cancelled by user"}) - return - logger.warn(f"LuaTools: Failed to apply fix: {exc}") - _set_fix_download_state(appid, {"status": "failed", "error": str(exc)}) - - -def apply_game_fix(appid: int, download_url: str, install_path: str, fix_type: str = "", game_name: str = "") -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - if not download_url or not install_path: - return json.dumps({"success": False, "error": "Missing download URL or install path"}) - - if not os.path.exists(install_path): - return json.dumps({"success": False, "error": "Install path does not exist"}) - - logger.log(f"LuaTools: ApplyGameFix appid={appid}, fixType={fix_type}") - - _set_fix_download_state(appid, {"status": "queued", "bytesRead": 0, "totalBytes": 0, "error": None}) - thread = threading.Thread( - target=_download_and_extract_fix, args=(appid, download_url, install_path, fix_type, game_name), daemon=True - ) - thread.start() - - return json.dumps({"success": True}) - - -def get_apply_fix_status(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - state = _get_fix_download_state(appid) - return json.dumps({"success": True, "state": state}) - - -def cancel_apply_fix(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - state = _get_fix_download_state(appid) - if not state or state.get("status") in {"done", "failed"}: - return json.dumps({"success": True, "message": "Nothing to cancel"}) - - _set_fix_download_state(appid, {"status": "cancelled", "success": False, "error": "Cancelled by user"}) - logger.log(f"LuaTools: CancelApplyFix requested for appid={appid}") - return json.dumps({"success": True}) - - -def _unfix_game_worker(appid: int, install_path: str, fix_date: str = None): - try: - logger.log(f"LuaTools: Starting un-fix for appid {appid}, fix_date={fix_date}") - log_file_path = os.path.join(install_path, f"luatools-fix-log-{appid}.log") - - if not os.path.exists(log_file_path): - _set_unfix_state(appid, {"status": "failed", "error": "No fix log found. Cannot un-fix."}) - return - - _set_unfix_state(appid, {"status": "removing", "progress": "Reading log file..."}) - - files_to_delete = set() # Use set to avoid duplicates - remaining_fixes = [] # Fixes to keep in the log - - try: - with open(log_file_path, "r", encoding="utf-8") as handle: - log_content = handle.read() - - # Parse multiple fixes (new format with [FIX] markers) - if "[FIX]" in log_content: - # New format with multiple fixes - fix_blocks = log_content.split("[FIX]") - for block in fix_blocks: - if not block.strip(): - continue - - lines = block.split("\n") - in_files_section = False - block_date = None - block_lines = [] # Store original block content - - for line in lines: - line_stripped = line.strip() - if line_stripped == "[/FIX]" or line_stripped == "---": - break - if line_stripped.startswith("Date:"): - block_date = line_stripped.replace("Date:", "").strip() - - block_lines.append(line) - - if line_stripped == "Files:": - in_files_section = True - elif in_files_section and line_stripped: - # If we're deleting a specific fix, only add files from that fix - if fix_date is None or (block_date and block_date == fix_date): - files_to_delete.add(line_stripped) - - # If we're deleting a specific fix, keep the others - if fix_date is not None and block_date and block_date != fix_date: - remaining_fixes.append("[FIX]\n" + "\n".join(block_lines) + "\n[/FIX]") - else: - # Old format (single fix without markers) - legacy support - # Delete all files (no individual selection possible) - lines = log_content.split("\n") - in_files_section = False - for line in lines: - line = line.strip() - if line == "Files:": - in_files_section = True - elif in_files_section and line: - files_to_delete.add(line) - - logger.log(f"LuaTools: Found {len(files_to_delete)} unique files to remove from log") - except Exception as exc: - logger.warn(f"LuaTools: Failed to read log file: {exc}") - _set_unfix_state(appid, {"status": "failed", "error": f"Failed to read log file: {str(exc)}"}) - return - - _set_unfix_state(appid, {"status": "removing", "progress": f"Removing {len(files_to_delete)} files..."}) - deleted_count = 0 - for file_path in files_to_delete: - try: - full_path = os.path.join(install_path, file_path) - if os.path.exists(full_path): - os.remove(full_path) - deleted_count += 1 - logger.log(f"LuaTools: Deleted {file_path}") - except Exception as exc: - logger.warn(f"LuaTools: Failed to delete {file_path}: {exc}") - - logger.log(f"LuaTools: Deleted {deleted_count}/{len(files_to_delete)} files") - - # Update or delete the log file - if remaining_fixes: - # We deleted a specific fix, update the log with remaining fixes - try: - with open(log_file_path, "w", encoding="utf-8") as handle: - handle.write("\n\n---\n\n".join(remaining_fixes)) - logger.log(f"LuaTools: Updated log file, {len(remaining_fixes)} fixes remaining") - except Exception as exc: - logger.warn(f"LuaTools: Failed to update log file: {exc}") - else: - # No fixes remaining, delete the log file - try: - os.remove(log_file_path) - logger.log(f"LuaTools: Deleted log file {log_file_path}") - except Exception as exc: - logger.warn(f"LuaTools: Failed to delete log file: {exc}") - - _set_unfix_state(appid, {"status": "done", "success": True, "filesRemoved": deleted_count}) - - except Exception as exc: - logger.warn(f"LuaTools: Un-fix failed: {exc}") - _set_unfix_state(appid, {"status": "failed", "error": str(exc)}) - - -def unfix_game(appid: int, install_path: str = "", fix_date: str = "") -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - resolved_path = install_path - if not resolved_path: - try: - result = get_game_install_path_response(appid) - if not result.get("success") or not result.get("installPath"): - return json.dumps({"success": False, "error": "Could not find game install path"}) - resolved_path = result["installPath"] - except Exception as exc: - return json.dumps({"success": False, "error": f"Failed to get install path: {str(exc)}"}) - - if not os.path.exists(resolved_path): - return json.dumps({"success": False, "error": "Install path does not exist"}) - - logger.log(f"LuaTools: UnFixGame appid={appid}, path={resolved_path}, fix_date={fix_date}") - - _set_unfix_state(appid, {"status": "queued", "progress": "", "error": None}) - thread = threading.Thread(target=_unfix_game_worker, args=(appid, resolved_path, fix_date or None), daemon=True) - thread.start() - - return json.dumps({"success": True}) - - -def get_unfix_status(appid: int) -> str: - try: - appid = int(appid) - except Exception: - return json.dumps({"success": False, "error": "Invalid appid"}) - - state = _get_unfix_state(appid) - return json.dumps({"success": True, "state": state}) - - -def get_installed_fixes() -> str: - """Scan all Steam library folders for games with luatools fix logs.""" - try: - from steam_utils import _find_steam_path, _parse_vdf_simple - - steam_path = _find_steam_path() - if not steam_path: - return json.dumps({"success": False, "error": "Could not find Steam installation path"}) - - library_vdf_path = os.path.join(steam_path, "config", "libraryfolders.vdf") - if not os.path.exists(library_vdf_path): - return json.dumps({"success": False, "error": "Could not find libraryfolders.vdf"}) - - try: - with open(library_vdf_path, "r", encoding="utf-8") as handle: - vdf_content = handle.read() - library_data = _parse_vdf_simple(vdf_content) - except Exception as exc: - logger.warn(f"LuaTools: Failed to parse libraryfolders.vdf: {exc}") - return json.dumps({"success": False, "error": "Failed to parse libraryfolders.vdf"}) - - library_folders = library_data.get("libraryfolders", {}) - all_library_paths = [] - - for folder_data in library_folders.values(): - if isinstance(folder_data, dict): - folder_path = folder_data.get("path", "") - if folder_path: - folder_path = folder_path.replace("\\\\", "\\") - all_library_paths.append(folder_path) - - installed_fixes = [] - - for lib_path in all_library_paths: - steamapps_path = os.path.join(lib_path, "steamapps") - if not os.path.exists(steamapps_path): - continue - - # Get all appmanifest files - try: - for filename in os.listdir(steamapps_path): - if not filename.startswith("appmanifest_") or not filename.endswith(".acf"): - continue - - # Extract appid from filename - try: - appid_str = filename.replace("appmanifest_", "").replace(".acf", "") - appid = int(appid_str) - except Exception: - continue - - # Parse manifest to get install directory - manifest_path = os.path.join(steamapps_path, filename) - try: - with open(manifest_path, "r", encoding="utf-8") as handle: - manifest_content = handle.read() - manifest_data = _parse_vdf_simple(manifest_content) - app_state = manifest_data.get("AppState", {}) - install_dir = app_state.get("installdir", "") - game_name = app_state.get("name", f"Unknown Game ({appid})") - - if not install_dir: - continue - - full_install_path = os.path.join(lib_path, "steamapps", "common", install_dir) - if not os.path.exists(full_install_path): - continue - - # Check for luatools fix log - log_file_path = os.path.join(full_install_path, f"luatools-fix-log-{appid}.log") - if os.path.exists(log_file_path): - # Parse the log file to get fix info (supports multiple fixes) - try: - with open(log_file_path, "r", encoding="utf-8") as log_handle: - log_content = log_handle.read() - - # Parse multiple fixes (new format with [FIX] markers) - fixes_in_log = [] - if "[FIX]" in log_content: - # New format with multiple fixes - fix_blocks = log_content.split("[FIX]") - for block in fix_blocks: - if not block.strip(): - continue - - # Extract data from this fix block - fix_data = { - "appid": appid, - "gameName": game_name, - "installPath": full_install_path, - "date": "", - "fixType": "", - "downloadUrl": "", - "filesCount": 0, - "files": [] - } - - lines = block.split("\n") - in_files_section = False - - for line in lines: - line = line.strip() - if line == "[/FIX]" or line == "---": - break - if line.startswith("Date:"): - fix_data["date"] = line.replace("Date:", "").strip() - elif line.startswith("Game:"): - log_game_name = line.replace("Game:", "").strip() - if log_game_name and log_game_name != f"Unknown Game ({appid})": - fix_data["gameName"] = log_game_name - elif line.startswith("Fix Type:"): - fix_data["fixType"] = line.replace("Fix Type:", "").strip() - elif line.startswith("Download URL:"): - fix_data["downloadUrl"] = line.replace("Download URL:", "").strip() - elif line == "Files:": - in_files_section = True - elif in_files_section and line: - fix_data["files"].append(line) - - fix_data["filesCount"] = len(fix_data["files"]) - if fix_data["date"]: # Only add if it has a date (valid fix) - fixes_in_log.append(fix_data) - else: - # Old format (single fix without markers) - legacy support - log_lines = log_content.split("\n") - fix_data = { - "appid": appid, - "gameName": game_name, - "installPath": full_install_path, - "date": "", - "fixType": "", - "downloadUrl": "", - "filesCount": 0, - "files": [] - } - - in_files_section = False - for line in log_lines: - line = line.strip() - if line.startswith("Date:"): - fix_data["date"] = line.replace("Date:", "").strip() - elif line.startswith("Game:"): - log_game_name = line.replace("Game:", "").strip() - if log_game_name and log_game_name != f"Unknown Game ({appid})": - fix_data["gameName"] = log_game_name - elif line.startswith("Fix Type:"): - fix_data["fixType"] = line.replace("Fix Type:", "").strip() - elif line.startswith("Download URL:"): - fix_data["downloadUrl"] = line.replace("Download URL:", "").strip() - elif line == "Files:": - in_files_section = True - elif in_files_section and line: - fix_data["files"].append(line) - - fix_data["filesCount"] = len(fix_data["files"]) - if fix_data["date"]: - fixes_in_log.append(fix_data) - - # Add all fixes found for this game - for fix in fixes_in_log: - installed_fixes.append(fix) - - except Exception as exc: - logger.warn(f"LuaTools: Failed to parse fix log for {appid}: {exc}") - - except Exception as exc: - logger.warn(f"LuaTools: Failed to process manifest {filename}: {exc}") - continue - - except Exception as exc: - logger.warn(f"LuaTools: Failed to scan library {lib_path}: {exc}") - continue - - return json.dumps({"success": True, "fixes": installed_fixes}) - - except Exception as exc: - logger.warn(f"LuaTools: Failed to get installed fixes: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -__all__ = [ - "apply_game_fix", - "cancel_apply_fix", - "check_for_fixes", - "get_apply_fix_status", - "get_installed_fixes", - "get_unfix_status", - "unfix_game", -] - diff --git a/backend/http_client.lua b/backend/http_client.lua index 97b5afe..e517f46 100644 --- a/backend/http_client.lua +++ b/backend/http_client.lua @@ -9,6 +9,18 @@ function http_client.get(url, options) return m_http.get(url, options) end +function http_client.head(url, options) + options = options or {} + options.timeout = options.timeout or config.HTTP_TIMEOUT_SECONDS + -- If Millennium m_http supports method override, we could use m_http.request, but let's assume m_http.head exists or use get with method = HEAD + if type(m_http.head) == "function" then + return m_http.head(url, options) + end + -- Fallback to standard request if head doesn't exist + options.method = "HEAD" + return m_http.get(url, options) +end + function http_client.post(url, options) options = options or {} options.timeout = options.timeout or config.HTTP_TIMEOUT_SECONDS diff --git a/backend/http_client.py b/backend/http_client.py deleted file mode 100644 index 896b774..0000000 --- a/backend/http_client.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Shared HTTP client management for the LuaTools backend.""" - -from typing import Optional - -import httpx # type: ignore - -from config import HTTP_TIMEOUT_SECONDS -from logger import logger - -_HTTP_CLIENT: Optional[httpx.Client] = None - - -def ensure_http_client(context: str = "") -> httpx.Client: - """Create the shared HTTP client if needed and return it.""" - global _HTTP_CLIENT - if _HTTP_CLIENT is None: - prefix = f"{context}: " if context else "" - logger.log(f"{prefix}Initializing shared HTTPX client...") - try: - _HTTP_CLIENT = httpx.Client(timeout=HTTP_TIMEOUT_SECONDS) - logger.log(f"{prefix}HTTPX client initialized") - except Exception as exc: - logger.error(f"{prefix}Failed to initialize HTTPX client: {exc}") - raise - return _HTTP_CLIENT - - -def get_http_client() -> httpx.Client: - """Return the shared HTTP client, creating it if necessary.""" - return ensure_http_client() - - -def close_http_client(context: str = "") -> None: - """Close and dispose of the shared HTTP client.""" - global _HTTP_CLIENT - if _HTTP_CLIENT is None: - return - - try: - _HTTP_CLIENT.close() - except Exception: - pass - finally: - _HTTP_CLIENT = None - prefix = f"{context}: " if context else "" - logger.log(f"{prefix}HTTPX client closed") - diff --git a/backend/locales/pt-BR.json b/backend/locales/pt-BR.json index bec53b3..7913387 100644 --- a/backend/locales/pt-BR.json +++ b/backend/locales/pt-BR.json @@ -1,228 +1,228 @@ -{ - "_meta": { - "code": "pt-BR", - "name": "Brazilian Portuguese", - "nativeName": "Português (Brasil)", - "credits": "ZooM" - }, - "strings": { - "Add via LuaTools": "Adicionar via LuaTools", - "Advanced": "Avançado", - "All-In-One Fixes": "Correções all-in-one", - "Apply": "Aplicar", - "Applying {fix}": "Aplicando {fix}", - "Are you sure you want to un-fix? This will remove fix files and verify game files.": "Tem certeza de que deseja remover a correção? Isso removerá os arquivos da correção e verificará os arquivos do jogo.", - "Are you sure?": "Tem certeza?", - "Back": "Voltar", - "Base Game": "Jogo Base", - "Cancel": "Cancelar", - "Cancellation failed": "Falha ao cancelar", - "Cancelled": "Cancelado", - "Cancelled by user": "Cancelado pelo usuário", - "Cancelled: {reason}": "Cancelado: {reason}", - "Cancelling...": "Cancelando...", - "Check for updates": "Buscar atualizações", - "Checking availability…": "Verificando disponibilidade…", - "Checking content…": "Verificando conteúdo…", - "Checking generic fix...": "Verificando correção genérica...", - "Checking key...": "Verificando chave...", - "Checking online-fix...": "Verificando correção do online-fix...", - "Checking…": "Verificando…", - "Close": "Fechar", - "Confirm": "Confirmar", - "Content details =>": "Detalhes do conteúdo =>", - "DLC Detected": "DLC Detectada", - "DLCs are added together with the base game. To add fixes for this DLC, please go to the base game page:

{gameName}": "DLCs são adicionadas junto com o jogo base. Para adicionar esta DLC, por favor vá para a página do jogo base:

{gameName}", - "Discord": "Discord", - "Dismiss": "Fechar", - "Dlc: ": "DLC: ", - "Downloading...": "Baixando...", - "Downloading: {percent}%": "Baixando: {percent}%", - "Downloading…": "Baixando…", - "Error applying fix": "Erro ao aplicar a correção", - "Error checking for fixes": "Erro ao verificar as correções", - "Error starting Online Fix": "Erro ao iniciar o Online Fix", - "Error starting un-fix": "Erro ao iniciar o removedor de correções", - "Error! Code: {code}": "Erro! Código: {code}", - "Error, Code: {code}": "Erro, Código: {code}", - "Error, Timed Out": "Erro, Tempo esgotado", - "Error: {error}": "Erro: {error}", - "Expires": "Expira", - "Extracting to game folder...": "Extraindo para a pasta do jogo...", - "Failed": "Falhou", - "Failed to cancel fix download": "Falha ao cancelar o download da correção", - "Failed to check for fixes.": "Falha ao verificar as correções.", - "Failed to load free APIs.": "Falha ao carregar as APIs gratuitas.", - "Failed to start fix download": "Falha ao iniciar o download da correção", - "Failed to start un-fix": "Falha ao iniciar o removedor de correções", - "Failed to verify key": "Falha ao verificar chave", - "Failed: {error}": "Falhou: {error}", - "Fetch Free API's": "Buscar APIs gratuitas", - "Fetching game name...": "Buscando nome do jogo...", - "Finishing…": "Finalizando…", - "Fixes Menu": "Menu de correções", - "Found": "Encontrado", - "Game Added!": "Jogo adicionado!", - "Game added!": "Jogo adicionado!", - "Game folder": "Pasta do jogo", - "Game install path not found": "Caminho de instalação do jogo não encontrado", - "Game not found on any available API.": "Jogo não encontrado em nenhuma API disponível.", - "Generic Fix": "Correção Genérica", - "Generic fix found!": "Correção genérica encontrada!", - "Go to Base Game": "Ir para o Jogo Base", - "Hide": "Ocultar", - "Included": "Incluído 🎉", - "Initializing download...": "Iniciando download...", - "Installing…": "Instalando…", - "Invalid Morrenus API Key format": "Formato de chave API Morrenus inválido", - "Invalid key format": "Formato de chave inválido", - "Invalid or rejected key": "Chave inválida ou rejeitada", - "Join the Discord!": "Entrar no Discord!", - "Left click to install, Right click for SteamDB": "Clique com o botão esquerdo do mouse para instalar o jogo, direito para abrir o site do SteamDB", - "Loaded free APIs: {count}": "APIs gratuitas carregadas: {count}", - "Loading APIs...": "Carregando APIs...", - "Loading fixes...": "Carregando correções...", - "Look for Fixes": "Procurar correções", - "LuaTools backend unavailable": "Backend do LuaTools indisponível", - "LuaTools · AIO Fixes Menu": "LuaTools · Menu AIO de Correções", - "LuaTools · Added Games": "LuaTools · Jogos adicionados", - "LuaTools · Fixes Menu": "LuaTools · Menu de Correções", - "LuaTools · Menu": "LuaTools · Menu", - "LuaTools · {api}": "LuaTools · {api}", - "Manage Game": "Gerenciar jogo", - "Missing": "Faltando ❌", - "No games found.": "Nenhum jogo encontrado.", - "No generic fix": "Nenhuma correção genérica encontrada.", - "No online-fix": "Nenhuma correção online-fix encontrada.", - "No updates available.": "Nenhuma atualização disponível.", - "No workshop for the game": "Nenhum workshop para o jogo ✅", - "Not found": "Não encontrado", - "Online Fix": "Correção online", - "Online Fix (Unsteam)": "Correção online (Unsteam)", - "Online-fix found!": "Online-fix encontrado!", - "Only possible thanks to {name} 💜": "Só é possível graças a {name} 💜", - "Proceed": "Continuar", - "Processing package…": "Processando pacote…", - "Remove via LuaTools": "Remover via LuaTools", - "Removed {count} files. Running Steam verification...": "{count} arquivos removidos. Executando a verificação da Steam...", - "Removing fix files...": "Removendo arquivos da correção...", - "Restart Steam": "Reiniciar Steam", - "Restart Steam now?": "Reiniciar o Steam agora?", - "Searching across sources...": "Buscando em todas as fontes...", - "Select Download Source": "Selecionar fonte de download", - "Settings": "Configurações", - "Skipped": "Ignorado", - "The game has been added successfully.": "O jogo foi adicionado com sucesso.", - "This game may not work, support for it wont be given in our discord": "Este jogo pode não funcionar, suporte não será dado em nosso discord", - "Un-Fix (verify game)": "Desfazer correção (verificar jogo)", - "Un-Fixing game": "Desfazendo correção do jogo", - "Unknown Game": "Jogo desconhecido", - "Unknown error": "Erro desconhecido", - "Usage": "Uso", - "Verifying API limits...": "Verificando limites da API...", - "Waiting…": "Aguardando…", - "Working…": "Trabalhando…", - "Workshop: ": "Workshop: ", - "You have exceeded your daily download limit. Please wait until tomorrow for more uses, or upgrade your plan on the Morrenus website.": "Você excedeu seu limite diário de downloads. Aguarde até amanhã ou atualize seu plano no site do Morrenus.", - "Your Morrenus API key is invalid or expired. Please check your key in the settings or regenerate it on the Morrenus website.": "Sua chave API Morrenus é inválida ou expirou. Verifique sua chave nas configurações ou gere uma nova no site do Morrenus.", - "bigpicture.mouseTip": "Para usar o modo mouse no Steam: Botão Guia + Joystick direito, clique com RB", - "common.alert.ok": "OK", - "common.appName": "LuaTools", - "common.error.unsupportedOption": "Tipo de opção não suportado: {type}", - "common.status.error": "Erro", - "common.status.loading": "Carregando...", - "common.status.success": "Sucesso", - "common.translationMissing": "tradução ausente", - "common.warning": "Aviso", - "days left": "dias restantes", - "disclaimer.inputLabel": "digite \"Eu Entendo\" na caixa abaixo para continuar", - "disclaimer.inputPlaceholder": "Eu Entendo", - "disclaimer.line1": "O LuaTools não é afiliado de forma alguma ao Millennium", - "disclaimer.line2": "O Millennium NÃO oferecerá suporte para este plugin no servidor do Discord deles", - "disclaimer.line3": "Você será BANIDO dos dois servidores se buscar ajuda no Discord do Millennium", - "disclaimer.title": "Aviso Importante", - "gameStatus.denuvo": "Denuvo", - "gameStatus.needsFixes": "Correção disponível", - "gameStatus.playable": "Jogável", - "gameStatus.unplayable": "Não jogável", - "menu.advancedLabel": "Avançado", - "menu.checkForUpdates": "Verificar atualizações", - "menu.discord": "Discord", - "menu.error.getPath": "Erro ao encontrar o caminho do jogo", - "menu.error.noAppId": "Não foi possível determinar o AppID do jogo", - "menu.error.noInstall": "Não foi possível encontrar a instalação do jogo", - "menu.error.notInstalled": "Jogo não instalado! Adicione e instale primeiro :D", - "menu.fetchFreeApis": "Buscar APIs gratuitas", - "menu.fixesMenu": "Menu de Correções", - "menu.joinDiscordLabel": "Entre no Discord!", - "menu.manageGameLabel": "Gerenciar jogo", - "menu.remove.confirm": "Remover LuaTools para este jogo?", - "menu.remove.failure": "Falha ao remover o LuaTools.", - "menu.remove.success": "LuaTools removido para este jogo.", - "menu.removeLuaTools": "Remover jogo via LuaTools", - "menu.settings": "Configurações", - "menu.title": "LuaTools · Menu", - "settings.close": "Fechar", - "settings.donateKeys.description": "Permitir que o LuaTools doe chaves Steam sobrando.", - "settings.donateKeys.label": "Doar chaves", - "settings.donateKeys.no": "Não", - "settings.donateKeys.yes": "Sim", - "settings.empty": "Nenhuma configuração disponível.", - "settings.error": "Falha ao carregar as configurações.", - "settings.fastDownload.description": "Escolhe automaticamente a primeira fonte disponível ao adicionar um jogo.", - "settings.fastDownload.label": "Download Rápido", - "settings.general": "Geral", - "settings.generalDescription": "Preferências globais do LuaTools.", - "settings.installedFixes.date": "Instalado:", - "settings.installedFixes.delete": "Excluir", - "settings.installedFixes.deleteConfirm": "Tem certeza de que deseja remover esta correção? Isso excluirá os arquivos da correção e executará a verificação da Steam.", - "settings.installedFixes.deleteError": "Falha ao remover correção.", - "settings.installedFixes.deleteSuccess": "Correção removida com sucesso!", - "settings.installedFixes.deleting": "Removendo correção...", - "settings.installedFixes.empty": "Nenhuma correção instalada ainda.", - "settings.installedFixes.error": "Falha ao carregar correções instaladas.", - "settings.installedFixes.files": "{count} arquivos", - "settings.installedFixes.loading": "Procurando correções instaladas...", - "settings.installedFixes.title": "Correções Instaladas", - "settings.installedFixes.type": "Tipo:", - "settings.installedLua.delete": "Remover", - "settings.installedLua.deleteConfirm": "Remover via LuaTools para este jogo?", - "settings.installedLua.deleteError": "Falha ao remover via LuaTools.", - "settings.installedLua.deleteSuccess": "Removido via LuaTools com sucesso!", - "settings.installedLua.deleting": "Removendo via LuaTools...", - "settings.installedLua.disabled": "Desabilitado", - "settings.installedLua.empty": "Nenhum script Lua instalado ainda.", - "settings.installedLua.error": "Falha ao carregar scripts Lua instalados.", - "settings.installedLua.loading": "Procurando scripts Lua instalados...", - "settings.installedLua.modified": "Modificado:", - "settings.installedLua.title": "Jogos via LuaTools", - "settings.installedLua.unknownInfo": "Jogos mostrando 'Jogo Desconhecido' foram instalados de fontes externas (não via LuaTools).", - "settings.language.description": "Escolha o idioma utilizado pelo LuaTools.", - "settings.language.label": "Idioma", - "settings.language.option.en": "Inglês", - "settings.language.option.pt-BR": "Português (Brasil)", - "settings.loading": "Carregando configurações...", - "settings.noChanges": "Nenhuma alteração para salvar.", - "settings.refresh": "Atualizar", - "settings.refreshing": "Atualizando...", - "settings.save": "Salvar Configurações", - "settings.saveError": "Falha ao salvar as configurações.", - "settings.saveSuccess": "Configurações salvas com sucesso.", - "settings.saving": "Salvando...", - "settings.search.clear": "Limpar busca", - "settings.search.noResults": "Nenhum resultado encontrado", - "settings.search.placeholder": "Buscar configurações, jogos, correções...", - "settings.theme.description": "Escolha o tema de cores para a interface do LuaTools.", - "settings.theme.label": "Tema", - "settings.title": "LuaTools · Configurações", - "settings.unsaved": "Alterações não salvas", - "settings.useSteamLanguage.description": "Utilizar o idioma do cliente Steam ao invés da configuração do LuaTools.", - "settings.useSteamLanguage.label": "Usar Idioma do Steam", - "settings.useSteamLanguage.no": "Não", - "settings.useSteamLanguage.yes": "Sim", - "{fix} applied successfully!": "{fix} aplicado com sucesso!", - "settings.morrenusApiKey.label": "Chave API do Morrenus", - "settings.morrenusApiKey.description": "Chave API necessária pra usar a API Sadie. Pegue pelo {link}", - "settings.morrenusApiKey.placeholder": "Insira aqui sua chave" - } -} \ No newline at end of file +{ + "_meta": { + "code": "pt-BR", + "name": "Brazilian Portuguese", + "nativeName": "Português (Brasil)", + "credits": "ZooM" + }, + "strings": { + "Add via LuaTools": "Adicionar via LuaTools", + "Advanced": "Avançado", + "All-In-One Fixes": "Correções all-in-one", + "Apply": "Aplicar", + "Applying {fix}": "Aplicando {fix}", + "Are you sure you want to un-fix? This will remove fix files and verify game files.": "Tem certeza de que deseja remover a correção? Isso removerá os arquivos da correção e verificará os arquivos do jogo.", + "Are you sure?": "Tem certeza?", + "Back": "Voltar", + "Base Game": "Jogo Base", + "Cancel": "Cancelar", + "Cancellation failed": "Falha ao cancelar", + "Cancelled": "Cancelado", + "Cancelled by user": "Cancelado pelo usuário", + "Cancelled: {reason}": "Cancelado: {reason}", + "Cancelling...": "Cancelando...", + "Check for updates": "Buscar atualizações", + "Checking availability…": "Verificando disponibilidade…", + "Checking content…": "Verificando conteúdo…", + "Checking generic fix...": "Verificando correção genérica...", + "Checking key...": "Verificando chave...", + "Checking online-fix...": "Verificando correção do online-fix...", + "Checking…": "Verificando…", + "Close": "Fechar", + "Confirm": "Confirmar", + "Content details =>": "Detalhes do conteúdo =>", + "DLC Detected": "DLC Detectada", + "DLCs are added together with the base game. To add fixes for this DLC, please go to the base game page:

{gameName}": "DLCs são adicionadas junto com o jogo base. Para adicionar esta DLC, por favor vá para a página do jogo base:

{gameName}", + "Discord": "Discord", + "Dismiss": "Fechar", + "Dlc: ": "DLC: ", + "Downloading...": "Baixando...", + "Downloading: {percent}%": "Baixando: {percent}%", + "Downloading…": "Baixando…", + "Error applying fix": "Erro ao aplicar a correção", + "Error checking for fixes": "Erro ao verificar as correções", + "Error starting Online Fix": "Erro ao iniciar o Online Fix", + "Error starting un-fix": "Erro ao iniciar o removedor de correções", + "Error! Code: {code}": "Erro! Código: {code}", + "Error, Code: {code}": "Erro, Código: {code}", + "Error, Timed Out": "Erro, Tempo esgotado", + "Error: {error}": "Erro: {error}", + "Expires": "Expira", + "Extracting to game folder...": "Extraindo para a pasta do jogo...", + "Failed": "Falhou", + "Failed to cancel fix download": "Falha ao cancelar o download da correção", + "Failed to check for fixes.": "Falha ao verificar as correções.", + "Failed to load free APIs.": "Falha ao carregar as APIs gratuitas.", + "Failed to start fix download": "Falha ao iniciar o download da correção", + "Failed to start un-fix": "Falha ao iniciar o removedor de correções", + "Failed to verify key": "Falha ao verificar chave", + "Failed: {error}": "Falhou: {error}", + "Fetch Free API's": "Buscar APIs gratuitas", + "Fetching game name...": "Buscando nome do jogo...", + "Finishing…": "Finalizando…", + "Fixes Menu": "Menu de correções", + "Found": "Encontrado", + "Game Added!": "Jogo adicionado!", + "Game added!": "Jogo adicionado!", + "Game folder": "Pasta do jogo", + "Game install path not found": "Caminho de instalação do jogo não encontrado", + "Game not found on any available API.": "Jogo não encontrado em nenhuma API disponível.", + "Generic Fix": "Correção Genérica", + "Generic fix found!": "Correção genérica encontrada!", + "Go to Base Game": "Ir para o Jogo Base", + "Hide": "Ocultar", + "Included": "Incluído 🎉", + "Initializing download...": "Iniciando download...", + "Installing…": "Instalando…", + "Invalid Morrenus API Key format": "Formato de chave API Morrenus inválido", + "Invalid key format": "Formato de chave inválido", + "Invalid or rejected key": "Chave inválida ou rejeitada", + "Join the Discord!": "Entrar no Discord!", + "Left click to install, Right click for SteamDB": "Clique com o botão esquerdo do mouse para instalar o jogo, direito para abrir o site do SteamDB", + "Loaded free APIs: {count}": "APIs gratuitas carregadas: {count}", + "Loading APIs...": "Carregando APIs...", + "Loading fixes...": "Carregando correções...", + "Look for Fixes": "Procurar correções", + "LuaTools backend unavailable": "Backend do LuaTools indisponível", + "LuaTools · AIO Fixes Menu": "LuaTools · Menu AIO de Correções", + "LuaTools · Added Games": "LuaTools · Jogos adicionados", + "LuaTools · Fixes Menu": "LuaTools · Menu de Correções", + "LuaTools · Menu": "LuaTools · Menu", + "LuaTools · {api}": "LuaTools · {api}", + "Manage Game": "Gerenciar jogo", + "Missing": "Faltando ❌", + "No games found.": "Nenhum jogo encontrado.", + "No generic fix": "Nenhuma correção genérica encontrada.", + "No online-fix": "Nenhuma correção online-fix encontrada.", + "No updates available.": "Nenhuma atualização disponível.", + "No workshop for the game": "Nenhum workshop para o jogo ✅", + "Not found": "Não encontrado", + "Online Fix": "Correção online", + "Online Fix (Unsteam)": "Correção online (Unsteam)", + "Online-fix found!": "Online-fix encontrado!", + "Only possible thanks to {name} 💜": "Só é possível graças a {name} 💜", + "Proceed": "Continuar", + "Processing package…": "Processando pacote…", + "Remove via LuaTools": "Remover via LuaTools", + "Removed {count} files. Running Steam verification...": "{count} arquivos removidos. Executando a verificação da Steam...", + "Removing fix files...": "Removendo arquivos da correção...", + "Restart Steam": "Reiniciar Steam", + "Restart Steam now?": "Reiniciar o Steam agora?", + "Searching across sources...": "Buscando em todas as fontes...", + "Select Download Source": "Selecionar fonte de download", + "Settings": "Configurações", + "Skipped": "Ignorado", + "The game has been added successfully.": "O jogo foi adicionado com sucesso.", + "This game may not work, support for it wont be given in our discord": "Este jogo pode não funcionar, suporte não será dado em nosso discord", + "Un-Fix (verify game)": "Desfazer correção (verificar jogo)", + "Un-Fixing game": "Desfazendo correção do jogo", + "Unknown Game": "Jogo desconhecido", + "Unknown error": "Erro desconhecido", + "Usage": "Uso", + "Verifying API limits...": "Verificando limites da API...", + "Waiting…": "Aguardando…", + "Working…": "Trabalhando…", + "Workshop: ": "Workshop: ", + "You have exceeded your daily download limit. Please wait until tomorrow for more uses, or upgrade your plan on the Morrenus website.": "Você excedeu seu limite diário de downloads. Aguarde até amanhã ou atualize seu plano no site do Morrenus.", + "Your Morrenus API key is invalid or expired. Please check your key in the settings or regenerate it on the Morrenus website.": "Sua chave API Morrenus é inválida ou expirou. Verifique sua chave nas configurações ou gere uma nova no site do Morrenus.", + "bigpicture.mouseTip": "Para usar o modo mouse no Steam: Botão Guia + Joystick direito, clique com RB", + "common.alert.ok": "OK", + "common.appName": "LuaTools", + "common.error.unsupportedOption": "Tipo de opção não suportado: {type}", + "common.status.error": "Erro", + "common.status.loading": "Carregando...", + "common.status.success": "Sucesso", + "common.translationMissing": "tradução ausente", + "common.warning": "Aviso", + "days left": "dias restantes", + "disclaimer.inputLabel": "digite \"Eu Entendo\" na caixa abaixo para continuar", + "disclaimer.inputPlaceholder": "Eu Entendo", + "disclaimer.line1": "O LuaTools não é afiliado de forma alguma ao Millennium", + "disclaimer.line2": "O Millennium NÃO oferecerá suporte para este plugin no servidor do Discord deles", + "disclaimer.line3": "Você será BANIDO dos dois servidores se buscar ajuda no Discord do Millennium", + "disclaimer.title": "Aviso Importante", + "gameStatus.denuvo": "Denuvo", + "gameStatus.needsFixes": "Correção disponível", + "gameStatus.playable": "Jogável", + "gameStatus.unplayable": "Não jogável", + "menu.advancedLabel": "Avançado", + "menu.checkForUpdates": "Verificar atualizações", + "menu.discord": "Discord", + "menu.error.getPath": "Erro ao encontrar o caminho do jogo", + "menu.error.noAppId": "Não foi possível determinar o AppID do jogo", + "menu.error.noInstall": "Não foi possível encontrar a instalação do jogo", + "menu.error.notInstalled": "Jogo não instalado! Adicione e instale primeiro :D", + "menu.fetchFreeApis": "Buscar APIs gratuitas", + "menu.fixesMenu": "Menu de Correções", + "menu.joinDiscordLabel": "Entre no Discord!", + "menu.manageGameLabel": "Gerenciar jogo", + "menu.remove.confirm": "Remover LuaTools para este jogo?", + "menu.remove.failure": "Falha ao remover o LuaTools.", + "menu.remove.success": "LuaTools removido para este jogo.", + "menu.removeLuaTools": "Remover jogo via LuaTools", + "menu.settings": "Configurações", + "menu.title": "LuaTools · Menu", + "settings.close": "Fechar", + "settings.donateKeys.description": "Permitir que o LuaTools doe chaves Steam sobrando.", + "settings.donateKeys.label": "Doar chaves", + "settings.donateKeys.no": "Não", + "settings.donateKeys.yes": "Sim", + "settings.empty": "Nenhuma configuração disponível.", + "settings.error": "Falha ao carregar as configurações.", + "settings.fastDownload.description": "Escolhe automaticamente a primeira fonte disponível ao adicionar um jogo.", + "settings.fastDownload.label": "Download Rápido", + "settings.general": "Geral", + "settings.generalDescription": "Preferências globais do LuaTools.", + "settings.installedFixes.date": "Instalado:", + "settings.installedFixes.delete": "Excluir", + "settings.installedFixes.deleteConfirm": "Tem certeza de que deseja remover esta correção? Isso excluirá os arquivos da correção e executará a verificação da Steam.", + "settings.installedFixes.deleteError": "Falha ao remover correção.", + "settings.installedFixes.deleteSuccess": "Correção removida com sucesso!", + "settings.installedFixes.deleting": "Removendo correção...", + "settings.installedFixes.empty": "Nenhuma correção instalada ainda.", + "settings.installedFixes.error": "Falha ao carregar correções instaladas.", + "settings.installedFixes.files": "{count} arquivos", + "settings.installedFixes.loading": "Procurando correções instaladas...", + "settings.installedFixes.title": "Correções Instaladas", + "settings.installedFixes.type": "Tipo:", + "settings.installedLua.delete": "Remover", + "settings.installedLua.deleteConfirm": "Remover via LuaTools para este jogo?", + "settings.installedLua.deleteError": "Falha ao remover via LuaTools.", + "settings.installedLua.deleteSuccess": "Removido via LuaTools com sucesso!", + "settings.installedLua.deleting": "Removendo via LuaTools...", + "settings.installedLua.disabled": "Desabilitado", + "settings.installedLua.empty": "Nenhum script Lua instalado ainda.", + "settings.installedLua.error": "Falha ao carregar scripts Lua instalados.", + "settings.installedLua.loading": "Procurando scripts Lua instalados...", + "settings.installedLua.modified": "Modificado:", + "settings.installedLua.title": "Jogos via LuaTools", + "settings.installedLua.unknownInfo": "Jogos mostrando 'Jogo Desconhecido' foram instalados de fontes externas (não via LuaTools).", + "settings.language.description": "Escolha o idioma utilizado pelo LuaTools.", + "settings.language.label": "Idioma", + "settings.language.option.en": "Inglês", + "settings.language.option.pt-BR": "Português (Brasil)", + "settings.loading": "Carregando configurações...", + "settings.noChanges": "Nenhuma alteração para salvar.", + "settings.refresh": "Atualizar", + "settings.refreshing": "Atualizando...", + "settings.save": "Salvar Configurações", + "settings.saveError": "Falha ao salvar as configurações.", + "settings.saveSuccess": "Configurações salvas com sucesso.", + "settings.saving": "Salvando...", + "settings.search.clear": "Limpar busca", + "settings.search.noResults": "Nenhum resultado encontrado", + "settings.search.placeholder": "Buscar configurações, jogos, correções...", + "settings.theme.description": "Escolha o tema de cores para a interface do LuaTools.", + "settings.theme.label": "Tema", + "settings.title": "LuaTools · Configurações", + "settings.unsaved": "Alterações não salvas", + "settings.useSteamLanguage.description": "Utilizar o idioma do cliente Steam ao invés da configuração do LuaTools.", + "settings.useSteamLanguage.label": "Usar Idioma do Steam", + "settings.useSteamLanguage.no": "Não", + "settings.useSteamLanguage.yes": "Sim", + "{fix} applied successfully!": "{fix} aplicado com sucesso!", + "settings.morrenusApiKey.label": "Chave API do Morrenus", + "settings.morrenusApiKey.description": "Chave API necessária pra usar a API Sadie. Pegue pelo {link}", + "settings.morrenusApiKey.placeholder": "Insira aqui sua chave" + } +} diff --git a/backend/logger.py b/backend/logger.py deleted file mode 100644 index 5158c0f..0000000 --- a/backend/logger.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Shared logger instance for the LuaTools plugin backend.""" - -import PluginUtils # type: ignore - -_LOGGER_INSTANCE = None - - -def get_logger() -> PluginUtils.Logger: - """Return a singleton PluginUtils.Logger instance.""" - global _LOGGER_INSTANCE - if _LOGGER_INSTANCE is None: - _LOGGER_INSTANCE = PluginUtils.Logger() - return _LOGGER_INSTANCE - - -# Convenience alias so other modules can `from logger import logger` -logger = get_logger() - - diff --git a/backend/main.lua b/backend/main.lua index b5194b1..dabb001 100644 --- a/backend/main.lua +++ b/backend/main.lua @@ -2,22 +2,22 @@ -- All exported functions return JSON-encoded strings, mirroring the Python backend's json.dumps() returns. -- This is required because Millennium's Lua bridge does not deep-serialize nested Lua tables. -local cjson = require("json") -local m_utils = require("utils") -local logger = require("plugin_logger") -local millennium = require("millennium") -local fs = require("fs") -local http_client = require("http_client") -local paths = require("paths") -local steam_utils = require("steam_utils") -local utils = require("plugin_utils") -local locales_mod = require("locales.manager") - -local api_manifest = require("api_manifest") -local downloads = require("downloads") -local fixes = require("fixes") +local cjson = require("json") +local m_utils = require("utils") +local logger = require("plugin_logger") +local millennium = require("millennium") +local fs = require("fs") +local http_client = require("http_client") +local paths = require("paths") +local steam_utils = require("steam_utils") +local utils = require("plugin_utils") +local locales_mod = require("locales.manager") + +local api_manifest = require("api_manifest") +local downloads = require("downloads") +local fixes = require("fixes") local settings_manager = require("settings.manager") -local auto_update = require("auto_update") +local auto_update = require("auto_update") -- ── Helpers ────────────────────────────────────────────────────────────────── @@ -134,7 +134,7 @@ _G["Logger.error"] = Logger.error -- Every function returns a JSON string, matching the Python backend exactly. function GetPluginDir() - return paths.get_plugin_dir() -- plain string, matches Python + return paths.get_plugin_dir() -- plain string, matches Python end function InitApis() @@ -199,6 +199,19 @@ function GetApiList() return json_ok(res) end +function AddCustomApi(api_key, contentScriptQuery, name, url) + -- JS passes: { api_key, contentScriptQuery, name, url } + -- Reconstruct the payload object for api_manifest + local payload = { + name = tostring(name or ""), + url = tostring(url or ""), + api_key = tostring(api_key or "") + } + local ok, res = pcall(api_manifest.add_custom_api, payload) + if not ok then return json_err(res) end + return json_ok(res) +end + function CancelAddViaLuaTools(appid) -- No-op cancel stub; download is synchronous in Lua return json_ok({ success = true }) @@ -208,14 +221,14 @@ function CheckApisForApp(appid) if type(appid) == "table" then appid = appid.appid end local ok, res = pcall(downloads.check_apis_for_app, tonumber(appid)) if not ok then return json_err(res) end - + -- Ensure empty arrays encode as [] and not {} if res and type(res.results) == "table" and #res.results == 0 then -- Serialize manually or inject cjson.empty_array local success_json = res.success and "true" or "false" return '{"success":' .. success_json .. ',"results":[]}' end - + return json_ok(res) end @@ -229,7 +242,7 @@ function GetMorrenusStats(api_key, force_refresh) local endpoint = "https://hubcapmanifest.com/api/v1/user/stats?api_key=" .. api_key local ok, resp = pcall(http_client.get, endpoint, { timeout = 10 }) if ok and resp and resp.status == 200 then - return resp.body -- already JSON string + return resp.body -- already JSON string end return json_err("request failed") end @@ -238,15 +251,16 @@ function StartAddViaLuaToolsFromUrl(apiName, appid, contentScriptQuery, url) -- Millennium's IPC bridge sorts JS object keys alphabetically and passes their values as positional arguments. -- The JS passes: { apiName: ..., appid: ..., contentScriptQuery: "", url: ... } -- So the Lua signature MUST be (apiName, appid, contentScriptQuery, url) - - logger.log("StartAddViaLuaToolsFromUrl CALLED: appid=" .. tostring(appid) .. ", url=" .. tostring(url) .. ", apiName=" .. tostring(apiName)) - + + logger.log("StartAddViaLuaToolsFromUrl CALLED: appid=" .. + tostring(appid) .. ", url=" .. tostring(url) .. ", apiName=" .. tostring(apiName)) + local ok, res = pcall(downloads.start_add_via_luatools_from_url, appid, url, apiName) - if not ok then + if not ok then logger.warn("StartAddViaLuaToolsFromUrl CRASHED inside pcall: " .. tostring(res)) - return json_err(res) + return json_err(res) end - + return json_ok(res) end @@ -256,7 +270,8 @@ function GetIconDataUrl() if fs.exists(icon_path) then local content = m_utils.read_file(icon_path) if content then - return json_ok({ success = true, dataUrl = "data:image/png;base64," .. (m_utils.base64_encode and m_utils.base64_encode(content) or "") }) + return json_ok({ success = true, dataUrl = "data:image/png;base64," .. + (m_utils.base64_encode and m_utils.base64_encode(content) or "") }) end end return json_ok({ success = false, error = "icon not found" }) @@ -332,13 +347,13 @@ function ApplyGameFix(appid, contentScriptQuery, downloadUrl, fixType, gameName, -- Millennium's IPC bridge sorts JS object keys alphabetically and passes their values as positional arguments. -- The JS passes: { appid, contentScriptQuery, downloadUrl, fixType, gameName, installPath } -- So the Lua signature MUST be (appid, contentScriptQuery, downloadUrl, fixType, gameName, installPath) - + local ok, res = pcall(fixes.apply_game_fix, tonumber(appid), tostring(downloadUrl or ""), tostring(installPath or ""), tostring(fixType or ""), tostring(gameName or "")) - if not ok then + if not ok then logger.warn("ApplyGameFix CRASHED: " .. tostring(res)) - return json_err(res) + return json_err(res) end return json_ok(res) end @@ -425,7 +440,7 @@ end function OpenExternalUrl(url) if type(url) == "table" then url = url.url end url = tostring(url or "") - if not (url:sub(1,7) == "http://" or url:sub(1,8) == "https://") then + if not (url:sub(1, 7) == "http://" or url:sub(1, 8) == "https://") then return json_err("Invalid URL") end local is_win = (m_utils.getenv("OS") or ""):find("Windows") ~= nil @@ -455,7 +470,25 @@ function GetSettingsConfig() end function GetThemes() - return json_ok({ success = true, themes = {} }) + local themes_json_path = fs.join(paths.get_plugin_dir(), "public", "themes", "themes.json") + local themes_list = {} + + if fs.exists(themes_json_path) then + local success, data = pcall(cjson.decode, utils.read_text(themes_json_path)) + if success and type(data) == "table" then + for _, item in ipairs(data) do + if type(item) == "table" and item.value then + table.insert(themes_list, item) + end + end + else + logger.warn("GetThemes failed to decode themes.json") + end + else + logger.warn("GetThemes: themes.json not found") + end + + return json_ok({ success = true, themes = themes_list }) end function ApplySettingsChanges(changes) @@ -548,7 +581,7 @@ end -- ── Return lifecycle table ──────────────────────────────────────────────────── return { - on_load = on_load, - on_unload = on_unload, + on_load = on_load, + on_unload = on_unload, on_frontend_loaded = on_frontend_loaded, } diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index 5101a41..0000000 --- a/backend/main.py +++ /dev/null @@ -1,526 +0,0 @@ -import json -import os -import shutil -import sys -import webbrowser - -from typing import Any - -import Millennium # type: ignore -import PluginUtils # type: ignore - -from api_manifest import ( - fetch_free_apis_now as api_fetch_free_apis_now, - get_api_list as api_get_api_list, - get_init_apis_message as api_get_init_message, - init_apis as api_init_apis, - store_last_message, -) -from auto_update import ( - apply_pending_update_if_any, - check_for_updates_now as auto_check_for_updates_now, - restart_steam as auto_restart_steam, - start_auto_update_background_check, -) -from config import WEBKIT_DIR_NAME, WEB_UI_ICON_FILE, WEB_UI_JS_FILE -from downloads import ( - cancel_add_via_luatools, - check_apis_for_app, - delete_luatools_for_app, - dismiss_loaded_apps, - get_add_status, - get_icon_data_url, - get_installed_lua_scripts, - has_luatools_for_app, - get_games_database, - init_applist, - read_loaded_apps, - start_add_via_luatools, - start_add_via_luatools_from_url, -) -from fixes import ( - apply_game_fix, - cancel_apply_fix, - check_for_fixes, - get_apply_fix_status, - get_installed_fixes, - get_unfix_status, - init_fixes_index, - unfix_game, -) -from utils import ensure_temp_download_dir -from http_client import close_http_client, ensure_http_client -from logger import logger as shared_logger -from paths import get_plugin_dir, public_path -from settings.manager import ( - apply_settings_changes, - get_available_locales, - get_settings_payload, - get_translation_map, - init_settings, -) -from steam_utils import detect_steam_install_path, get_game_install_path_response, open_game_folder - -logger = shared_logger - - -def GetPluginDir() -> str: # Legacy API used by the frontend - return get_plugin_dir() - - -class Logger: - @staticmethod - def log(message: str) -> str: - shared_logger.log(f"[Frontend] {message}") - return json.dumps({"success": True}) - - @staticmethod - def warn(message: str) -> str: - shared_logger.warn(f"[Frontend] {message}") - return json.dumps({"success": True}) - - @staticmethod - def error(message: str) -> str: - shared_logger.error(f"[Frontend] {message}") - return json.dumps({"success": True}) - - -def _steam_ui_path() -> str: - return os.path.join(Millennium.steam_path(), "steamui", WEBKIT_DIR_NAME) - - -def _copy_webkit_files() -> None: - plugin_dir = get_plugin_dir() - steam_ui_path = _steam_ui_path() - os.makedirs(steam_ui_path, exist_ok=True) - - js_src = public_path(WEB_UI_JS_FILE) - js_dst = os.path.join(steam_ui_path, WEB_UI_JS_FILE) - logger.log(f"Copying LuaTools web UI from {js_src} to {js_dst}") - try: - shutil.copy(js_src, js_dst) - except Exception as exc: - logger.error(f"Failed to copy LuaTools web UI: {exc}") - - icon_src = public_path(WEB_UI_ICON_FILE) - icon_dst = os.path.join(steam_ui_path, WEB_UI_ICON_FILE) - if os.path.exists(icon_src): - try: - shutil.copy(icon_src, icon_dst) - logger.log(f"Copied LuaTools icon to {icon_dst}") - except Exception as exc: - logger.error(f"Failed to copy LuaTools icon: {exc}") - else: - logger.warn(f"LuaTools icon not found at {icon_src}") - - # Copy theme CSS files - themes_src = os.path.join(plugin_dir, "public", "themes") - themes_dst = os.path.join(steam_ui_path, "themes") - if os.path.exists(themes_src): - try: - os.makedirs(themes_dst, exist_ok=True) - for filename in os.listdir(themes_src): - if filename.endswith(".css"): - theme_src = os.path.join(themes_src, filename) - theme_dst = os.path.join(themes_dst, filename) - shutil.copy(theme_src, theme_dst) - logger.log(f"Copied theme file {filename} to {theme_dst}") - except Exception as exc: - logger.warn(f"Failed to copy theme files: {exc}") - - -def _inject_webkit_files() -> None: - js_path = os.path.join(WEBKIT_DIR_NAME, WEB_UI_JS_FILE) - Millennium.add_browser_js(js_path) - logger.log(f"LuaTools injected web UI: {js_path}") - - -def InitApis(contentScriptQuery: str = "") -> str: - return api_init_apis(contentScriptQuery) - - -def GetInitApisMessage(contentScriptQuery: str = "") -> str: - return api_get_init_message(contentScriptQuery) - - -def FetchFreeApisNow(contentScriptQuery: str = "") -> str: - return api_fetch_free_apis_now(contentScriptQuery) - - -def CheckForUpdatesNow(contentScriptQuery: str = "") -> str: - result = auto_check_for_updates_now() - return json.dumps(result) - - -def RestartSteam(contentScriptQuery: str = "") -> str: - success = auto_restart_steam() - if success: - return json.dumps({"success": True}) - return json.dumps({"success": False, "error": "Failed to restart Steam"}) - - -def HasLuaToolsForApp(appid: int, contentScriptQuery: str = "") -> str: - return has_luatools_for_app(appid) - - -def StartAddViaLuaTools(appid: int, contentScriptQuery: str = "") -> str: - return start_add_via_luatools(appid) - - -def GetAddViaLuaToolsStatus(appid: int, contentScriptQuery: str = "") -> str: - return get_add_status(appid) - - -def GetApiList(contentScriptQuery: str = "") -> str: - return api_get_api_list(contentScriptQuery) - - -def CancelAddViaLuaTools(appid: int, contentScriptQuery: str = "") -> str: - return cancel_add_via_luatools(appid) - - -def CheckApisForApp(appid: int, contentScriptQuery: str = "") -> str: - return check_apis_for_app(appid) - - -MORRENUS_STATS_CACHE = {} - -def GetMorrenusStats(api_key: str, force_refresh: bool = False, contentScriptQuery: str = "", **kwargs: Any) -> str: - import time - global MORRENUS_STATS_CACHE - - if "force_refresh" in kwargs: - force_refresh = bool(kwargs["force_refresh"]) - - now = time.time() - - if not force_refresh: - cached = MORRENUS_STATS_CACHE.get(api_key) - if cached and (now - cached["time"] < 600): - return cached["data"] - - try: - from http_client import ensure_http_client - client = ensure_http_client("LuaTools: GetMorrenusStats") - resp = client.get(f"https://hubcapmanifest.com/api/v1/user/stats?api_key={api_key}", follow_redirects=True, timeout=10) - data = resp.text - if resp.status_code == 200: - MORRENUS_STATS_CACHE[api_key] = {"time": now, "data": data} - return data - except Exception as exc: - logger.warn(f"LuaTools: GetMorrenusStats failed: {exc}") - return json.dumps({"error": str(exc)}) - - -def StartAddViaLuaToolsFromUrl(appid: int, url: str, apiName: str, contentScriptQuery: str = "") -> str: - return start_add_via_luatools_from_url(appid, url, apiName) - - -def GetIconDataUrl(contentScriptQuery: str = "") -> str: - return get_icon_data_url() - - -def GetGamesDatabase(contentScriptQuery: str = "") -> str: - return get_games_database() - - -def ReadLoadedApps(contentScriptQuery: str = "") -> str: - return read_loaded_apps() - - -def DismissLoadedApps(contentScriptQuery: str = "") -> str: - return dismiss_loaded_apps() - - -def DeleteLuaToolsForApp(appid: int, contentScriptQuery: str = "") -> str: - return delete_luatools_for_app(appid) - - -def CheckForFixes(appid: int, contentScriptQuery: str = "") -> str: - return check_for_fixes(appid) - - -def ApplyGameFix(appid: int, downloadUrl: str, installPath: str, fixType: str = "", gameName: str = "", contentScriptQuery: str = "") -> str: - return apply_game_fix(appid, downloadUrl, installPath, fixType, gameName) - - -def GetApplyFixStatus(appid: int, contentScriptQuery: str = "") -> str: - return get_apply_fix_status(appid) - - -def CancelApplyFix(appid: int, contentScriptQuery: str = "") -> str: - return cancel_apply_fix(appid) - - -def UnFixGame(appid: int, installPath: str = "", fixDate: str = "", contentScriptQuery: str = "") -> str: - return unfix_game(appid, installPath, fixDate) - - -def GetUnfixStatus(appid: int, contentScriptQuery: str = "") -> str: - return get_unfix_status(appid) - - -def GetInstalledFixes(contentScriptQuery: str = "") -> str: - return get_installed_fixes() - - -def GetInstalledLuaScripts(contentScriptQuery: str = "") -> str: - return get_installed_lua_scripts() - - -def GetGameInstallPath(appid: int, contentScriptQuery: str = "") -> str: - result = get_game_install_path_response(appid) - return json.dumps(result) - - -def OpenGameFolder(path: str, contentScriptQuery: str = "") -> str: - success = open_game_folder(path) - if success: - return json.dumps({"success": True}) - return json.dumps({"success": False, "error": "Failed to open path"}) - - -def OpenExternalUrl(url: str, contentScriptQuery: str = "") -> str: - try: - value = str(url or "").strip() - if not (value.startswith("http://") or value.startswith("https://")): - return json.dumps({"success": False, "error": "Invalid URL"}) - if sys.platform.startswith("win"): - try: - os.startfile(value) # type: ignore[attr-defined] - except Exception: - webbrowser.open(value) - else: - webbrowser.open(value) - return json.dumps({"success": True}) - except Exception as exc: - logger.warn(f"LuaTools: OpenExternalUrl failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def GetSettingsConfig(contentScriptQuery: str = "") -> str: - try: - payload = get_settings_payload() - response = { - "success": True, - "schemaVersion": payload.get("version"), - "schema": payload.get("schema", []), - "values": payload.get("values", {}), - "language": payload.get("language"), - "locales": payload.get("locales", []), - "translations": payload.get("translations", {}), - } - return json.dumps(response) - except Exception as exc: - logger.warn(f"LuaTools: GetSettingsConfig failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def GetThemes(contentScriptQuery: str = "") -> str: - """Return the full themes palette list for the frontend.""" - try: - themes_path = os.path.join(get_plugin_dir(), 'public', 'themes', 'themes.json') - if os.path.exists(themes_path): - try: - with open(themes_path, 'r', encoding='utf-8') as fh: - data = json.load(fh) - return json.dumps({"success": True, "themes": data}) - except Exception as exc: - logger.warn(f"LuaTools: Failed to read themes.json: {exc}") - return json.dumps({"success": False, "error": "Failed to read themes.json"}) - else: - return json.dumps({"success": True, "themes": []}) - except Exception as exc: - logger.warn(f"LuaTools: GetThemes failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def ApplySettingsChanges( - _contentScriptQuery: str = "", changes: Any = None, **kwargs: Any -) -> str: - try: - if "changes" in kwargs and changes is None: - changes = kwargs["changes"] - if changes is None: - changes = kwargs - - try: - logger.log( - "LuaTools: ApplySettingsChanges raw argument " - f"type={type(changes)} value={changes!r}" - ) - logger.log(f"LuaTools: ApplySettingsChanges kwargs: {kwargs}") - except Exception: - pass - - payload: Any = None - - if isinstance(changes, str) and changes: - try: - payload = json.loads(changes) - except Exception: - logger.warn("LuaTools: Failed to parse changes string payload") - return json.dumps({"success": False, "error": "Invalid JSON payload"}) - else: - # When a full payload dict was sent as JSON, unwrap keys we expect. - if isinstance(payload, dict) and "changes" in payload: - payload = payload.get("changes") - elif isinstance(payload, dict) and "changesJson" in payload and isinstance(payload["changesJson"], str): - try: - payload = json.loads(payload["changesJson"]) - except Exception: - logger.warn("LuaTools: Failed to parse changesJson string inside payload") - return json.dumps({"success": False, "error": "Invalid JSON payload"}) - elif isinstance(changes, dict) and changes: - # When the bridge passes a dict argument directly. - if "changesJson" in changes and isinstance(changes["changesJson"], str): - try: - payload = json.loads(changes["changesJson"]) - except Exception: - logger.warn("LuaTools: Failed to parse changesJson payload from dict") - return json.dumps({"success": False, "error": "Invalid JSON payload"}) - elif "changes" in changes: - payload = changes.get("changes") - else: - payload = changes - else: - # Look for JSON payload inside kwargs. - changes_json = kwargs.get("changesJson") - if isinstance(changes_json, dict): - payload = changes_json - elif isinstance(changes_json, str) and changes_json: - try: - payload = json.loads(changes_json) - except Exception: - logger.warn("LuaTools: Failed to parse changesJson payload") - return json.dumps({"success": False, "error": "Invalid JSON payload"}) - else: - payload = changes - - if payload is None: - payload = {} - elif not isinstance(payload, dict): - logger.warn(f"LuaTools: Parsed payload is not a dict: {payload!r}") - return json.dumps({"success": False, "error": "Invalid payload format"}) - - try: - logger.log(f"LuaTools: ApplySettingsChanges received payload: {payload}") - except Exception: - pass - - result = apply_settings_changes(payload) - try: - logger.log(f"LuaTools: ApplySettingsChanges result: {result}") - except Exception: - pass - response = json.dumps(result) - try: - logger.log(f"LuaTools: ApplySettingsChanges response json: {response}") - except Exception: - pass - return response - except Exception as exc: - logger.warn(f"LuaTools: ApplySettingsChanges failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def GetAvailableLocales(contentScriptQuery: str = "") -> str: - try: - locales = get_available_locales() - return json.dumps({"success": True, "locales": locales}) - except Exception as exc: - logger.warn(f"LuaTools: GetAvailableLocales failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def GetTranslations(contentScriptQuery: str = "", language: str = "", **kwargs: Any) -> str: - try: - if not language and "language" in kwargs: - language = kwargs["language"] - bundle = get_translation_map(language) - bundle["success"] = True - return json.dumps(bundle) - except Exception as exc: - logger.warn(f"LuaTools: GetTranslations failed: {exc}") - return json.dumps({"success": False, "error": str(exc)}) - - -def GetAvailableThemes(contentScriptQuery: str = "") -> str: - """Return list of available theme CSS files.""" - try: - themes_dir = os.path.join(get_plugin_dir(), "public", "themes") - themes = [] - if os.path.exists(themes_dir): - for filename in os.listdir(themes_dir): - if filename.endswith(".css"): - theme_name = filename[:-4] # Remove .css extension - # Capitalize first letter for display - display_name = theme_name.capitalize() - themes.append({"value": theme_name, "label": display_name}) - # Sort themes, but put 'original' first - themes.sort(key=lambda x: (x["value"] != "original", x["label"])) - return json.dumps({"success": True, "themes": themes}) - except Exception as exc: - logger.warn(f"LuaTools: GetAvailableThemes failed: {exc}") - return json.dumps({"success": False, "error": str(exc), "themes": []}) - - -class Plugin: - def _front_end_loaded(self): - _copy_webkit_files() - - def _load(self): - logger.log(f"bootstrapping LuaTools plugin, millennium {Millennium.version()}") - - try: - detect_steam_install_path() - except Exception as exc: - logger.warn(f"LuaTools: steam path detection failed: {exc}") - - ensure_http_client("InitApis") - ensure_temp_download_dir() - - try: - init_settings() - except Exception as exc: - logger.warn(f"LuaTools: settings initialization failed: {exc}") - - try: - message = apply_pending_update_if_any() - if message: - store_last_message(message) - except Exception as exc: - logger.warn(f"AutoUpdate: apply pending failed: {exc}") - - try: - init_applist() - except Exception as exc: - logger.warn(f"LuaTools: Applist initialization failed: {exc}") - - try: - init_fixes_index() - except Exception as exc: - logger.warn(f"LuaTools: Fixes index initialization failed: {exc}") - - _copy_webkit_files() - _inject_webkit_files() - - try: - result = InitApis("boot") - logger.log(f"InitApis (boot) return: {result}") - except Exception as exc: - logger.error(f"InitApis (boot) failed: {exc}") - - try: - start_auto_update_background_check() - except Exception as exc: - logger.warn(f"AutoUpdate: start background check failed: {exc}") - - Millennium.ready() - - def _unload(self): - logger.log("unloading") - close_http_client("InitApis") - - -plugin = Plugin() diff --git a/backend/paths.py b/backend/paths.py deleted file mode 100644 index 465b024..0000000 --- a/backend/paths.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Filesystem path helpers for the LuaTools backend.""" - -import os - - -def get_backend_dir() -> str: - """Return the absolute path to the backend directory.""" - return os.path.dirname(os.path.realpath(__file__)) - - -def get_plugin_dir() -> str: - """Return the absolute path to the root plugin directory.""" - backend_dir = get_backend_dir() - return os.path.abspath(os.path.join(backend_dir, "..")) - - -def backend_path(filename: str) -> str: - """Return an absolute path to a file inside the backend directory.""" - return os.path.join(get_backend_dir(), filename) - - -def public_path(filename: str) -> str: - """Return an absolute path to a file inside the public directory.""" - return os.path.join(get_plugin_dir(), "public", filename) - diff --git a/backend/plugin_utils.lua b/backend/plugin_utils.lua index 1ec1296..d1b9cf0 100644 --- a/backend/plugin_utils.lua +++ b/backend/plugin_utils.lua @@ -30,6 +30,11 @@ function utils.decode_json(text) if success then return data else return {} end end +function utils.encode_json(data) + local success, content = pcall(cjson.encode, data) + if success then return content else return "{}" end +end + function utils.write_json(path, data) local success, content = pcall(cjson.encode, data) if not success then diff --git a/backend/steam_utils.py b/backend/steam_utils.py deleted file mode 100644 index e8892e2..0000000 --- a/backend/steam_utils.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Steam-related utilities used across LuaTools backend modules.""" - -from __future__ import annotations - -import os -import re -import subprocess -import sys -from typing import Dict, Optional - -import Millennium # type: ignore - -from logger import logger - -_STEAM_INSTALL_PATH: Optional[str] = None - -if sys.platform.startswith("win"): - try: - import winreg # type: ignore - except Exception: # pragma: no cover - registry import failure fallback - winreg = None # type: ignore -else: - winreg = None # type: ignore - - -def detect_steam_install_path() -> str: - """Return the cached Steam installation path or discover it.""" - global _STEAM_INSTALL_PATH - if _STEAM_INSTALL_PATH: - return _STEAM_INSTALL_PATH - - path = None - - if sys.platform.startswith("win") and winreg is not None: - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam") as key: - path, _ = winreg.QueryValueEx(key, "SteamPath") - except Exception: - path = None - - if not path: - try: - path = Millennium.steam_path() - except Exception: - path = None - - _STEAM_INSTALL_PATH = path - logger.log(f"LuaTools: Steam install path set to {_STEAM_INSTALL_PATH}") - return _STEAM_INSTALL_PATH or "" - - -def _parse_vdf_simple(content: str) -> Dict[str, any]: - """Simple VDF parser for libraryfolders.vdf and appmanifest files.""" - result: Dict[str, any] = {} - stack = [result] - current_key = None - - lines = content.split("\n") - tokens = [] - for line in lines: - line = line.strip() - if not line or line.startswith("//"): - continue - parts = re.findall(r'"[^"]*"|\{|\}', line) - tokens.extend(parts) - - i = 0 - while i < len(tokens): - token = tokens[i].strip('"') - - if tokens[i] == "{": - if current_key: - new_dict = {} - stack[-1][current_key] = new_dict - stack.append(new_dict) - current_key = None - elif tokens[i] == "}": - if len(stack) > 1: - stack.pop() - elif current_key is None: - current_key = token - else: - stack[-1][current_key] = token - current_key = None - i += 1 - - return result - - -def _find_steam_path() -> str: - global _STEAM_INSTALL_PATH - if _STEAM_INSTALL_PATH: - return _STEAM_INSTALL_PATH - - if sys.platform.startswith("win") and winreg: - try: - try: - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam") - steam_path = winreg.QueryValueEx(key, "SteamPath")[0] - winreg.CloseKey(key) - if steam_path and os.path.exists(steam_path): - _STEAM_INSTALL_PATH = steam_path - return steam_path - except Exception: - pass - - try: - key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"Software\Valve\Steam") - steam_path = winreg.QueryValueEx(key, "InstallPath")[0] - winreg.CloseKey(key) - if steam_path and os.path.exists(steam_path): - _STEAM_INSTALL_PATH = steam_path - return steam_path - except Exception: - pass - except Exception as exc: - logger.warn(f"LuaTools: Failed to read Steam path from registry: {exc}") - - return "" - - -def has_lua_for_app(appid: int) -> bool: - try: - base_path = detect_steam_install_path() or Millennium.steam_path() - if not base_path: - return False - - stplug_path = os.path.join(base_path, "config", "stplug-in") - lua_file = os.path.join(stplug_path, f"{appid}.lua") - disabled_file = os.path.join(stplug_path, f"{appid}.lua.disabled") - return os.path.exists(lua_file) or os.path.exists(disabled_file) - except Exception as exc: - logger.error(f"LuaTools (steam_utils): Error checking Lua scripts for app {appid}: {exc}") - return False - - -def get_game_install_path_response(appid: int) -> Dict[str, any]: - """Find the game installation path. Returns dict mirroring previous JSON output.""" - try: - appid = int(appid) - except Exception: - return {"success": False, "error": "Invalid appid"} - - steam_path = _find_steam_path() - if not steam_path: - return {"success": False, "error": "Could not find Steam installation path"} - - library_vdf_path = os.path.join(steam_path, "config", "libraryfolders.vdf") - if not os.path.exists(library_vdf_path): - logger.warn(f"LuaTools: libraryfolders.vdf not found at {library_vdf_path}") - return {"success": False, "error": "Could not find libraryfolders.vdf"} - - try: - with open(library_vdf_path, "r", encoding="utf-8") as handle: - vdf_content = handle.read() - library_data = _parse_vdf_simple(vdf_content) - except Exception as exc: - logger.warn(f"LuaTools: Failed to parse libraryfolders.vdf: {exc}") - return {"success": False, "error": "Failed to parse libraryfolders.vdf"} - - library_folders = library_data.get("libraryfolders", {}) - library_path = None - appid_str = str(appid) - all_library_paths = [] - - for folder_data in library_folders.values(): - if isinstance(folder_data, dict): - folder_path = folder_data.get("path", "") - if folder_path: - folder_path = folder_path.replace("\\\\", "\\") - all_library_paths.append(folder_path) - - apps = folder_data.get("apps", {}) - if isinstance(apps, dict) and appid_str in apps: - library_path = folder_path - break - - appmanifest_path = None - if not library_path: - logger.log( - f"LuaTools: appid {appid} not in libraryfolders.vdf, searching all libraries for appmanifest" - ) - for lib_path in all_library_paths: - candidate_path = os.path.join(lib_path, "steamapps", f"appmanifest_{appid}.acf") - if os.path.exists(candidate_path): - library_path = lib_path - appmanifest_path = candidate_path - logger.log(f"LuaTools: Found appmanifest at {appmanifest_path}") - break - else: - appmanifest_path = os.path.join(library_path, "steamapps", f"appmanifest_{appid}.acf") - - if not library_path or not appmanifest_path or not os.path.exists(appmanifest_path): - logger.log(f"LuaTools: appmanifest not found for {appid} in any library") - return {"success": False, "error": "menu.error.notInstalled"} - - try: - with open(appmanifest_path, "r", encoding="utf-8") as handle: - manifest_content = handle.read() - manifest_data = _parse_vdf_simple(manifest_content) - except Exception as exc: - logger.warn(f"LuaTools: Failed to parse appmanifest: {exc}") - return {"success": False, "error": "Failed to parse appmanifest"} - - app_state = manifest_data.get("AppState", {}) - install_dir = app_state.get("installdir", "") - if not install_dir: - logger.warn(f"LuaTools: installdir not found in appmanifest for {appid}") - return {"success": False, "error": "Install directory not found"} - - full_install_path = os.path.join(library_path, "steamapps", "common", install_dir) - if not os.path.exists(full_install_path): - logger.warn(f"LuaTools: Game install path does not exist: {full_install_path}") - return {"success": False, "error": "Game directory not found"} - - logger.log(f"LuaTools: Game install path for {appid}: {full_install_path}") - return { - "success": True, - "installPath": full_install_path, - "installDir": install_dir, - "libraryPath": library_path, - "path": full_install_path, - } - - -def open_game_folder(path: str) -> bool: - """Open the game folder using the platform default file explorer.""" - try: - if not path or not os.path.exists(path): - return False - - if sys.platform.startswith("win"): - subprocess.Popen(["explorer", os.path.normpath(path)]) - elif sys.platform == "darwin": - subprocess.Popen(["open", path]) - else: - subprocess.Popen(["xdg-open", path]) - return True - except Exception as exc: - logger.warn(f"LuaTools: Failed to open game folder: {exc}") - return False - - -__all__ = [ - "detect_steam_install_path", - "get_game_install_path_response", - "has_lua_for_app", - "open_game_folder", -] - diff --git a/backend/update.json b/backend/update.json index b62c3d0..7cab1ec 100644 --- a/backend/update.json +++ b/backend/update.json @@ -1,6 +1,6 @@ { "github": { - "owner": "madoiscool", + "owner": "piqseu", "repo": "ltsteamplugin", "asset_name": "ltsteamplugin.zip" } diff --git a/backend/utils.py b/backend/utils.py deleted file mode 100644 index b9e2fde..0000000 --- a/backend/utils.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Generic helpers for file and data handling in the LuaTools backend.""" - -from __future__ import annotations - -import json -import os -import re -from typing import Any, Dict - -from paths import backend_path, get_plugin_dir - - -def read_text(path: str) -> str: - try: - with open(path, "r", encoding="utf-8") as handle: - return handle.read() - except Exception: - return "" - - -def write_text(path: str, text: str) -> None: - with open(path, "w", encoding="utf-8") as handle: - handle.write(text) - - -def read_json(path: str) -> Dict[str, Any]: - try: - with open(path, "r", encoding="utf-8") as handle: - return json.load(handle) - except Exception: - return {} - - -def write_json(path: str, data: Dict[str, Any]) -> bool: - """Write JSON data to file. Returns True on success, False on failure.""" - try: - with open(path, "w", encoding="utf-8") as handle: - json.dump(data, handle, indent=2) - return True - except Exception as exc: - # Import logger here to avoid circular import - try: - from logger import logger - logger.warn(f"write_json failed for {path}: {exc}") - except Exception: - pass - return False - - -def count_apis(text: str) -> int: - try: - data = json.loads(text) - apis = data.get("api_list", []) - if isinstance(apis, list): - return len(apis) - except Exception: - pass - return text.count('"name"') - - -def normalize_manifest_text(text: str) -> str: - content = (text or "").strip() - if not content: - return content - - content = re.sub(r",\s*]", "]", content) - content = re.sub(r",\s*}\s*$", "}", content) - - if content.startswith('"api_list"') or content.startswith("'api_list'") or content.startswith("api_list"): - if not content.startswith("{"): - content = "{" + content - if not content.endswith("}"): - content = content.rstrip(",") + "}" - - try: - json.loads(content) - return content - except Exception: - return text - - -def parse_version(version: str) -> tuple: - try: - parts = [int(part) for part in re.findall(r"\d+", str(version))] - return tuple(parts or [0]) - except Exception: - return (0,) - - -def get_plugin_version() -> str: - try: - plugin_json_path = os.path.join(get_plugin_dir(), "plugin.json") - data = read_json(plugin_json_path) - return str(data.get("version", "0")) - except Exception: - return "0" - - -def ensure_temp_download_dir() -> str: - root = backend_path("temp_dl") - try: - os.makedirs(root, exist_ok=True) - except Exception: - pass - return root - - -__all__ = [ - "backend_path", - "ensure_temp_download_dir", - "count_apis", - "get_plugin_dir", - "get_plugin_version", - "normalize_manifest_text", - "parse_version", - "read_json", - "read_text", - "write_json", - "write_text", -] - diff --git a/public/luatools.js b/public/luatools.js index 69a7f7f..a61f2e2 100644 --- a/public/luatools.js +++ b/public/luatools.js @@ -1246,6 +1246,178 @@ } } + function showCustomApiModal() { + try { + const old = document.querySelector(".luatools-custom-api-overlay"); + if (old) old.remove(); + } catch (_) {} + + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement("div"); + overlay.className = "luatools-custom-api-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:500px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const title = document.createElement("div"); + title.style.cssText = `font-size:22px;font-weight:600;margin-bottom:8px;color:${colors.text};`; + title.textContent = lt("Add Custom API"); + + const desc = document.createElement("div"); + desc.style.cssText = `font-size:14px;color:${colors.textSecondary};margin-bottom:20px;line-height:1.5;`; + desc.innerHTML = lt("Enter the custom API details below. You MUST include <appid> in the URL where the Game ID goes, and optionally <apikey> if an API key is required."); + + const body = document.createElement("div"); + body.style.cssText = "display:flex;flex-direction:column;gap:16px;margin-bottom:24px;"; + + function createInputGroup(labelText, placeholder, type = "text") { + const wrap = document.createElement("div"); + wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;"; + const lbl = document.createElement("label"); + lbl.style.cssText = `font-size:13px;font-weight:600;color:${colors.text};`; + lbl.textContent = labelText; + const input = document.createElement("input"); + input.type = type; + input.placeholder = placeholder; + input.style.cssText = `width:100%;padding:10px 12px;background:rgba(0,0,0,0.2);border:1px solid ${colors.borderRgba};border-radius:8px;color:${colors.text};font-size:14px;outline:none;transition:border-color 0.2s;box-sizing:border-box;`; + input.onfocus = () => (input.style.borderColor = colors.accent); + input.onblur = () => (input.style.borderColor = colors.borderRgba); + wrap.appendChild(lbl); + wrap.appendChild(input); + return { wrap, input }; + } + + const nameField = createInputGroup("API Name", "My Custom API"); + const urlField = createInputGroup("API URL", "https://example.com/download?id=&key="); + + body.appendChild(nameField.wrap); + body.appendChild(urlField.wrap); + + const toggleWrap = document.createElement("div"); + toggleWrap.style.cssText = "display:flex;align-items:center;gap:10px;margin-top:8px;cursor:pointer;"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.style.cssText = `width:16px;height:16px;accent-color:${colors.accent};cursor:pointer;`; + + const toggleLabel = document.createElement("span"); + toggleLabel.style.cssText = `font-size:14px;color:${colors.text};`; + toggleLabel.textContent = lt("Require API Key"); + + toggleWrap.appendChild(checkbox); + toggleWrap.appendChild(toggleLabel); + + const apiKeyField = createInputGroup("API Key", "Enter your API key here"); + apiKeyField.wrap.style.display = "none"; + + toggleWrap.onclick = function(e) { + if (e.target !== checkbox) checkbox.checked = !checkbox.checked; + apiKeyField.wrap.style.display = checkbox.checked ? "flex" : "none"; + }; + + body.appendChild(toggleWrap); + body.appendChild(apiKeyField.wrap); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:12px;margin-top:24px;"; + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = lt("Cancel"); + cancelBtn.style.cssText = `padding:8px 16px;background:transparent;border:1px solid ${colors.borderRgba};border-radius:8px;color:${colors.text};font-size:14px;font-weight:500;cursor:pointer;transition:all 0.2s ease;`; + cancelBtn.onmouseover = () => (cancelBtn.style.background = `rgba(255,255,255,0.1)`); + cancelBtn.onmouseout = () => (cancelBtn.style.background = "transparent"); + cancelBtn.onclick = () => overlay.remove(); + + const saveBtn = document.createElement("button"); + saveBtn.textContent = lt("Save API"); + saveBtn.style.cssText = `padding:8px 24px;background:${colors.accent};border:none;border-radius:8px;color:#fff;font-size:14px;font-weight:600;cursor:pointer;transition:transform 0.1s, filter 0.2s;`; + saveBtn.onmouseover = () => (saveBtn.style.filter = "brightness(1.1)"); + saveBtn.onmouseout = () => (saveBtn.style.filter = "none"); + saveBtn.onmousedown = () => (saveBtn.style.transform = "scale(0.96)"); + saveBtn.onmouseup = () => (saveBtn.style.transform = "scale(1)"); + + saveBtn.onclick = function() { + const name = nameField.input.value.trim(); + const url = urlField.input.value.trim(); + const needsKey = checkbox.checked; + const apiKey = apiKeyField.input.value.trim(); + + if (!name || !url) { + ShowLuaToolsAlert("Error", lt("Name and URL are required.")); + return; + } + + try { + const dummyUrl = url.replace("", "123").replace("", "abc"); + const parsedUrl = new URL(dummyUrl); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + ShowLuaToolsAlert("Error", lt("URL must start with http:// or https://")); + return; + } + } catch (e) { + ShowLuaToolsAlert("Error", lt("Please enter a valid URL.")); + return; + } + + if (!url.includes("")) { + ShowLuaToolsAlert("Error", lt("URL must contain placeholder.")); + return; + } + if (needsKey && !url.includes("")) { + ShowLuaToolsAlert("Error", lt("URL must contain when Require API Key is checked.")); + return; + } + if (needsKey && !apiKey) { + ShowLuaToolsAlert("Error", lt("Please enter an API Key.")); + return; + } + + saveBtn.textContent = lt("Saving..."); + saveBtn.disabled = true; + saveBtn.style.opacity = "0.7"; + + Millennium.callServerMethod("luatools", "AddCustomApi", { + name: name, + url: url, + api_key: needsKey ? apiKey : "", + contentScriptQuery: "" + }).then(function(res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success) { + overlay.remove(); + ShowLuaToolsAlert("Success", lt("Custom API added successfully!")); + } else { + saveBtn.textContent = lt("Save API"); + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + ShowLuaToolsAlert("Error", payload.error || "Failed to save API."); + } + } catch (e) { + saveBtn.textContent = lt("Save API"); + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + ShowLuaToolsAlert("Error", e.toString()); + } + }); + }; + + btnRow.appendChild(cancelBtn); + btnRow.appendChild(saveBtn); + + modal.appendChild(title); + modal.appendChild(desc); + modal.appendChild(body); + modal.appendChild(btnRow); + overlay.appendChild(modal); + document.body.appendChild(overlay); + } + function showSettingsPopup() { if ( document.querySelector(".luatools-settings-overlay") || @@ -1406,6 +1578,12 @@ "menu.settings", "Settings", ); + const customApiBtn = createIconButton( + "lt-settings-custom-api", + "fa-solid fa-code-branch", + "menu.customApi", + "Custom API", + ); const closeBtn = createIconButton( "lt-settings-close", "fa-xmark", @@ -1416,6 +1594,16 @@ // Check if we are on a game page const isGamePage = window.location.href.includes("/app/"); + if (customApiBtn) { + customApiBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) { } + showCustomApiModal(); + }); + } + const removeBtn = document.createElement("a"); removeBtn.id = "lt-settings-remove-lua"; removeBtn.href = "#"; @@ -6441,7 +6629,7 @@ ": " + source.name, ); - startDirectDownload(appid, source.url, source.name); + startDirectDownload(appid, available, 0); } else { // Multiple sources, let user select showSourceSelectionModal(appid, available); @@ -6460,7 +6648,11 @@ }); }; - const startDirectDownload = function (appid, url, apiName) { + const startDirectDownload = function (appid, availableSources, index = 0) { + const source = availableSources[index]; + const url = source.url; + const apiName = source.name; + const performDownload = function () { runState.inProgress = true; runState.appid = appid; @@ -6470,8 +6662,13 @@ if (overlay) { // Reset for progress const status = overlay.querySelector(".luatools-status"); - if (status) - status.textContent = lt("Initializing download..."); + if (status) { + if (index > 0) { + status.textContent = lt("Failed on {previous}. Trying {current}...").replace("{previous}", availableSources[index-1].name).replace("{current}", apiName); + } else { + status.textContent = lt("Initializing download..."); + } + } const progressWrap = overlay.querySelector( ".luatools-progress-wrap", ); @@ -6498,7 +6695,17 @@ contentScriptQuery: "", }, ); - startPolling(appid); + + const onFailedCallback = function(errMsg) { + if (index + 1 < availableSources.length) { + backendLog("LuaTools: Fast download failed on " + apiName + " (" + errMsg + "). Trying next API: " + availableSources[index+1].name); + setTimeout(function() { + startDirectDownload(appid, availableSources, index + 1); + }, 1500); + } + }; + + startPolling(appid, onFailedCallback); }; if (apiName && apiName.toLowerCase().includes("morrenus")) { @@ -6667,7 +6874,7 @@ apiList.style.flexDirection = ""; apiList.innerHTML = ""; // Clear selection buttons if (status) status.style.display = ""; // Restore status text - startDirectDownload(appid, source.url, source.name); + startDirectDownload(appid, [source], 0); }; apiList.appendChild(btn); @@ -6738,7 +6945,7 @@ ); // Changed from true to false (bubble phase instead of capture phase) // Poll backend for progress and update progress bar and text - function startPolling(appid) { + function startPolling(appid, onFailedCallback) { let done = false; let lastCheckedApi = null; let successfulApi = null; // Track which API successfully found the file @@ -7193,6 +7400,10 @@ clearInterval(timer); runState.inProgress = false; runState.appid = null; + + if (onFailedCallback) { + onFailedCallback(st.error || "Unknown error"); + } } } catch (_) { } }); @@ -7244,6 +7455,24 @@ setTimeout(checkUrlChange, 100); }; + // Pre-fetch settings quietly to ensure background values (like fastDownload) are populated immediately, + // and apply themes immediately once settings load. + function bootSettings() { + if (typeof Millennium === "undefined" || typeof Millennium.callServerMethod !== "function") { + setTimeout(bootSettings, 200); + return; + } + Promise.all([ + loadThemes(), + fetchSettingsConfig() + ]).then(function() { + if (typeof ensureLuaToolsStyles === "function") ensureLuaToolsStyles(); + }).catch(function(e) { + try { backendLog("LuaTools: Boot fetchSettingsConfig failed: " + String(e)); } catch(_) {} + }); + } + bootSettings(); + // Use MutationObserver to catch dynamically added content // Heavily optimized and throttled version to avoid blocking gamepad if (typeof MutationObserver !== "undefined") { diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cd67168..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -httpx==0.27.2 From 4a10bea002f388222107e3fceba01d61ffcd8cba Mon Sep 17 00:00:00 2001 From: piq Date: Mon, 1 Jun 2026 15:50:39 -0300 Subject: [PATCH 03/10] unbreak error modal when fast download is disabled (line ending disaster) --- backend/api.json | 62 +- public/luatools.js | 15356 ++++++++++++++++++++++--------------------- 2 files changed, 7731 insertions(+), 7687 deletions(-) diff --git a/backend/api.json b/backend/api.json index 8e01f8e..3c18f15 100644 --- a/backend/api.json +++ b/backend/api.json @@ -1,32 +1,30 @@ -{ - "api_list": [ - { - "name": "Morrenus", - "url": "https://hubcapmanifest.com/api/v1/manifest/?api_key=", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - }, - { - "name": "Ryuu", - "url": "http://167.235.229.108/", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - }, - { - "name": "TwentyTwo Cloud", - "url": "https://api.twentytwocloud.com/download?appid=", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - }, - { - "name": "Sushi", - "url": "https://raw.githubusercontent.com/sushi-dev55-alt/sushitools-games-repo-alt/refs/heads/main/.zip", - "success_code": 200, - "unavailable_code": 404, - "enabled": true - } - ] -} +{"api_list": [ + { + "name": "Morrenus", + "url": "https://hubcapmanifest.com/api/v1/manifest/?api_key=", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + }, + { + "name": "Ryuu", + "url": "http://167.235.229.108/", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + }, + { + "name": "TwentyTwo Cloud", + "url": "https://api.twentytwocloud.com/download?appid=", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + }, + { + "name": "Sushi", + "url": "https://raw.githubusercontent.com/sushi-dev55-alt/sushitools-games-repo-alt/refs/heads/main/.zip", + "success_code": 200, + "unavailable_code": 404, + "enabled": true + } + ]} \ No newline at end of file diff --git a/public/luatools.js b/public/luatools.js index a61f2e2..fe46232 100644 --- a/public/luatools.js +++ b/public/luatools.js @@ -1,7655 +1,7701 @@ -// LuaTools button injection (standalone plugin) - -// ============================================ -// GAMEPAD NAVIGATION SYSTEM - Inline Version -// ============================================ -(function () { - "use strict"; - - // Inject gamepad navigation CSS - const gamepadCSS = document.createElement("style"); - gamepadCSS.id = "gamepad-navigation-styles"; - gamepadCSS.textContent = ` - .active-focus { - outline: 3px solid #66c0f4 !important; - outline-offset: 2px !important; - box-shadow: 0 0 0 4px rgba(102, 192, 244, 0.3), - 0 0 12px rgba(102, 192, 244, 0.5) !important; - position: relative !important; - z-index: 9999 !important; - transition: outline 0.15s ease, box-shadow 0.15s ease !important; - } - - @keyframes gamepad-focus-pulse { - 0%, 100% { - box-shadow: 0 0 0 4px rgba(102, 192, 244, 0.3), - 0 0 12px rgba(102, 192, 244, 0.5); - } - 50% { - box-shadow: 0 0 0 4px rgba(102, 192, 244, 0.5), - 0 0 16px rgba(102, 192, 244, 0.7); - } - } - - .active-focus { - animation: gamepad-focus-pulse 1.5s ease-in-out infinite; - } - - button.active-focus, - a.active-focus { - background-color: rgba(102, 192, 244, 0.15) !important; - transform: scale(1.02); - } - - .BasicUI .active-focus, - .touch .active-focus { - outline-width: 4px !important; - outline-offset: 3px !important; - } - - input.active-focus, - select.active-focus, - textarea.active-focus { - border-color: #66c0f4 !important; - background-color: rgba(102, 192, 244, 0.1) !important; - } - - .active-focus:focus { - outline: 3px solid #66c0f4 !important; - } - - button, - a, - input, - select, - textarea, - .focusable { - transition: transform 0.15s ease, background-color 0.15s ease !important; - } - - .luatools-button.active-focus, - .luatools-restart-button.active-focus { - transform: scale(1.05) !important; - background: linear-gradient(135deg, rgba(102, 192, 244, 0.3), rgba(102, 192, 244, 0.2)) !important; - } - - .btnv6_blue_hoverfade.active-focus { - background: linear-gradient(to right, #47bfff 5%, #1a9fff 95%) !important; - } - - .active-focus { - scroll-margin: 20px; - } - `; - document.head.appendChild(gamepadCSS); - - // Gamepad Navigation System - // ALL LuaTools overlays that should block Steam navigation - const OVERLAY_SELECTORS = [ - ".luatools-overlay", - ".luatools-settings-overlay", - ".luatools-fixes-results-overlay", - ".luatools-loading-fixes-overlay", - ".luatools-unfix-overlay", - ".luatools-settings-manager-overlay", - ".luatools-alert-overlay", - ".luatools-confirm-overlay", - ".luatools-loadedapps-overlay", - ]; - const OVERLAY_SELECTOR_STRING = OVERLAY_SELECTORS.join(", "); - - const CONFIG = { - deadzone: 0.4, // Increased from 0.3 to prevent unwanted drift - debounceTime: 200, - pollRate: 16, - stickThreshold: 0.7, // Increased threshold for stick navigation - buttonMap: { - A: 0, - B: 1, - X: 2, - Y: 3, - LB: 4, - RB: 5, - LT: 6, - RT: 7, - SELECT: 8, - START: 9, - L3: 10, - R3: 11, - DPAD_UP: 12, - DPAD_DOWN: 13, - DPAD_LEFT: 14, - DPAD_RIGHT: 15, - }, - axesMap: { - LEFT_STICK_X: 0, - LEFT_STICK_Y: 1, - RIGHT_STICK_X: 2, - RIGHT_STICK_Y: 3, - }, - }; - - const state = { - gamepadConnected: false, - gamepadIndex: null, - focusableElements: [], - currentFocusIndex: 0, - lastNavigationTime: 0, - lastAxisValues: { - x: 0, - y: 0, - }, - buttonStates: {}, - animationFrameId: null, - }; - - // duplicated from main code thing for reliability - function isBigPictureMode() { - if (typeof window.__LUATOOLS_IS_BIG_PICTURE__ !== "undefined") { - return window.__LUATOOLS_IS_BIG_PICTURE__; - } - const htmlClasses = document.documentElement.className; - const userAgent = navigator.userAgent; - let score = 0; - if (htmlClasses.includes("BasicUI")) score += 3; - if (htmlClasses.includes("DesktopUI")) score -= 3; - if (userAgent.includes("Valve Steam Gamepad")) score += 2; - if (userAgent.includes("Valve Steam Client")) score -= 2; - if (htmlClasses.includes("touch")) score += 1; - return score > 0; - } - - // B button handler removed - users should use the modal buttons directly - // This prevents conflicts with Steam's back navigation - let onBackHandler = function () { - console.log( - "[Gamepad] B button pressed - ignoring (use modal buttons instead)", - ); - // Do nothing - let users navigate with D-pad/stick and press A on Cancel/Back buttons - }; - - function onGamepadConnected(event) { - console.log("[Gamepad] Gamepad conectado en Millennium:", event.gamepad.id); - state.gamepadConnected = true; - state.gamepadIndex = event.gamepad.index; - if (!state.animationFrameId) { - pollGamepad(); - } - // Don't scan immediately - only scan when an overlay is opened - // scanFocusableElements() will be called by the overlay's setTimeout - } - - function onGamepadDisconnected(event) { - console.log("[Gamepad] Gamepad disconnected:", event.gamepad.id); - if (state.gamepadIndex === event.gamepad.index) { - state.gamepadConnected = false; - state.gamepadIndex = null; - if (state.animationFrameId) { - cancelAnimationFrame(state.animationFrameId); - state.animationFrameId = null; - } - } - } - - function scanFocusableElements() { - if (!isBigPictureMode()) return; - - // Only scan if there's a LuaTools overlay active - const activeOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); - - if (!activeOverlay) { - console.log("[Gamepad] No LuaTools overlay active, skipping scan"); - state.focusableElements = []; - state.currentFocusIndex = 0; - return; - } - - // Only scan elements INSIDE the active overlay - const selectors = [ - "button:not([disabled])", - "a[href]:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - '[tabindex="0"]', - '[tabindex]:not([tabindex="-1"])', - ".focusable:not([disabled])", - ].join(", "); - - // Use querySelectorAll on the overlay, not the whole document - const elements = Array.from(activeOverlay.querySelectorAll(selectors)); - state.focusableElements = elements.filter(function (el) { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return ( - rect.width > 0 && - rect.height > 0 && - style.display !== "none" && - style.visibility !== "hidden" && - style.opacity !== "0" - ); - }); - - console.log( - "[Gamepad] Scanned " + - state.focusableElements.length + - " focusable elements inside overlay", - ); - - if (state.focusableElements.length > 0) { - focusElement(0); - } - } - - function focusElement(index) { - const prevElement = state.focusableElements[state.currentFocusIndex]; - if (prevElement) { - prevElement.blur(); - prevElement.classList.remove("active-focus"); - } - - if (index < 0) index = 0; - if (index >= state.focusableElements.length) - index = state.focusableElements.length - 1; - - state.currentFocusIndex = index; - - const element = state.focusableElements[index]; - if (element) { - element.focus(); - element.classList.add("active-focus"); - element.scrollIntoView({ - behavior: "smooth", - block: "nearest", - inline: "nearest", - }); - console.log("[Gamepad] Focused element " + index + ":", element); - } - } - - function navigate(direction) { - const now = Date.now(); - if (now - state.lastNavigationTime < CONFIG.debounceTime) { - return; - } - state.lastNavigationTime = now; - - if (state.focusableElements.length === 0) { - scanFocusableElements(); - return; - } - - let newIndex = state.currentFocusIndex; - - switch (direction) { - case "up": - newIndex--; - break; - case "down": - newIndex++; - break; - case "left": - newIndex = findElementInDirection("left"); - break; - case "right": - newIndex = findElementInDirection("right"); - break; - } - - if (newIndex < 0) newIndex = state.focusableElements.length - 1; - if (newIndex >= state.focusableElements.length) newIndex = 0; - - focusElement(newIndex); - } - - function findElementInDirection(direction) { - const currentElement = state.focusableElements[state.currentFocusIndex]; - if (!currentElement) return state.currentFocusIndex; - - const currentRect = currentElement.getBoundingClientRect(); - let closestIndex = state.currentFocusIndex; - let closestDistance = Infinity; - - state.focusableElements.forEach(function (el, index) { - if (index === state.currentFocusIndex) return; - - const rect = el.getBoundingClientRect(); - let isInDirection = false; - let distance = 0; - - if (direction === "left") { - isInDirection = rect.right <= currentRect.left; - distance = currentRect.left - rect.right; - } else if (direction === "right") { - isInDirection = rect.left >= currentRect.right; - distance = rect.left - currentRect.right; - } - - if (isInDirection && distance < closestDistance) { - closestDistance = distance; - closestIndex = index; - } - }); - - return closestIndex; - } - - function handleButtonPress(buttonIndex) { - const element = state.focusableElements[state.currentFocusIndex]; - - switch (buttonIndex) { - case CONFIG.buttonMap.A: - if (element) { - console.log("[Gamepad] A button: clicking element", element); - element.click(); - setTimeout(scanFocusableElements, 100); - } - break; - - case CONFIG.buttonMap.B: - // B button disabled - users should use modal buttons - console.log("[Gamepad] B button pressed - ignoring"); - break; - - case CONFIG.buttonMap.DPAD_UP: - navigate("up"); - break; - - case CONFIG.buttonMap.DPAD_DOWN: - navigate("down"); - break; - - case CONFIG.buttonMap.DPAD_LEFT: - navigate("left"); - break; - - case CONFIG.buttonMap.DPAD_RIGHT: - navigate("right"); - break; - } - } - - function pollGamepad() { - if (!state.gamepadConnected) { - state.animationFrameId = null; - return; - } - - // Check if there's an active LuaTools overlay - const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); - - // If no overlay is active, skip input processing but keep polling - if (!hasActiveOverlay) { - state.animationFrameId = requestAnimationFrame(pollGamepad); - return; - } - - const gamepads = navigator.getGamepads(); - const gamepad = gamepads[state.gamepadIndex]; - - if (!gamepad) { - state.animationFrameId = requestAnimationFrame(pollGamepad); - return; - } - - // Buttons - gamepad.buttons.forEach(function (button, index) { - const wasPressed = state.buttonStates[index] || false; - const isPressed = button.pressed; - - if (isPressed && !wasPressed) { - handleButtonPress(index); - } - - state.buttonStates[index] = isPressed; - }); - - // Left stick - const axisX = gamepad.axes[CONFIG.axesMap.LEFT_STICK_X] || 0; - const axisY = gamepad.axes[CONFIG.axesMap.LEFT_STICK_Y] || 0; - - const x = Math.abs(axisX) > CONFIG.deadzone ? axisX : 0; - const y = Math.abs(axisY) > CONFIG.deadzone ? axisY : 0; - - const now = Date.now(); - const threshold = CONFIG.stickThreshold; // Use higher threshold (0.7) - if (now - state.lastNavigationTime >= CONFIG.debounceTime) { - if (y < -threshold && state.lastAxisValues.y >= -threshold) { - navigate("up"); - } else if (y > threshold && state.lastAxisValues.y <= threshold) { - navigate("down"); - } else if (x < -threshold && state.lastAxisValues.x >= -threshold) { - navigate("left"); - } else if (x > threshold && state.lastAxisValues.x <= threshold) { - navigate("right"); - } - } - - state.lastAxisValues.x = x; - state.lastAxisValues.y = y; - - state.animationFrameId = requestAnimationFrame(pollGamepad); - } - - // Disabled: MutationObserver was causing unwanted auto-scanning - // Only manual scanElements() calls from overlay setTimeout will trigger scans - /* - const observer = new MutationObserver(function(mutations) { - clearTimeout(observer.rescanTimeout); - observer.rescanTimeout = setTimeout(function() { - if (state.gamepadConnected) { - scanFocusableElements(); - } - }, 300); - }); - */ - - // Block Steam's gamepad navigation when overlay is active - function blockSteamNavigation(event) { - const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); - - if (hasActiveOverlay && state.gamepadConnected) { - // Block arrow keys, Enter, Escape, Backspace and other navigation keys - // Note: Steam may translate gamepad B button to Escape or Backspace - const navKeys = [ - "ArrowUp", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - "Enter", - "Escape", - "Backspace", - " ", - "Tab", - ]; - if (navKeys.includes(event.key)) { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - console.log("[Gamepad] Blocked Steam navigation key:", event.key); - return false; - } - } - } - - // Block clicks on Steam UI when overlay is active - function blockSteamClicks(event) { - const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); - - if (hasActiveOverlay && state.gamepadConnected) { - // Only allow clicks inside the overlay - const clickedInsideOverlay = event.target.closest( - OVERLAY_SELECTOR_STRING, - ); - - if (!clickedInsideOverlay) { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - console.log("[Gamepad] Blocked click outside overlay"); - return false; - } - } - } - - // Block browser history navigation when overlay is active - function blockHistoryNavigation(event) { - const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); - if (hasActiveOverlay && state.gamepadConnected) { - console.log("[Gamepad] Blocked history navigation (popstate)"); - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - // Push the current state back to prevent navigation - window.history.pushState(null, "", window.location.href); - return false; - } - } - - function init() { - if (!isBigPictureMode()) { - console.log("[Gamepad] Not in Big Picture Mode, skipping initialization"); - return; - } - - console.log("[Gamepad] Initializing Gamepad Navigation System..."); - - window.addEventListener("gamepadconnected", onGamepadConnected); - window.addEventListener("gamepaddisconnected", onGamepadDisconnected); - - // Block Steam's keyboard navigation when overlay is active - document.addEventListener("keydown", blockSteamNavigation, true); - document.addEventListener("keyup", blockSteamNavigation, true); - - // Block clicks outside overlay when gamepad is active - document.addEventListener("click", blockSteamClicks, true); - document.addEventListener("mousedown", blockSteamClicks, true); - - // Block browser history navigation (back button) - window.addEventListener("popstate", blockHistoryNavigation, true); - - const gamepads = navigator.getGamepads(); - for (let i = 0; i < gamepads.length; i++) { - if (gamepads[i]) { - onGamepadConnected({ - gamepad: gamepads[i], - }); - break; - } - } - - // Disabled: MutationObserver auto-scanning - /* - observer.observe(document.body, { - childList: true, - subtree: true - }); - */ - - // Don't scan on init - only scan when overlays are opened - // scanFocusableElements(); - - console.log("[Gamepad] Initialization complete"); - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } - - window.GamepadNav = { - scanElements: scanFocusableElements, - setBackHandler: function (fn) { - if (typeof fn === "function") { - onBackHandler = fn; - } - }, - focusElement: focusElement, - getCurrentIndex: function () { - return state.currentFocusIndex; - }, - getElements: function () { - return state.focusableElements; - }, - isConnected: function () { - return state.gamepadConnected; - }, - }; -})(); - -// ============================================ -// LUATOOLS MAIN CODE -// ============================================ -(function () { - "use strict"; - - // Big Picture Mode Detector - Multi-method system for maximum reliability - function isBigPictureMode() { - const htmlClasses = document.documentElement.className; - const userAgent = navigator.userAgent; - - // METHOD 1: HTML Classes - // Big Picture: 'BasicUI' + 'touch' - // Normal Mode: 'DesktopUI' (without 'touch') - const hasBigPictureClass = htmlClasses.includes("BasicUI"); - const hasDesktopClass = htmlClasses.includes("DesktopUI"); - const hasTouchClass = htmlClasses.includes("touch"); - - // METHOD 2: User Agent - // Big Picture: 'Valve Steam Gamepad' - // Normal Mode: 'Valve Steam Client' - const isGamepadUA = userAgent.includes("Valve Steam Gamepad"); - const isClientUA = userAgent.includes("Valve Steam Client"); - - // Scoring system: each indicator adds points - let bigPictureScore = 0; - - // BasicUI/DesktopUI class (weight: 3 points - highly reliable) - if (hasBigPictureClass) bigPictureScore += 3; - if (hasDesktopClass) bigPictureScore -= 3; - - // User Agent (weight: 2 points - reliable) - if (isGamepadUA) bigPictureScore += 2; - if (isClientUA) bigPictureScore -= 2; - - // Touch class (weight: 1 point - additional indicator) - if (hasTouchClass) bigPictureScore += 1; - - // Positive score = Big Picture, negative/zero = Normal - const isBigPicture = bigPictureScore > 0; - - return isBigPicture; - } - - // Detect and save mode at startup - window.__LUATOOLS_IS_BIG_PICTURE__ = isBigPictureMode(); - - // Forward logs to Millennium backend so they appear in the dev console - function backendLog(message) { - try { - if ( - typeof Millennium !== "undefined" && - typeof Millennium.callServerMethod === "function" - ) { - Millennium.callServerMethod("luatools", "Logger.log", { - message: String(message), - }); - } - } catch (err) { - if (typeof console !== "undefined" && console.warn) { - console.warn("[LuaTools] backendLog failed", err); - } - } - } - - backendLog("LuaTools script loaded"); - backendLog( - "Mode Detection: " + - (window.__LUATOOLS_IS_BIG_PICTURE__ ? "BIG PICTURE MODE" : "NORMAL MODE"), - ); - // anti-spam state - const logState = { - missingOnce: false, - existsOnce: false, - }; - // click/run debounce state - const runState = { - inProgress: false, - appid: null, - }; - - // Games Database - backend handles caching - function fetchGamesDatabase() { - if ( - typeof Millennium === "undefined" || - typeof Millennium.callServerMethod !== "function" - ) { - return Promise.resolve({}); - } - return Millennium.callServerMethod("luatools", "GetGamesDatabase", { - contentScriptQuery: "", - }) - .then(function (res) { - var payload = (res && (res.result || res.value)) || res; - if (typeof payload === "string") { - try { - payload = JSON.parse(payload); - } catch (e) { } - } - return payload || {}; - }) - .catch(function (err) { - console.warn("[LuaTools] Failed to fetch games database", err); - return {}; - }); - } - - // Fixes - backend handles caching - function fetchFixes(appid) { - if ( - typeof Millennium === "undefined" || - typeof Millennium.callServerMethod !== "function" - ) { - return Promise.resolve(null); - } - return Millennium.callServerMethod("luatools", "CheckForFixes", { - appid: appid, - contentScriptQuery: "", - }) - .then(function (res) { - const payload = typeof res === "string" ? JSON.parse(res) : res; - return payload && payload.success ? payload : null; - }) - .catch(function (err) { - console.warn("[LuaTools] Failed to fetch fixes", err); - return null; - }); - } - - // Cache for game names fetched from Steam API - const steamGameNameCache = {}; - - /** - * get game name separately without cached full appid - * @param {number|string} appid - * @returns {Promise} - */ - function fetchSteamGameName(appid) { - if (!appid) return Promise.resolve(null); - if (steamGameNameCache[appid]) - return Promise.resolve(steamGameNameCache[appid]); - - return fetch( - "https://store.steampowered.com/api/appdetails?appids=" + - appid + - "&filters=basic", - ) - .then(function (res) { - return res.json(); - }) - .then(function (data) { - if ( - data && - data[appid] && - data[appid].success && - data[appid].data && - data[appid].data.name - ) { - const name = data[appid].data.name; - steamGameNameCache[appid] = name; - return name; - } - return null; - }) - .catch(function (err) { - backendLog( - "LuaTools: fetchSteamGameName error for " + appid + ": " + err, - ); - return null; - }); - } - - const TRANSLATION_PLACEHOLDER = "translation missing"; - - function applyTranslationBundle(bundle) { - if (!bundle || typeof bundle !== "object") return; - const stored = window.__LuaToolsI18n || {}; - if (bundle.language) { - stored.language = String(bundle.language); - } else if (!stored.language) { - stored.language = "en"; - } - if (bundle.strings && typeof bundle.strings === "object") { - stored.strings = bundle.strings; - } else if (!stored.strings) { - stored.strings = {}; - } - if (Array.isArray(bundle.locales)) { - stored.locales = bundle.locales; - } else if (!Array.isArray(stored.locales)) { - stored.locales = []; - } - stored.ready = true; - stored.lastFetched = Date.now(); - window.__LuaToolsI18n = stored; - } - - // Theme definitions (pulled from themes.json; inline only used as fallback) - const DEFAULT_THEMES = { - original: { - name: "Original", - bgPrimary: "#1b2838", - bgSecondary: "#2a475e", - bgTertiary: "rgba(44, 79, 112, 0.86)", - bgHover: "rgba(68, 112, 153, 0.86)", - bgContainer: "rgba(40, 74, 102, 0.6)", - bgContainerGradient: "rgba(40, 74, 102, 0.85), #0b141e", - accent: "#66c0f4", - accentLight: "#a4d7f5", - accentDark: "#4a9ece", - border: "rgba(102,192,244,0.3)", - borderHover: "rgba(102,192,244,0.8)", - text: "#fff", - textSecondary: "#c7d5e0", - gradient: "linear-gradient(135deg, #66c0f4 0%, #a4d7f5 100%)", - gradientLight: "linear-gradient(135deg, #a4d7f5 0%, #7dd4ff 100%)", - shadow: "rgba(102,192,244,0.4)", - shadowHover: "rgba(102,192,244,0.6)", - }, - }; - - // Runtime THEMES map - start with fallback, then hydrate from themes.json/backend. - let THEMES = DEFAULT_THEMES; - let themesLoaded = false; - - function normalizeThemesPayload(input) { - try { - let payload = input; - if (typeof payload === "string") payload = JSON.parse(payload); - if (payload && typeof payload === "object") { - if (Array.isArray(payload.themes)) return payload.themes; - if (Array.isArray(payload.result)) return payload.result; - if (payload.result && Array.isArray(payload.result.themes)) - return payload.result.themes; - if (Array.isArray(payload.value)) return payload.value; - } - if (Array.isArray(payload)) return payload; - } catch (_) { - /* ignore */ - } - return []; - } - - function _applyBackendThemes(themesArray) { - try { - const themes = normalizeThemesPayload(themesArray); - if (!Array.isArray(themes) || themes.length === 0) return; - const map = {}; - themes.forEach(function (t) { - if (!t || (!t.value && !t.key)) return; - const key = t.value || t.key; - map[key] = Object.assign({}, t, { - value: key, - name: t.name || key, - }); - }); - if (Object.keys(map).length === 0) return; - // Merge into existing THEMES if themes have been loaded, otherwise start from DEFAULT_THEMES - THEMES = Object.assign({}, themesLoaded ? THEMES : DEFAULT_THEMES, map); - themesLoaded = true; - try { - ensureLuaToolsStyles(); - } catch (_) { } - } catch (e) { - console.warn("Failed to apply backend themes", e); - } - } - - function loadThemesFromFile() { - try { - return fetch("themes/themes.json", { - cache: "no-store", - }) - .then(function (res) { - if (!res || !res.ok) return null; - return res.json(); - }) - .then(function (json) { - if (!json) return null; - _applyBackendThemes(json); - return json; - }) - .catch(function () { - return null; - }); - } catch (_) { - return Promise.resolve(null); - } - } - - function loadThemesFromBackend() { - if ( - typeof Millennium === "undefined" || - typeof Millennium.callServerMethod !== "function" - ) { - return Promise.resolve(null); - } - return Millennium.callServerMethod("luatools", "GetThemes", { - contentScriptQuery: "", - }) - .then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success && payload.themes) { - _applyBackendThemes(payload.themes); - return payload.themes; - } - } catch (_) { } - return null; - }) - .catch(function () { - return null; - }); - } - - function loadThemes() { - return Promise.all([loadThemesFromFile(), loadThemesFromBackend()]).catch( - function () { - /* ignore */ - }, - ); - } - - // Trigger load (non-blocking). Keeps DEFAULT_THEMES as a safe fallback. - const themeLoadPromise = loadThemes(); - - function getCurrentThemeKey() { - try { - const settings = window.__LuaToolsSettings || {}; - const themeKey = (settings.values || {}).general || {}; - return themeKey.theme || "original"; - } catch (e) { - return "original"; - } - } - - function getCurrentTheme() { - try { - const themeName = getCurrentThemeKey(); - const theme = THEMES[themeName] || THEMES.original; - if (!THEMES[themeName]) { - try { - backendLog( - "LuaTools: Theme " + - themeName + - " not found in THEMES, using original. Available: " + - Object.keys(THEMES).join(", "), - ); - } catch (_) { } - } - return theme; - } catch (e) { - return THEMES.original; - } - } - - function hexToRgb(hex) { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? [ - parseInt(result[1], 16), - parseInt(result[2], 16), - parseInt(result[3], 16), - ] - : [102, 192, 244]; - } - - function getThemeColors() { - const theme = getCurrentTheme(); - const rgb = hexToRgb(theme.accent); - return { - modalBg: `linear-gradient(135deg, ${theme.bgPrimary} 0%, ${theme.bgSecondary} 100%)`, - border: theme.accent, - borderRgba: theme.border, - text: theme.text, - textSecondary: theme.textSecondary, - accent: theme.accent, - accentLight: theme.accentLight, - gradient: theme.gradient, - gradientLight: theme.gradientLight, - shadow: theme.shadow, - shadowHover: theme.shadowHover, - shadowRgba: theme.shadow.replace("0.4", "0.3"), - bgContainer: theme.bgContainer, - bgTertiary: theme.bgTertiary, - bgHover: theme.bgHover, - rgbString: rgb.join(","), - }; - } - - function generateThemeStyles(theme) { - return ` - /* Force overlay backdrops to follow the active theme (overrides inline styles) */ - .luatools-settings-overlay, - .luatools-overlay, - .luatools-fixes-results-overlay, - .luatools-loading-fixes-overlay, - .luatools-unfix-overlay, - .luatools-settings-manager-overlay, - .luatools-loadedapps-overlay { - background: rgba(${theme.rgbString}, 0.12) !important; - backdrop-filter: blur(8px) !important; - } - - /* Prefer overlay-scoped select rules to override theme CSS files */ - .luatools-settings-overlay select, - .luatools-settings-manager-overlay select, - .luatools-overlay select, - .luatools-fixes-results-overlay select, - .luatools-loadedapps-overlay select { - background-color: ${theme.bgTertiary} !important; - color: ${theme.text} !important; - border: 1px solid ${theme.border} !important; - border-radius: 3px !important; - padding: 6px 8px !important; - font-size: 14px !important; - } - .luatools-settings-overlay select option, - .luatools-settings-manager-overlay select option, - .luatools-overlay select option, - .luatools-fixes-results-overlay select option, - .luatools-loadedapps-overlay select option { - background-color: ${theme.bgPrimary} !important; - color: ${theme.text} !important; - } - .luatools-settings-overlay select option:checked, - .luatools-settings-manager-overlay select option:checked, - .luatools-overlay select option:checked, - .luatools-fixes-results-overlay select option:checked, - .luatools-loadedapps-overlay select option:checked { - background: ${theme.accent} !important; - color: ${theme.text} !important; - } - .luatools-settings-overlay select:hover, - .luatools-settings-manager-overlay select:hover, - .luatools-overlay select:hover, - .luatools-fixes-results-overlay select:hover, - .luatools-loadedapps-overlay select:hover { - border-color: ${theme.borderHover} !important; - } - .luatools-settings-overlay select:focus, - .luatools-settings-manager-overlay select:focus, - .luatools-overlay select:focus, - .luatools-fixes-results-overlay select:focus, - .luatools-loadedapps-overlay select:focus { - outline: none !important; - border-color: ${theme.accent} !important; - box-shadow: 0 0 0 2px ${theme.shadow} !important; - } - .luatools-btn { - padding: 12px 24px; - background: ${theme.bgSecondary}; - border: 2px solid ${theme.border.replace("0.3", "0.5")}; - border-radius: 12px; - color: ${theme.text}; - font-size: 15px; - font-weight: 600; - text-decoration: none; - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); - cursor: pointer; - box-shadow: 0 2px 8px ${theme.shadow}; - letter-spacing: 0.3px; - } - .luatools-btn:hover:not([data-disabled="1"]) { - background: ${theme.bgHover}; - transform: translateY(-2px); - box-shadow: 0 6px 20px ${theme.shadowHover}; - border-color: ${theme.borderHover}; - } - .luatools-btn.primary { - background: ${theme.gradient}; - border-color: ${theme.borderHover.replace("0.8", "0.8")}; - color: ${theme.text}; - font-weight: 700; - box-shadow: 0 4px 15px ${theme.shadow}, inset 0 1px 0 rgba(255,255,255,0.3); - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); - } - .luatools-btn.primary:hover:not([data-disabled="1"]) { - background: ${theme.gradientLight}; - transform: translateY(-3px) scale(1.03); - box-shadow: 0 8px 25px rgba(26, 159, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.4); - } - - /* Modern Toggle Switch */ - .luatools-toggle-container { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - } - .luatools-toggle-label-wrap { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - margin-right: 20px; - } - .luatools-toggle { - position: relative; - display: inline-block; - width: 50px; - height: 26px; - flex-shrink: 0; - } - .luatools-toggle input { - opacity: 0; - width: 0; - height: 0; - } - .luatools-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.1); - transition: .4s; - border-radius: 34px; - border: 1px solid rgba(255, 255, 255, 0.2); - } - .luatools-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: #ffffff; - transition: .4s; - border-radius: 50%; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - } - input:checked + .luatools-slider { - background-color: #1a9fff; - border-color: #1a9fff; - } - input:checked + .luatools-slider:before { - transform: translateX(24px); - } - .luatools-slider:hover { - border-color: #1a9fff; - } - - @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } - } - @keyframes slideUp { - from { - opacity: 0; - transform: scale(0.9); - } - to { - opacity: 1; - transform: scale(1); - } - } - @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } - } - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } - } - - /* Store header button - LuaTools themed icon button */ - button.luatools-header-button { - display: inline-flex; - align-items: center; - justify-content: center; - align-self: center; - width: 36px; - height: 36px; - padding: 0; - border: 2px solid ${theme.border.replace("0.3", "0.5")}; - border-radius: 4px; - background: ${theme.bgSecondary}; - color: ${theme.text}; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); - box-shadow: 0 2px 8px ${theme.shadow}; - margin-left: 12px; - } - button.luatools-header-button:hover { - background: ${theme.bgHover}; - transform: translateY(-1px); - box-shadow: 0 4px 12px ${theme.shadowHover}; - border-color: ${theme.borderHover}; - } - button.luatools-header-button:focus-visible { - outline: 2px solid ${theme.accent}; - outline-offset: 2px; - } - button.luatools-header-button img, - button.luatools-header-button svg { - height: 16px; - width: 16px; - } - `; - } - - function ensureThemeStylesheet(themeKey) { - const id = "luatools-theme-css"; - const href = "themes/" + themeKey + ".css"; - const link = document.getElementById(id); - if (link) { - const currentTheme = link.getAttribute("data-theme"); - if (currentTheme === themeKey) return; - link.href = href; - link.setAttribute("data-theme", themeKey); - return; - } - try { - const el = document.createElement("link"); - el.id = id; - el.rel = "stylesheet"; - el.href = href; - el.setAttribute("data-theme", themeKey); - document.head.appendChild(el); - } catch (err) { - backendLog("LuaTools: Theme CSS injection failed: " + err); - } - } - - function ensureLuaToolsStyles() { - const styleEl = document.getElementById("luatools-styles"); - const themeKey = getCurrentThemeKey(); - const theme = getCurrentTheme(); - const styles = generateThemeStyles(theme); - - try { - ensureThemeStylesheet(themeKey); - } catch (_) { } - - if (styleEl) { - styleEl.textContent = styles; - } else { - try { - const style = document.createElement("style"); - style.id = "luatools-styles"; - style.textContent = styles; - document.head.appendChild(style); - } catch (err) { - backendLog("LuaTools: Styles injection failed: " + err); - } - } - } - - function ensureFontAwesome() { - if (document.getElementById("luatools-fontawesome")) return; - try { - const link = document.createElement("link"); - link.id = "luatools-fontawesome"; - link.rel = "stylesheet"; - link.href = - "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"; - link.integrity = - "sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="; - link.crossOrigin = "anonymous"; - link.referrerPolicy = "no-referrer"; - document.head.appendChild(link); - } catch (err) { - backendLog("LuaTools: Font Awesome injection failed: " + err); - } - } - - function showCustomApiModal() { - try { - const old = document.querySelector(".luatools-custom-api-overlay"); - if (old) old.remove(); - } catch (_) {} - - ensureLuaToolsStyles(); - ensureFontAwesome(); - - const overlay = document.createElement("div"); - overlay.className = "luatools-custom-api-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:500px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const title = document.createElement("div"); - title.style.cssText = `font-size:22px;font-weight:600;margin-bottom:8px;color:${colors.text};`; - title.textContent = lt("Add Custom API"); - - const desc = document.createElement("div"); - desc.style.cssText = `font-size:14px;color:${colors.textSecondary};margin-bottom:20px;line-height:1.5;`; - desc.innerHTML = lt("Enter the custom API details below. You MUST include <appid> in the URL where the Game ID goes, and optionally <apikey> if an API key is required."); - - const body = document.createElement("div"); - body.style.cssText = "display:flex;flex-direction:column;gap:16px;margin-bottom:24px;"; - - function createInputGroup(labelText, placeholder, type = "text") { - const wrap = document.createElement("div"); - wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;"; - const lbl = document.createElement("label"); - lbl.style.cssText = `font-size:13px;font-weight:600;color:${colors.text};`; - lbl.textContent = labelText; - const input = document.createElement("input"); - input.type = type; - input.placeholder = placeholder; - input.style.cssText = `width:100%;padding:10px 12px;background:rgba(0,0,0,0.2);border:1px solid ${colors.borderRgba};border-radius:8px;color:${colors.text};font-size:14px;outline:none;transition:border-color 0.2s;box-sizing:border-box;`; - input.onfocus = () => (input.style.borderColor = colors.accent); - input.onblur = () => (input.style.borderColor = colors.borderRgba); - wrap.appendChild(lbl); - wrap.appendChild(input); - return { wrap, input }; - } - - const nameField = createInputGroup("API Name", "My Custom API"); - const urlField = createInputGroup("API URL", "https://example.com/download?id=&key="); - - body.appendChild(nameField.wrap); - body.appendChild(urlField.wrap); - - const toggleWrap = document.createElement("div"); - toggleWrap.style.cssText = "display:flex;align-items:center;gap:10px;margin-top:8px;cursor:pointer;"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.style.cssText = `width:16px;height:16px;accent-color:${colors.accent};cursor:pointer;`; - - const toggleLabel = document.createElement("span"); - toggleLabel.style.cssText = `font-size:14px;color:${colors.text};`; - toggleLabel.textContent = lt("Require API Key"); - - toggleWrap.appendChild(checkbox); - toggleWrap.appendChild(toggleLabel); - - const apiKeyField = createInputGroup("API Key", "Enter your API key here"); - apiKeyField.wrap.style.display = "none"; - - toggleWrap.onclick = function(e) { - if (e.target !== checkbox) checkbox.checked = !checkbox.checked; - apiKeyField.wrap.style.display = checkbox.checked ? "flex" : "none"; - }; - - body.appendChild(toggleWrap); - body.appendChild(apiKeyField.wrap); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:12px;margin-top:24px;"; - - const cancelBtn = document.createElement("button"); - cancelBtn.textContent = lt("Cancel"); - cancelBtn.style.cssText = `padding:8px 16px;background:transparent;border:1px solid ${colors.borderRgba};border-radius:8px;color:${colors.text};font-size:14px;font-weight:500;cursor:pointer;transition:all 0.2s ease;`; - cancelBtn.onmouseover = () => (cancelBtn.style.background = `rgba(255,255,255,0.1)`); - cancelBtn.onmouseout = () => (cancelBtn.style.background = "transparent"); - cancelBtn.onclick = () => overlay.remove(); - - const saveBtn = document.createElement("button"); - saveBtn.textContent = lt("Save API"); - saveBtn.style.cssText = `padding:8px 24px;background:${colors.accent};border:none;border-radius:8px;color:#fff;font-size:14px;font-weight:600;cursor:pointer;transition:transform 0.1s, filter 0.2s;`; - saveBtn.onmouseover = () => (saveBtn.style.filter = "brightness(1.1)"); - saveBtn.onmouseout = () => (saveBtn.style.filter = "none"); - saveBtn.onmousedown = () => (saveBtn.style.transform = "scale(0.96)"); - saveBtn.onmouseup = () => (saveBtn.style.transform = "scale(1)"); - - saveBtn.onclick = function() { - const name = nameField.input.value.trim(); - const url = urlField.input.value.trim(); - const needsKey = checkbox.checked; - const apiKey = apiKeyField.input.value.trim(); - - if (!name || !url) { - ShowLuaToolsAlert("Error", lt("Name and URL are required.")); - return; - } - - try { - const dummyUrl = url.replace("", "123").replace("", "abc"); - const parsedUrl = new URL(dummyUrl); - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { - ShowLuaToolsAlert("Error", lt("URL must start with http:// or https://")); - return; - } - } catch (e) { - ShowLuaToolsAlert("Error", lt("Please enter a valid URL.")); - return; - } - - if (!url.includes("")) { - ShowLuaToolsAlert("Error", lt("URL must contain placeholder.")); - return; - } - if (needsKey && !url.includes("")) { - ShowLuaToolsAlert("Error", lt("URL must contain when Require API Key is checked.")); - return; - } - if (needsKey && !apiKey) { - ShowLuaToolsAlert("Error", lt("Please enter an API Key.")); - return; - } - - saveBtn.textContent = lt("Saving..."); - saveBtn.disabled = true; - saveBtn.style.opacity = "0.7"; - - Millennium.callServerMethod("luatools", "AddCustomApi", { - name: name, - url: url, - api_key: needsKey ? apiKey : "", - contentScriptQuery: "" - }).then(function(res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success) { - overlay.remove(); - ShowLuaToolsAlert("Success", lt("Custom API added successfully!")); - } else { - saveBtn.textContent = lt("Save API"); - saveBtn.disabled = false; - saveBtn.style.opacity = "1"; - ShowLuaToolsAlert("Error", payload.error || "Failed to save API."); - } - } catch (e) { - saveBtn.textContent = lt("Save API"); - saveBtn.disabled = false; - saveBtn.style.opacity = "1"; - ShowLuaToolsAlert("Error", e.toString()); - } - }); - }; - - btnRow.appendChild(cancelBtn); - btnRow.appendChild(saveBtn); - - modal.appendChild(title); - modal.appendChild(desc); - modal.appendChild(body); - modal.appendChild(btnRow); - overlay.appendChild(modal); - document.body.appendChild(overlay); - } - - function showSettingsPopup() { - if ( - document.querySelector(".luatools-settings-overlay") || - settingsMenuPending - ) - return; - settingsMenuPending = true; - ensureTranslationsLoaded(false) - .catch(function () { - return null; - }) - .finally(function () { - settingsMenuPending = false; - if (document.querySelector(".luatools-settings-overlay")) return; - - try { - const d = document.querySelector(".luatools-overlay"); - if (d) d.remove(); - } catch (_) { } - ensureLuaToolsStyles(); - ensureFontAwesome(); - - const overlay = document.createElement("div"); - overlay.className = "luatools-settings-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `position:relative;background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:460px;padding:20px 24px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const header = document.createElement("div"); - header.style.cssText = `display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid ${colors.borderRgba};`; - - const title = document.createElement("div"); - title.style.cssText = `display:flex;align-items:center;gap:10px;font-size:22px;color:${colors.text};font-weight:600;`; - const titleIcon = document.createElement("img"); - titleIcon.style.cssText = "width:24px;height:24px;border-radius:4px;"; - titleIcon.alt = "LuaTools"; - try { - Millennium.callServerMethod("luatools", "GetIconDataUrl", { - contentScriptQuery: "", - }).then(function (res) { - try { - const p = typeof res === "string" ? JSON.parse(res) : res; - titleIcon.src = - p && p.success && p.dataUrl - ? p.dataUrl - : "LuaTools/luatools-icon.png"; - } catch (_) { - titleIcon.src = "LuaTools/luatools-icon.png"; - } - }); - } catch (_) { - titleIcon.src = "LuaTools/luatools-icon.png"; - } - titleIcon.onerror = function () { - this.style.display = "none"; - }; - const titleText = document.createElement("span"); - titleText.textContent = t("menu.title", "LuaTools · Menu"); - title.appendChild(titleIcon); - title.appendChild(titleText); - - const iconButtons = document.createElement("div"); - iconButtons.style.cssText = "display:flex;gap:12px;"; - - function createIconButton(id, iconClass, titleKey, titleFallback) { - const btn = document.createElement("a"); - btn.id = id; - btn.href = "#"; - const btnColors = getThemeColors(); - btn.style.cssText = `display:flex;align-items:center;justify-content:center;width:40px;height:40px;background:rgba(${btnColors.rgbString},0.1);border:1px solid ${btnColors.borderRgba};border-radius:10px;color:${btnColors.accent};font-size:18px;text-decoration:none;transition:all 0.3s ease;cursor:pointer;`; - btn.innerHTML = ''; - btn.title = t(titleKey, titleFallback); - btn.onmouseover = function () { - this.style.background = `rgba(${btnColors.rgbString},0.25)`; - this.style.transform = "translateY(-2px) scale(1.05)"; - this.style.boxShadow = `0 8px 16px ${btnColors.shadowRgba}`; - this.style.borderColor = btnColors.accent; - }; - btn.onmouseout = function () { - this.style.background = `rgba(${btnColors.rgbString},0.1)`; - this.style.transform = "translateY(0) scale(1)"; - this.style.boxShadow = "none"; - this.style.borderColor = btnColors.borderRgba; - }; - iconButtons.appendChild(btn); - return btn; - } - - const body = document.createElement("div"); - body.style.cssText = - "font-size:14px;line-height:1.6;margin-bottom:12px;"; - - // Add mouse mode tip for Big Picture - if (window.__LUATOOLS_IS_BIG_PICTURE__) { - const tip = document.createElement("div"); - tip.style.cssText = - "background:rgba(102,192,244,0.15);border-left:3px solid #66c0f4;padding:12px 16px;border-radius:6px;font-size:13px;color:#c7d5e0;margin-bottom:16px;line-height:1.5;"; - tip.innerHTML = - '' + - t( - "bigpicture.mouseTip", - "To use mouse mode in Steam: Guide Button + Right Joystick, click with RB", - ); - body.appendChild(tip); - } - - const container = document.createElement("div"); - container.style.cssText = - "margin-top:16px;display:flex;flex-direction:column;gap:12px;align-items:stretch;"; - - function createCardButton(id, key, fallback, iconClass) { - const btn = document.createElement("a"); - btn.id = id; - btn.href = "#"; - const btnColors = getThemeColors(); - btn.style.cssText = `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:1;background:rgba(${btnColors.rgbString},0.06);border:1px solid ${btnColors.borderRgba};border-radius:12px;color:${btnColors.text};font-size:11px;font-weight:500;text-decoration:none;transition:all 0.2s ease;cursor:pointer;text-align:center;padding:14px 6px;min-width:0;`; - const iconHtml = iconClass - ? '' - : ""; - const textSpan = - '' + - t(key, fallback) + - ""; - btn.innerHTML = iconHtml + textSpan; - btn.onmouseover = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.15)`; - this.style.transform = "translateY(-2px)"; - this.style.boxShadow = `0 8px 20px ${c.shadow.replace("0.4", "0.15")}`; - this.style.borderColor = c.accent; - }; - btn.onmouseout = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.06)`; - this.style.transform = "translateY(0)"; - this.style.boxShadow = "none"; - this.style.borderColor = c.borderRgba; - }; - return btn; - } - - const discordBtn = createIconButton( - "lt-settings-discord", - "fa-brands fa-discord", - "menu.discord", - "Discord", - ); - const settingsManagerBtn = createIconButton( - "lt-settings-open-manager", - "fa-gear", - "menu.settings", - "Settings", - ); - const customApiBtn = createIconButton( - "lt-settings-custom-api", - "fa-solid fa-code-branch", - "menu.customApi", - "Custom API", - ); - const closeBtn = createIconButton( - "lt-settings-close", - "fa-xmark", - "settings.close", - "Close", - ); - - // Check if we are on a game page - const isGamePage = window.location.href.includes("/app/"); - - if (customApiBtn) { - customApiBtn.addEventListener("click", function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - showCustomApiModal(); - }); - } - - const removeBtn = document.createElement("a"); - removeBtn.id = "lt-settings-remove-lua"; - removeBtn.href = "#"; - const removeBtnColors = getThemeColors(); - removeBtn.style.cssText = `display:none;align-items:center;justify-content:center;gap:8px;padding:10px 16px;background:rgba(${removeBtnColors.rgbString},0.06);border:1px solid ${removeBtnColors.borderRgba};border-radius:10px;color:${removeBtnColors.textSecondary};font-size:13px;font-weight:500;text-decoration:none;transition:all 0.2s ease;cursor:pointer;text-align:center;`; - removeBtn.innerHTML = - '' + - t("menu.removeLuaTools", "Remove via LuaTools") + - ""; - removeBtn.onmouseover = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.15)`; - this.style.borderColor = c.accent; - }; - removeBtn.onmouseout = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.06)`; - this.style.borderColor = c.borderRgba; - }; - container.appendChild(removeBtn); - - // Card button grid - const cardGrid = document.createElement("div"); - cardGrid.style.cssText = - "display:flex;gap:10px;justify-content:center;"; - - const fixesMenuBtn = createCardButton( - "lt-settings-fixes-menu", - "menu.fixesMenu", - "Fixes Menu", - "fa-wrench", - ); - if (isGamePage) cardGrid.appendChild(fixesMenuBtn); - - const checkBtn = createCardButton( - "lt-settings-check", - "menu.checkForUpdates", - "Check Updates", - "fa-cloud-arrow-down", - ); - cardGrid.appendChild(checkBtn); - - const fetchApisBtn = createCardButton( - "lt-settings-fetch-apis", - "menu.fetchFreeApis", - "Fetch APIs", - "fa-server", - ); - cardGrid.appendChild(fetchApisBtn); - - container.appendChild(cardGrid); - - body.appendChild(container); - - header.appendChild(title); - header.appendChild(iconButtons); - modal.appendChild(header); - modal.appendChild(body); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - - if (checkBtn) { - checkBtn.addEventListener("click", function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - try { - Millennium.callServerMethod("luatools", "CheckForUpdatesNow", { - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = - typeof res === "string" ? JSON.parse(res) : res; - const msg = - payload && payload.message - ? String(payload.message) - : lt("No updates available."); - ShowLuaToolsAlert("LuaTools", msg); - } catch (_) { } - }); - } catch (_) { } - }); - } - - if (discordBtn) { - discordBtn.addEventListener("click", function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - const url = "https://discord.gg/luatools"; - try { - Millennium.callServerMethod("luatools", "OpenExternalUrl", { - url, - contentScriptQuery: "", - }); - } catch (_) { } - }); - } - - if (fetchApisBtn) { - fetchApisBtn.addEventListener("click", function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - try { - Millennium.callServerMethod("luatools", "FetchFreeApisNow", { - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = - typeof res === "string" ? JSON.parse(res) : res; - const ok = payload && payload.success; - const count = payload && payload.count; - const successText = lt("Loaded free APIs: {count}").replace( - "{count}", - count != null ? count : "?", - ); - const failText = - payload && payload.error - ? String(payload.error) - : lt("Failed to load free APIs."); - const text = ok ? successText : failText; - ShowLuaToolsAlert("LuaTools", text); - } catch (_) { } - }); - } catch (_) { } - }); - } - - if (closeBtn) { - closeBtn.addEventListener("click", function (e) { - e.preventDefault(); - overlay.remove(); - }); - } - - if (settingsManagerBtn) { - // This is the icon button now - settingsManagerBtn.addEventListener("click", function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - showSettingsManagerPopup(false, showSettingsPopup); - }); - } - - if (fixesMenuBtn) { - fixesMenuBtn.addEventListener("click", function (e) { - e.preventDefault(); - try { - const match = - window.location.href.match( - /https:\/\/store\.steampowered\.com\/app\/(\d+)/, - ) || - window.location.href.match( - /https:\/\/steamcommunity\.com\/app\/(\d+)/, - ); - const appid = match - ? parseInt(match[1], 10) - : window.__LuaToolsCurrentAppId || NaN; - if (isNaN(appid)) { - try { - overlay.remove(); - } catch (_) { } - const errText = t( - "menu.error.noAppId", - "Could not determine game AppID", - ); - ShowLuaToolsAlert("LuaTools", errText); - return; - } - - Millennium.callServerMethod("luatools", "GetGameInstallPath", { - appid, - contentScriptQuery: "", - }) - .then(function (pathRes) { - try { - let isGameInstalled = false; - const pathPayload = - typeof pathRes === "string" - ? JSON.parse(pathRes) - : pathRes; - if ( - pathPayload && - pathPayload.success && - pathPayload.installPath - ) { - isGameInstalled = true; - window.__LuaToolsGameInstallPath = - pathPayload.installPath; - } - window.__LuaToolsGameIsInstalled = isGameInstalled; - try { - overlay.remove(); - } catch (_) { } - showFixesLoadingPopupAndCheck(appid); - } catch (err) { - backendLog("LuaTools: GetGameInstallPath error: " + err); - try { - overlay.remove(); - } catch (_) { } - } - }) - .catch(function () { - try { - overlay.remove(); - } catch (_) { } - const errorText = t( - "menu.error.getPath", - "Error getting game path", - ); - ShowLuaToolsAlert("LuaTools", errorText); - }); - } catch (err) { - backendLog("LuaTools: Fixes Menu button error: " + err); - } - }); - } - - try { - const match = - window.location.href.match( - /https:\/\/store\.steampowered\.com\/app\/(\d+)/, - ) || - window.location.href.match( - /https:\/\/steamcommunity\.com\/app\/(\d+)/, - ); - const appid = match - ? parseInt(match[1], 10) - : window.__LuaToolsCurrentAppId || NaN; - if ( - !isNaN(appid) && - typeof Millennium !== "undefined" && - typeof Millennium.callServerMethod === "function" - ) { - Millennium.callServerMethod("luatools", "HasLuaToolsForApp", { - appid, - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - const exists = !!( - payload && - payload.success && - payload.exists === true - ); - if (exists) { - const doDelete = function () { - try { - Millennium.callServerMethod( - "luatools", - "DeleteLuaToolsForApp", - { - appid, - contentScriptQuery: "", - }, - ) - .then(function () { - try { - window.__LuaToolsButtonInserted = false; - window.__LuaToolsPresenceCheckInFlight = false; - window.__LuaToolsPresenceCheckAppId = undefined; - addLuaToolsButton(); - const successText = t( - "menu.remove.success", - "LuaTools removed for this app.", - ); - ShowLuaToolsAlert("LuaTools", successText); - } catch (err) { - backendLog( - "LuaTools: post-delete cleanup failed: " + err, - ); - } - }) - .catch(function (err) { - const failureText = t( - "menu.remove.failure", - "Failed to remove LuaTools.", - ); - const errMsg = - err && err.message ? err.message : failureText; - ShowLuaToolsAlert("LuaTools", errMsg); - }); - } catch (err) { - backendLog("LuaTools: doDelete failed: " + err); - } - }; - - removeBtn.style.display = "flex"; - removeBtn.onclick = function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - const confirmMessage = t( - "menu.remove.confirm", - "Remove via LuaTools for this game?", - ); - showLuaToolsConfirm( - "LuaTools", - confirmMessage, - function () { - doDelete(); - }, - function () { - try { - showSettingsPopup(); - } catch (_) { } - }, - ); - }; - } else { - removeBtn.style.display = "none"; - } - } catch (_) { } - }); - } - } catch (_) { } - }); - } - - function ensureTranslationsLoaded(forceRefresh, preferredLanguage) { - try { - if ( - !forceRefresh && - window.__LuaToolsI18n && - window.__LuaToolsI18n.ready - ) { - return Promise.resolve(window.__LuaToolsI18n); - } - if ( - typeof Millennium === "undefined" || - typeof Millennium.callServerMethod !== "function" - ) { - window.__LuaToolsI18n = window.__LuaToolsI18n || { - language: "en", - locales: [], - strings: {}, - ready: false, - }; - return Promise.resolve(window.__LuaToolsI18n); - } - const settingsVals = - ((window.__LuaToolsSettings || {}).values || {}).general || {}; - const useSteamLang = - typeof settingsVals.useSteamLanguage === "boolean" - ? settingsVals.useSteamLanguage - : true; - let targetLanguage = - typeof preferredLanguage === "string" && preferredLanguage - ? preferredLanguage - : ""; - if (!targetLanguage) { - let steamLang = document.documentElement.lang || "en"; - if (steamLang.toLowerCase() === "pt-br") steamLang = "pt-BR"; - if (steamLang.toLowerCase() === "zh-cn") steamLang = "zh-CN"; - if (steamLang.toLowerCase() === "zh-tw") steamLang = "zh-TW"; - if (steamLang.toLowerCase() === "es-419") steamLang = "es"; - targetLanguage = useSteamLang - ? steamLang - : (window.__LuaToolsI18n && window.__LuaToolsI18n.language) || "en"; - } - return Millennium.callServerMethod("luatools", "GetTranslations", { - language: targetLanguage, - contentScriptQuery: "", - }) - .then(function (res) { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (!payload || payload.success !== true || !payload.strings) { - throw new Error("Invalid translation payload"); - } - applyTranslationBundle(payload); - // Update button text after translations are loaded - updateButtonTranslations(); - return window.__LuaToolsI18n; - }) - .catch(function (err) { - backendLog("LuaTools: translation load failed: " + err); - window.__LuaToolsI18n = window.__LuaToolsI18n || { - language: "en", - locales: [], - strings: {}, - ready: false, - }; - return window.__LuaToolsI18n; - }); - } catch (err) { - backendLog("LuaTools: ensureTranslationsLoaded error: " + err); - window.__LuaToolsI18n = window.__LuaToolsI18n || { - language: "en", - locales: [], - strings: {}, - ready: false, - }; - return Promise.resolve(window.__LuaToolsI18n); - } - } - - function translateText(key, fallback) { - if (!key) { - return typeof fallback !== "undefined" ? fallback : ""; - } - try { - const store = window.__LuaToolsI18n; - if ( - store && - store.strings && - Object.prototype.hasOwnProperty.call(store.strings, key) - ) { - const value = store.strings[key]; - if (typeof value === "string") { - const trimmed = value.trim(); - if (trimmed && trimmed.toLowerCase() !== TRANSLATION_PLACEHOLDER) { - return value; - } - } - } - } catch (_) { } - return typeof fallback !== "undefined" ? fallback : key; - } - - function t(key, fallback) { - return translateText(key, fallback); - } - - function lt(text) { - return t(text, text); - } - - // Translations are loaded by fetchSettingsConfig() in onFrontendReady — no separate preload needed. - - function askRestartConfirmation() { - showLuaToolsConfirm( - "LuaTools", - lt("Restart Steam now?"), - function () { - try { - Millennium.callServerMethod("luatools", "RestartSteam", { - contentScriptQuery: "", - }); - // SteamClient.User.StartRestart(true) Unreliable, closes but doesn't restart (on my pc) - } catch (_) { } - }, - function () { - /* Cancel - do nothing */ - }, - ); - } - - let settingsMenuPending = false; - - // Helper: show a Steam-style popup with a 10s loading bar (custom UI) - function showTestPopup() { - // Avoid duplicates - if (document.querySelector(".luatools-overlay")) return; - // Close settings popup if open so modals don't overlap - try { - const s = document.querySelector(".luatools-settings-overlay"); - if (s) s.remove(); - } catch (_) { } - - ensureLuaToolsStyles(); - ensureFontAwesome(); - const overlay = document.createElement("div"); - overlay.className = "luatools-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:520px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const title = document.createElement("div"); - const titleColors = getThemeColors(); - title.style.cssText = `display:flex;align-items:center;gap:10px;font-size:20px;color:${titleColors.text};margin-bottom:16px;font-weight:600;`; - title.className = "luatools-title"; - const dlTitleIcon = document.createElement("i"); - dlTitleIcon.className = "fa-solid fa-cloud-arrow-down"; - dlTitleIcon.style.cssText = `color:${titleColors.accent};font-size:20px;`; - title.appendChild(dlTitleIcon); - const dlTitleText = document.createElement("span"); - dlTitleText.textContent = lt("Select Download Source"); - title.appendChild(dlTitleText); - - // API list container - const apiListContainer = document.createElement("div"); - apiListContainer.className = "luatools-api-list"; - apiListContainer.style.cssText = "margin-bottom:16px;"; - - // Placeholder while loading APIs - const loadingItem = document.createElement("div"); - loadingItem.style.cssText = `text-align:center;padding:10px;color:${colors.textSecondary};font-size:13px;`; - loadingItem.textContent = lt("Loading APIs..."); - apiListContainer.appendChild(loadingItem); - - // Load APIs dynamically from backend - if ( - typeof Millennium !== "undefined" && - typeof Millennium.callServerMethod === "function" - ) { - Millennium.callServerMethod("luatools", "GetApiList", { - contentScriptQuery: "", - }) - .then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if ( - payload && - payload.success && - payload.apis && - Array.isArray(payload.apis) - ) { - // Clear loading message - apiListContainer.innerHTML = ""; - - // Create API items - payload.apis.forEach((api, index) => { - const apiItem = document.createElement("div"); - apiItem.className = `luatools-api-item luatools-api-${index}`; - apiItem.setAttribute("data-api-name", api.name); - apiItem.style.cssText = `display:flex;align-items:center;justify-content:space-between;padding:10px 14px;margin-bottom:8px;background:rgba(${colors.rgbString},0.1);border:1px solid ${colors.borderRgba};border-radius:6px;transition:all 0.2s;`; - - const apiName = document.createElement("div"); - apiName.className = "luatools-api-name"; - apiName.style.cssText = `font-size:14px;color:${colors.textSecondary};font-weight:500;`; - apiName.textContent = api.name; - - const apiStatus = document.createElement("div"); - apiStatus.className = "luatools-api-status"; - apiStatus.style.cssText = `font-size:14px;color:${colors.textSecondary};display:flex;align-items:center;gap:6px;`; - apiStatus.innerHTML = - "" + - lt("Waiting…") + - "" + - ''; - - apiItem.appendChild(apiName); - apiItem.appendChild(apiStatus); - apiListContainer.appendChild(apiItem); - }); - } - } catch (err) { - backendLog("Failed to parse API list: " + err); - } - }) - .catch(function (err) { - backendLog("Failed to load API list: " + err); - }); - } - - const body = document.createElement("div"); - body.style.cssText = `display:flex;align-items:center;justify-content:center;gap:8px;font-size:14px;line-height:1.4;margin-bottom:12px;color:${colors.textSecondary};`; - body.className = "luatools-status"; - body.innerHTML = - '' + - lt("Checking availability…") + - ""; - - const progressWrap = document.createElement("div"); - progressWrap.style.cssText = `background:rgba(0,0,0,0.3);height:20px;border-radius:4px;overflow:hidden;position:relative;display:none;border:1px solid ${colors.border};margin-top:12px;`; - progressWrap.className = "luatools-progress-wrap"; - const progressBar = document.createElement("div"); - progressBar.style.cssText = `height:100%;width:0%;background:${colors.gradient};transition:width 0.3s ease;box-shadow:0 0 10px ${colors.shadow};`; - progressBar.className = "luatools-progress-bar"; - progressWrap.appendChild(progressBar); - - const progressInfo = document.createElement("div"); - progressInfo.style.cssText = `display:none;margin-top:8px;font-size:12px;color:${colors.textSecondary};`; - progressInfo.className = "luatools-progress-info"; - - const percent = document.createElement("span"); - percent.className = "luatools-percent"; - percent.textContent = "0%"; - - const downloadSize = document.createElement("span"); - downloadSize.className = "luatools-download-size"; - downloadSize.style.cssText = "margin-left:12px;"; - downloadSize.textContent = ""; - - progressInfo.appendChild(percent); - progressInfo.appendChild(downloadSize); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = - "margin-top:20px;display:flex;gap:8px;justify-content:center;"; - const cancelBtn = document.createElement("a"); - cancelBtn.className = "luatools-btn luatools-cancel-btn"; - cancelBtn.style.cssText = - "display:none;align-items:center;justify-content:center;text-align:center;"; - cancelBtn.innerHTML = `${lt("Cancel")}`; - cancelBtn.href = "#"; - cancelBtn.onclick = function (e) { - e.preventDefault(); - cancelOperation(); - }; - const hideBtn = document.createElement("a"); - hideBtn.className = "luatools-btn luatools-hide-btn"; - hideBtn.style.cssText = - "display:flex;align-items:center;justify-content:center;text-align:center;"; - hideBtn.innerHTML = `${lt("Hide")}`; - hideBtn.href = "#"; - hideBtn.onclick = function (e) { - e.preventDefault(); - cleanup(); - }; - btnRow.appendChild(cancelBtn); - btnRow.appendChild(hideBtn); - - modal.appendChild(title); - modal.appendChild(apiListContainer); - modal.appendChild(body); - modal.appendChild(progressWrap); - modal.appendChild(progressInfo); - modal.appendChild(btnRow); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - - function cleanup() { - overlay.remove(); - } - - function cancelOperation() { - // Call backend to cancel the operation - try { - const match = - window.location.href.match( - /https:\/\/store\.steampowered\.com\/app\/(\d+)/, - ) || - window.location.href.match( - /https:\/\/steamcommunity\.com\/app\/(\d+)/, - ); - const appid = match - ? parseInt(match[1], 10) - : window.__LuaToolsCurrentAppId || NaN; - if ( - !isNaN(appid) && - typeof Millennium !== "undefined" && - typeof Millennium.callServerMethod === "function" - ) { - Millennium.callServerMethod("luatools", "CancelAddViaLuaTools", { - appid, - contentScriptQuery: "", - }); - } - } catch (_) { } - // Update UI to show cancelled - const status = overlay.querySelector(".luatools-status"); - if (status) status.textContent = lt("Cancelled"); - const cancelBtn = overlay.querySelector(".luatools-cancel-btn"); - if (cancelBtn) cancelBtn.style.display = "none"; - const hideBtn = overlay.querySelector(".luatools-hide-btn"); - if (hideBtn) hideBtn.innerHTML = `${lt("Close")}`; - // Hide progress UI - const wrap = overlay.querySelector(".luatools-progress-wrap"); - const progressInfo = overlay.querySelector(".luatools-progress-info"); - if (wrap) wrap.style.display = "none"; - if (progressInfo) progressInfo.style.display = "none"; - // Reset run state - runState.inProgress = false; - runState.appid = null; - } - } - - // Fixes Results popup - function showFixesResultsPopup(data, isGameInstalled) { - if (document.querySelector(".luatools-fixes-results-overlay")) return; - // Close other popups - try { - const d = document.querySelector(".luatools-overlay"); - if (d) d.remove(); - } catch (_) { } - try { - const s = document.querySelector(".luatools-settings-overlay"); - if (s) s.remove(); - } catch (_) { } - try { - const f = document.querySelector(".luatools-fixes-results-overlay"); - if (f) f.remove(); - } catch (_) { } - try { - const l = document.querySelector(".luatools-loading-fixes-overlay"); - if (l) l.remove(); - } catch (_) { } - - ensureLuaToolsStyles(); - ensureFontAwesome(); - const overlay = document.createElement("div"); - overlay.className = "luatools-fixes-results-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `position:relative;background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:640px;max-height:80vh;display:flex;flex-direction:column;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const header = document.createElement("div"); - header.style.cssText = `flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid ${colors.borderRgba};`; - - const title = document.createElement("div"); - title.style.cssText = `display:flex;align-items:center;gap:10px;font-size:22px;color:${colors.text};font-weight:600;`; - const titleIcon = document.createElement("i"); - titleIcon.className = "fa-solid fa-wrench"; - titleIcon.style.cssText = `color:${colors.accent};font-size:20px;`; - const titleText = document.createElement("span"); - titleText.textContent = lt("LuaTools · Fixes Menu"); - title.appendChild(titleIcon); - title.appendChild(titleText); - - const iconButtons = document.createElement("div"); - iconButtons.style.cssText = "display:flex;gap:12px;"; - - function createIconButton(id, iconClass, titleKey, titleFallback) { - const btn = document.createElement("a"); - btn.id = id; - btn.href = "#"; - const btnColors = getThemeColors(); - btn.style.cssText = `display:flex;align-items:center;justify-content:center;width:40px;height:40px;background:rgba(${btnColors.rgbString},0.1);border:1px solid ${btnColors.borderRgba};border-radius:10px;color:${btnColors.accent};font-size:18px;text-decoration:none;transition:all 0.3s ease;cursor:pointer;`; - btn.innerHTML = ''; - btn.title = t(titleKey, titleFallback); - btn.onmouseover = function () { - this.style.background = `rgba(${btnColors.rgbString},0.25)`; - this.style.transform = "translateY(-2px) scale(1.05)"; - this.style.boxShadow = `0 8px 16px ${btnColors.shadowRgba}`; - this.style.borderColor = btnColors.accent; - }; - btn.onmouseout = function () { - this.style.background = `rgba(${btnColors.rgbString},0.1)`; - this.style.transform = "translateY(0) scale(1)"; - this.style.boxShadow = "none"; - this.style.borderColor = btnColors.borderRgba; - }; - iconButtons.appendChild(btn); - return btn; - } - - const discordBtn = createIconButton( - "lt-fixes-discord", - "fa-brands fa-discord", - "menu.discord", - "Discord", - ); - const settingsBtn = createIconButton( - "lt-fixes-settings", - "fa-gear", - "menu.settings", - "Settings", - ); - const closeIconBtn = createIconButton( - "lt-fixes-close", - "fa-xmark", - "settings.close", - "Close", - ); - - const body = document.createElement("div"); - const bodyColors = getThemeColors(); - body.style.cssText = `flex:1 1 auto;overflow-y:auto;padding:20px;border:1px solid ${bodyColors.border};border-radius:12px;background:${bodyColors.bgContainer};`; - - try { - const bannerImg = document.querySelector(".game_header_image_full"); - if (bannerImg && bannerImg.src) { - body.style.background = `linear-gradient(to bottom, rgba(15, 15, 15, 0.85), #0f0f0f 70%), url('${bannerImg.src}') no-repeat top center`; - body.style.backgroundSize = "cover"; - } - } catch (_) { } - - // Add mouse mode tip for Big Picture - if (window.__LUATOOLS_IS_BIG_PICTURE__) { - const tip = document.createElement("div"); - tip.style.cssText = - "background:rgba(102,192,244,0.15);border-left:3px solid #66c0f4;padding:12px 16px;border-radius:6px;font-size:13px;color:#c7d5e0;margin-bottom:16px;line-height:1.5;"; - tip.innerHTML = - '' + - t( - "bigpicture.mouseTip", - "To use mouse mode in Steam: Guide Button + Right Joystick, click with RB", - ); - body.appendChild(tip); - } - - const gameHeader = document.createElement("div"); - gameHeader.style.cssText = - "display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:16px;"; - - const gameIcon = document.createElement("img"); - gameIcon.style.cssText = - "width:32px;height:32px;border-radius:4px;object-fit:cover;display:none;"; - try { - const iconImg = document.querySelector(".apphub_AppIcon img"); - if (iconImg && iconImg.src) { - gameIcon.src = iconImg.src; - gameIcon.style.display = "block"; - } - } catch (_) { } - - const gameName = document.createElement("div"); - gameName.style.cssText = - "font-size:22px;color:#fff;font-weight:600;text-align:center;"; - gameName.textContent = data.gameName || lt("Unknown Game"); - - if ( - !data.gameName || - data.gameName === "Unknown Game" || - data.gameName === lt("Unknown Game") || - data.gameName.startsWith("Unknown Game") - ) { - fetchSteamGameName(data.appid).then(function (name) { - if (name) { - data.gameName = name; - gameName.textContent = name; - } - }); - } - - const contentContainer = document.createElement("div"); - contentContainer.style.position = "relative"; - contentContainer.style.zIndex = "1"; - - const columnsContainer = document.createElement("div"); - columnsContainer.style.cssText = - "display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:16px;"; - - function createFixButton(label, text, icon, isSuccess, onClick) { - const btn = document.createElement("a"); - btn.href = "#"; - const btnColors = getThemeColors(); - btn.style.cssText = `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:1 1 calc(50% - 10px);min-width:140px;box-sizing:border-box;padding:14px 6px;background:rgba(${btnColors.rgbString},0.06);border:1px solid ${btnColors.borderRgba};border-radius:12px;color:${btnColors.text};text-decoration:none;transition:all 0.2s ease;cursor:pointer;text-align:center;`; - - const iconHtml = - ''; - const labelHtml = - '' + - label + - ""; - const textHtml = - '' + - text + - ""; - btn.innerHTML = iconHtml + labelHtml + textHtml; - - // If the active theme is light, make certain fix action texts/icons white for readability. - try { - const currentThemeKey = - (((window.__LuaToolsSettings || {}).values || {}).general || {}) - .theme || "original"; - // Use localized labels so this works in other languages - const applyLabel = lt("Apply"); - const onlineUnsteamLabel = lt("Online Fix (Unsteam)"); - const noOnlineLabel = lt("No online-fix"); - const unfixLabel = lt("Un-Fix (verify game)"); - const noGenericLabel = lt("No generic fix"); - const whiteTexts = new Set([ - applyLabel, - onlineUnsteamLabel, - noOnlineLabel, - unfixLabel, - noGenericLabel, - ]); - if (currentThemeKey === "light" && whiteTexts.has(String(text))) { - btn - .querySelectorAll("span, i") - .forEach((el) => (el.style.color = "#ffffff")); - } - } catch (_) { } - - if (isSuccess) { - btn.style.background = - "linear-gradient(135deg, rgba(92,156,62,0.4) 0%, rgba(92,156,62,0.2) 100%)"; - btn.style.borderColor = "rgba(92,156,62,0.6)"; - btn.onmouseover = function () { - this.style.background = - "linear-gradient(135deg, rgba(92,156,62,0.6) 0%, rgba(92,156,62,0.3) 100%)"; - this.style.transform = "translateY(-2px)"; - this.style.boxShadow = "0 8px 20px rgba(92,156,62,0.3)"; - this.style.borderColor = "#79c754"; - }; - btn.onmouseout = function () { - this.style.background = - "linear-gradient(135deg, rgba(92,156,62,0.4) 0%, rgba(92,156,62,0.2) 100%)"; - this.style.transform = "translateY(0)"; - this.style.boxShadow = "none"; - this.style.borderColor = "rgba(92,156,62,0.6)"; - }; - } else if (isSuccess === false) { - btn.style.opacity = "0.5"; - btn.style.cursor = "not-allowed"; - } else { - const mutableColors = getThemeColors(); - btn.onmouseover = function () { - const c = getThemeColors(); - this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.3) 0%, rgba(${c.rgbString},0.15) 100%)`; - this.style.transform = "translateY(-2px)"; - this.style.boxShadow = `0 8px 20px rgba(${c.rgbString},0.25)`; - this.style.borderColor = c.accent; - }; - btn.onmouseout = function () { - const c = getThemeColors(); - this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.15) 0%, rgba(${c.rgbString},0.05) 100%)`; - this.style.transform = "translateY(0)"; - this.style.boxShadow = "none"; - this.style.borderColor = c.border; - }; - } - - btn.onclick = onClick; - return btn; - } - - // left thing in fixes modal - const genericStatus = data.genericFix.status; - const genericSection = createFixButton( - lt("Generic Fix"), - genericStatus === 200 ? lt("Apply") : lt("No generic fix"), - genericStatus === 200 ? "fa-check" : "fa-circle-xmark", - genericStatus === 200 ? true : false, - function (e) { - e.preventDefault(); - if (genericStatus === 200 && isGameInstalled) { - const genericUrl = - "https://files.luatools.work/GameBypasses/" + data.appid + ".zip"; - applyFix( - data.appid, - genericUrl, - lt("Generic Fix"), - data.gameName, - overlay, - ); - } - }, - ); - columnsContainer.appendChild(genericSection); - - if (!isGameInstalled) { - genericSection.style.opacity = "0.5"; - genericSection.style.cursor = "not-allowed"; - } - - const onlineStatus = data.onlineFix.status; - const onlineSection = createFixButton( - lt("Online Fix"), - onlineStatus === 200 ? lt("Apply") : lt("No online-fix"), - onlineStatus === 200 ? "fa-check" : "fa-circle-xmark", - onlineStatus === 200 ? true : false, - function (e) { - e.preventDefault(); - if (onlineStatus === 200 && isGameInstalled) { - const onlineUrl = - data.onlineFix.url || - "https://files.luatools.work/OnlineFix1/" + data.appid + ".zip"; - applyFix( - data.appid, - onlineUrl, - lt("Online Fix"), - data.gameName, - overlay, - ); - } - }, - ); - columnsContainer.appendChild(onlineSection); - - if (!isGameInstalled) { - onlineSection.style.opacity = "0.5"; - onlineSection.style.cursor = "not-allowed"; - } - const aioSection = createFixButton( - lt("All-In-One Fixes"), - lt("Online Fix (Unsteam)"), - "fa-globe", - null, // default blue button - function (e) { - e.preventDefault(); - if (isGameInstalled) { - const downloadUrl = - "https://github.com/madoiscool/lt_api_links/releases/download/unsteam/Win64.zip"; - applyFix( - data.appid, - downloadUrl, - lt("Online Fix (Unsteam)"), - data.gameName, - overlay, - ); - } - }, - ); - columnsContainer.appendChild(aioSection); - if (!isGameInstalled) { - aioSection.style.opacity = "0.5"; - aioSection.style.cursor = "not-allowed"; - } - - const unfixSection = createFixButton( - lt("Manage Game"), - lt("Un-Fix (verify game)"), - "fa-trash", - null, // ^^ - function (e) { - e.preventDefault(); - if (isGameInstalled) { - try { - overlay.remove(); - } catch (_) { } - showLuaToolsConfirm( - "LuaTools", - lt( - "Are you sure you want to un-fix? This will remove fix files and verify game files.", - ), - function () { - startUnfix(data.appid); - }, - function () { - showFixesResultsPopup(data, isGameInstalled); - }, - ); - } - }, - ); - columnsContainer.appendChild(unfixSection); - if (!isGameInstalled) { - unfixSection.style.opacity = "0.5"; - unfixSection.style.cursor = "not-allowed"; - } - - // Credit message - const creditMsg = document.createElement("div"); - const creditColors = getThemeColors(); - creditMsg.style.cssText = `margin-top:16px;text-align:center;font-size:13px;color:${creditColors.textSecondary};`; - const creditTemplate = lt("Only possible thanks to {name} 💜"); - creditMsg.innerHTML = creditTemplate.replace( - "{name}", - `ShayneVi`, - ); - - // Wire up ShayneVi link - setTimeout(function () { - const shayenviLink = overlay.querySelector("#lt-shayenvi-link"); - if (shayenviLink) { - shayenviLink.addEventListener("click", function (e) { - e.preventDefault(); - try { - Millennium.callServerMethod("luatools", "OpenExternalUrl", { - url: "https://github.com/ShayneVi/", - contentScriptQuery: "", - }); - } catch (_) { } - }); - } - }, 0); - - // body moment - gameHeader.appendChild(gameIcon); - gameHeader.appendChild(gameName); - contentContainer.appendChild(gameHeader); - - contentContainer.appendChild(columnsContainer); - - if (!isGameInstalled) { - const notInstalledWarning = document.createElement("div"); - notInstalledWarning.style.cssText = - "margin-top: 16px; padding: 12px; background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); border-radius: 6px; color: #ffc107; font-size: 13px; text-align: center;"; - notInstalledWarning.innerHTML = - '' + - t("menu.error.notInstalled", "Game is not installed"); - contentContainer.appendChild(notInstalledWarning); - } - - contentContainer.appendChild(creditMsg); - body.appendChild(contentContainer); - - // header moment - header.appendChild(title); - header.appendChild(iconButtons); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = - "flex:0 0 auto;margin-top:16px;display:flex;gap:8px;justify-content:space-between;align-items:center;"; - - const rightButtons = document.createElement("div"); - rightButtons.style.cssText = "display:flex;gap:8px;"; - const gameFolderBtn = document.createElement("a"); - gameFolderBtn.className = "luatools-btn"; - gameFolderBtn.innerHTML = `${lt("Game folder")}`; - gameFolderBtn.href = "#"; - gameFolderBtn.onclick = function (e) { - e.preventDefault(); - if (window.__LuaToolsGameInstallPath) { - try { - Millennium.callServerMethod("luatools", "OpenGameFolder", { - path: window.__LuaToolsGameInstallPath, - contentScriptQuery: "", - }); - } catch (err) { - backendLog("LuaTools: Failed to open game folder: " + err); - } - } - }; - rightButtons.appendChild(gameFolderBtn); - - const backBtn = document.createElement("a"); - backBtn.className = "luatools-btn"; - backBtn.innerHTML = ''; - backBtn.href = "#"; - backBtn.onclick = function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - showSettingsPopup(); - }; - btnRow.appendChild(backBtn); - btnRow.appendChild(rightButtons); - - // final modal - modal.appendChild(header); - modal.appendChild(body); - modal.appendChild(btnRow); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - - closeIconBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - }; - discordBtn.onclick = function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - const url = "https://discord.gg/luatools"; - try { - Millennium.callServerMethod("luatools", "OpenExternalUrl", { - url, - contentScriptQuery: "", - }); - } catch (_) { } - }; - settingsBtn.onclick = function (e) { - e.preventDefault(); - try { - overlay.remove(); - } catch (_) { } - showSettingsManagerPopup(false, function () { - showFixesResultsPopup(data, isGameInstalled); - }); - }; - - function startUnfix(appid) { - try { - Millennium.callServerMethod("luatools", "UnFixGame", { - appid: appid, - installPath: window.__LuaToolsGameInstallPath, - contentScriptQuery: "", - }) - .then(function (res) { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success) { - showUnfixProgress(appid); - } else { - const errorKey = - payload && payload.error ? String(payload.error) : ""; - const errorMsg = - errorKey && - (errorKey.startsWith("menu.error.") || - errorKey.startsWith("common.")) - ? t(errorKey) - : errorKey || lt("Failed to start un-fix"); - ShowLuaToolsAlert("LuaTools", errorMsg); - } - }) - .catch(function () { - const msg = lt("Error starting un-fix"); - ShowLuaToolsAlert("LuaTools", msg); - }); - } catch (err) { - backendLog("LuaTools: Un-Fix start error: " + err); - } - } - } - - function showFixesLoadingPopupAndCheck(appid) { - if (document.querySelector(".luatools-loading-fixes-overlay")) return; - try { - const d = document.querySelector(".luatools-overlay"); - if (d) d.remove(); - } catch (_) { } - try { - const s = document.querySelector(".luatools-settings-overlay"); - if (s) s.remove(); - } catch (_) { } - try { - const f = document.querySelector(".luatools-fixes-overlay"); - if (f) f.remove(); - } catch (_) { } - - ensureLuaToolsStyles(); - ensureFontAwesome(); - const overlay = document.createElement("div"); - overlay.className = "luatools-loading-fixes-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:480px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const title = document.createElement("div"); - const titleColorsLoading = getThemeColors(); - title.style.cssText = `font-size:22px;color:${titleColorsLoading.text};margin-bottom:16px;font-weight:600;`; - title.textContent = lt("Loading fixes..."); - - const body = document.createElement("div"); - const bodyColorsLoading = getThemeColors(); - body.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:16px;color:${bodyColorsLoading.textSecondary};`; - body.textContent = lt("Checking availability…"); - - const progressWrap = document.createElement("div"); - const progressColorsLoading = getThemeColors(); - progressWrap.style.cssText = `background:rgba(0,0,0,0.3);height:12px;border-radius:4px;overflow:hidden;position:relative;border:1px solid ${progressColorsLoading.border};`; - const progressBar = document.createElement("div"); - progressBar.style.cssText = `height:100%;width:0%;background:${progressColorsLoading.gradient};transition:width 0.2s linear;box-shadow:0 0 10px ${progressColorsLoading.shadow};`; - progressWrap.appendChild(progressBar); - - modal.appendChild(title); - modal.appendChild(body); - modal.appendChild(progressWrap); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - - let progress = 0; - const progressInterval = setInterval(function () { - if (progress < 95) { - progress += Math.random() * 5; - progressBar.style.width = Math.min(progress, 95) + "%"; - } - }, 200); - - fetchFixes(appid) - .then(function (payload) { - if (payload && payload.success) { - const isGameInstalled = window.__LuaToolsGameIsInstalled === true; - showFixesResultsPopup(payload, isGameInstalled); - } else { - const errText = - payload && payload.error - ? String(payload.error) - : lt("Failed to check for fixes."); - ShowLuaToolsAlert("LuaTools", errText); - } - }) - .catch(function () { - const msg = lt("Error checking for fixes"); - ShowLuaToolsAlert("LuaTools", msg); - }) - .finally(function () { - clearInterval(progressInterval); - progressBar.style.width = "100%"; - setTimeout(function () { - try { - const l = document.querySelector(".luatools-loading-fixes-overlay"); - if (l) l.remove(); - } catch (_) { } - }, 300); - }); - } - - // Apply Fix function - function applyFix(appid, downloadUrl, fixType, gameName, resultsOverlay) { - try { - // Close results overlay - if (resultsOverlay) { - resultsOverlay.remove(); - } - - // Check if we have the game install path - if (!window.__LuaToolsGameInstallPath) { - const msg = lt("Game install path not found"); - ShowLuaToolsAlert("LuaTools", msg); - return; - } - - backendLog("LuaTools: Applying fix " + fixType + " for appid " + appid); - - // Start the download and extraction process - Millennium.callServerMethod("luatools", "ApplyGameFix", { - appid: appid, - downloadUrl: downloadUrl, - installPath: window.__LuaToolsGameInstallPath, - fixType: fixType, - gameName: gameName || "", - contentScriptQuery: "", - }) - .then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success) { - // Show download progress popup similar to Add via LuaTools - showFixDownloadProgress(appid, fixType); - } else { - const errorKey = - payload && payload.error ? String(payload.error) : ""; - const errorMsg = - errorKey && - (errorKey.startsWith("menu.error.") || - errorKey.startsWith("common.")) - ? t(errorKey) - : errorKey || lt("Failed to start fix download"); - ShowLuaToolsAlert("LuaTools", errorMsg); - } - } catch (err) { - backendLog("LuaTools: ApplyGameFix response error: " + err); - const msg = lt("Error applying fix"); - ShowLuaToolsAlert("LuaTools", msg); - } - }) - .catch(function (err) { - backendLog("LuaTools: ApplyGameFix error: " + err); - const msg = lt("Error applying fix"); - ShowLuaToolsAlert("LuaTools", msg); - }); - } catch (err) { - backendLog("LuaTools: applyFix error: " + err); - } - } - - // Show fix download progress popup - function showFixDownloadProgress(appid, fixType) { - // Reuse the download popup UI from Add via LuaTools - if (document.querySelector(".luatools-overlay")) return; - - ensureLuaToolsStyles(); - ensureFontAwesome(); - const overlay = document.createElement("div"); - overlay.className = "luatools-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:480px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const title = document.createElement("div"); - const applyFixTitleColors = getThemeColors(); - title.style.cssText = `font-size:22px;color:${applyFixTitleColors.text};margin-bottom:16px;font-weight:600;`; - title.textContent = lt("Applying {fix}").replace("{fix}", fixType); - - const body = document.createElement("div"); - const applyFixBodyColors = getThemeColors(); - body.style.cssText = `font-size:15px;line-height:1.6;margin-bottom:20px;color:${applyFixBodyColors.textSecondary};`; - body.innerHTML = - '
' + lt("Downloading...") + "
"; - - const btnRow = document.createElement("div"); - btnRow.className = "lt-fix-btn-row"; - btnRow.style.cssText = - "margin-top:16px;display:flex;gap:12px;justify-content:center;"; - - const hideBtn = document.createElement("a"); - hideBtn.href = "#"; - hideBtn.className = "luatools-btn"; - hideBtn.style.flex = "1"; - hideBtn.innerHTML = `${lt("Hide")}`; - hideBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - }; - btnRow.appendChild(hideBtn); - - const cancelBtn = document.createElement("a"); - cancelBtn.href = "#"; - cancelBtn.className = "luatools-btn primary"; - cancelBtn.style.flex = "1"; - cancelBtn.innerHTML = `${lt("Cancel")}`; - cancelBtn.onclick = function (e) { - e.preventDefault(); - if (cancelBtn.dataset.pending === "1") return; - cancelBtn.dataset.pending = "1"; - const span = cancelBtn.querySelector("span"); - if (span) span.textContent = lt("Cancelling..."); - const msgEl = document.getElementById("lt-fix-progress-msg"); - if (msgEl) msgEl.textContent = lt("Cancelling..."); - Millennium.callServerMethod("luatools", "CancelApplyFix", { - appid: appid, - contentScriptQuery: "", - }) - .then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (!payload || payload.success !== true) { - throw new Error( - (payload && payload.error) || lt("Cancellation failed"), - ); - } - } catch (err) { - cancelBtn.dataset.pending = "0"; - if (span) span.textContent = lt("Cancel"); - const msgEl2 = document.getElementById("lt-fix-progress-msg"); - if (msgEl2 && msgEl2.dataset.last) - msgEl2.textContent = msgEl2.dataset.last; - backendLog("LuaTools: CancelApplyFix response error: " + err); - const msg = lt("Failed to cancel fix download"); - ShowLuaToolsAlert("LuaTools", msg); - } - }) - .catch(function (err) { - cancelBtn.dataset.pending = "0"; - const span2 = cancelBtn.querySelector("span"); - if (span2) span2.textContent = lt("Cancel"); - const msgEl2 = document.getElementById("lt-fix-progress-msg"); - if (msgEl2 && msgEl2.dataset.last) - msgEl2.textContent = msgEl2.dataset.last; - backendLog("LuaTools: CancelApplyFix error: " + err); - const msg = lt("Failed to cancel fix download"); - ShowLuaToolsAlert("LuaTools", msg); - }); - }; - btnRow.appendChild(cancelBtn); - - modal.appendChild(title); - modal.appendChild(body); - modal.appendChild(btnRow); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - - // Start polling for progress - pollFixProgress(appid, fixType); - } - - function replaceFixButtonsWithClose(overlayEl) { - if (!overlayEl) return; - const btnRow = overlayEl.querySelector(".lt-fix-btn-row"); - if (!btnRow) return; - btnRow.innerHTML = ""; - btnRow.style.cssText = - "margin-top:16px;display:flex;justify-content:flex-end;"; - const closeBtn = document.createElement("a"); - closeBtn.href = "#"; - closeBtn.className = "luatools-btn primary"; - closeBtn.style.minWidth = "140px"; - closeBtn.innerHTML = `${lt("Close")}`; - closeBtn.onclick = function (e) { - e.preventDefault(); - overlayEl.remove(); - }; - btnRow.appendChild(closeBtn); - } - - // Poll fix download and extraction progress - function pollFixProgress(appid, fixType) { - const poll = function () { - try { - const overlayEl = document.querySelector(".luatools-overlay"); - if (!overlayEl) return; // Stop if overlay was closed - - Millennium.callServerMethod("luatools", "GetApplyFixStatus", { - appid: appid, - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success && payload.state) { - const state = payload.state; - const msgEl = document.getElementById("lt-fix-progress-msg"); - - if (state.status === "downloading") { - const pct = - state.totalBytes > 0 - ? Math.floor((state.bytesRead / state.totalBytes) * 100) - : 0; - if (msgEl) { - msgEl.textContent = lt("Downloading: {percent}%").replace( - "{percent}", - pct, - ); - msgEl.dataset.last = msgEl.textContent; - } - setTimeout(poll, 500); - } else if (state.status === "extracting") { - if (msgEl) { - msgEl.textContent = lt("Extracting to game folder..."); - msgEl.dataset.last = msgEl.textContent; - } - setTimeout(poll, 500); - } else if (state.status === "cancelled") { - if (msgEl) - msgEl.textContent = lt("Cancelled: {reason}").replace( - "{reason}", - state.error || lt("Cancelled by user"), - ); - replaceFixButtonsWithClose(overlayEl); - return; - } else if (state.status === "done") { - if (msgEl) - msgEl.textContent = lt("{fix} applied successfully!").replace( - "{fix}", - fixType, - ); - replaceFixButtonsWithClose(overlayEl); - return; // Stop polling - } else if (state.status === "failed") { - if (msgEl) - msgEl.textContent = lt("Failed: {error}").replace( - "{error}", - state.error || lt("Unknown error"), - ); - replaceFixButtonsWithClose(overlayEl); - return; // Stop polling - } else { - // Continue polling for unknown states - setTimeout(poll, 500); - } - } - } catch (err) { - backendLog("LuaTools: GetApplyFixStatus error: " + err); - } - }); - } catch (err) { - backendLog("LuaTools: pollFixProgress error: " + err); - } - }; - setTimeout(poll, 500); - } - - // Show un-fix progress popup - function showUnfixProgress(appid) { - // Remove any existing popup - try { - const old = document.querySelector(".luatools-unfix-overlay"); - if (old) old.remove(); - } catch (_) { } - - ensureLuaToolsStyles(); - ensureFontAwesome(); - const overlay = document.createElement("div"); - overlay.className = "luatools-unfix-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:480px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const title = document.createElement("div"); - const unfixTitleColors = getThemeColors(); - title.style.cssText = `font-size:22px;color:${unfixTitleColors.text};margin-bottom:16px;font-weight:600;`; - title.textContent = lt("Un-Fixing game"); - - const body = document.createElement("div"); - body.style.cssText = - "font-size:15px;line-height:1.6;margin-bottom:20px;color:#c7d5e0;"; - body.innerHTML = - '
' + - lt("Removing fix files...") + - "
"; - - const btnRow = document.createElement("div"); - btnRow.style.cssText = - "margin-top:16px;display:flex;justify-content:center;"; - const hideBtn = document.createElement("a"); - hideBtn.href = "#"; - hideBtn.className = "luatools-btn"; - hideBtn.style.minWidth = "140px"; - hideBtn.innerHTML = `${lt("Hide")}`; - hideBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - }; - btnRow.appendChild(hideBtn); - - modal.appendChild(title); - modal.appendChild(body); - modal.appendChild(btnRow); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - - // Start polling for progress - pollUnfixProgress(appid); - } - - // Poll un-fix progress - function pollUnfixProgress(appid) { - const poll = function () { - try { - const overlayEl = document.querySelector(".luatools-unfix-overlay"); - if (!overlayEl) return; // Stop if overlay was closed - - Millennium.callServerMethod("luatools", "GetUnfixStatus", { - appid: appid, - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success && payload.state) { - const state = payload.state; - const msgEl = document.getElementById("lt-unfix-progress-msg"); - - if (state.status === "removing") { - if (msgEl) - msgEl.textContent = - state.progress || lt("Removing fix files..."); - // Continue polling - setTimeout(poll, 500); - } else if (state.status === "done") { - const filesRemoved = state.filesRemoved || 0; - if (msgEl) - msgEl.textContent = lt( - "Removed {count} files. Running Steam verification...", - ).replace("{count}", filesRemoved); - // Change Hide button to Close button - try { - const btnRow = overlayEl.querySelector( - 'div[style*="justify-content:flex-end"]', - ); - if (btnRow) { - btnRow.innerHTML = ""; - const closeBtn = document.createElement("a"); - closeBtn.href = "#"; - closeBtn.className = "luatools-btn primary"; - closeBtn.style.minWidth = "140px"; - closeBtn.innerHTML = `${lt("Close")}`; - closeBtn.onclick = function (e) { - e.preventDefault(); - overlayEl.remove(); - }; - btnRow.appendChild(closeBtn); - } - } catch (_) { } - - // Trigger Steam verification after a short delay - setTimeout(function () { - try { - const verifyUrl = "steam://validate/" + appid; - window.location.href = verifyUrl; - backendLog("LuaTools: Running verify for appid " + appid); - } catch (_) { } - }, 1000); - - return; // Stop polling - } else if (state.status === "failed") { - if (msgEl) - msgEl.textContent = lt("Failed: {error}").replace( - "{error}", - state.error || lt("Unknown error"), - ); - // Change Hide button to Close button - try { - const btnRow = overlayEl.querySelector( - 'div[style*="justify-content:flex-end"]', - ); - if (btnRow) { - btnRow.innerHTML = ""; - const closeBtn = document.createElement("a"); - closeBtn.href = "#"; - closeBtn.className = "luatools-btn primary"; - closeBtn.style.minWidth = "140px"; - closeBtn.innerHTML = `${lt("Close")}`; - closeBtn.onclick = function (e) { - e.preventDefault(); - overlayEl.remove(); - }; - btnRow.appendChild(closeBtn); - } - } catch (_) { } - return; // Stop polling - } else { - // Continue polling for unknown states - setTimeout(poll, 500); - } - } - } catch (err) { - backendLog("LuaTools: GetUnfixStatus error: " + err); - } - }); - } catch (err) { - backendLog("LuaTools: pollUnfixProgress error: " + err); - } - }; - setTimeout(poll, 500); - } - - function fetchSettingsConfig(forceRefresh) { - try { - if ( - !forceRefresh && - window.__LuaToolsSettings && - Array.isArray(window.__LuaToolsSettings.schema) - ) { - return Promise.resolve(window.__LuaToolsSettings); - } - } catch (_) { } - - if ( - typeof Millennium === "undefined" || - typeof Millennium.callServerMethod !== "function" - ) { - return Promise.reject(new Error(lt("LuaTools backend unavailable"))); - } - - return Millennium.callServerMethod("luatools", "GetSettingsConfig", { - contentScriptQuery: "", - }).then(function (res) { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (!payload || payload.success !== true) { - const errorMsg = - payload && payload.error - ? String(payload.error) - : t("settings.error", "Failed to load settings."); - throw new Error(errorMsg); - } - const config = { - schemaVersion: payload.schemaVersion || 0, - schema: Array.isArray(payload.schema) ? payload.schema : [], - values: - payload && payload.values && typeof payload.values === "object" - ? payload.values - : {}, - language: payload && payload.language ? String(payload.language) : "en", - locales: Array.isArray(payload && payload.locales) - ? payload.locales - : [], - translations: - payload && - payload.translations && - typeof payload.translations === "object" - ? payload.translations - : {}, - lastFetched: Date.now(), - }; - applyTranslationBundle({ - language: config.language, - locales: config.locales, - strings: config.translations, - }); - window.__LuaToolsSettings = config; - return config; - }); - } - - function initialiseSettingsDraft(config) { - const values = JSON.parse(JSON.stringify((config && config.values) || {})); - if (!config || !Array.isArray(config.schema)) { - return values; - } - for (let i = 0; i < config.schema.length; i++) { - const group = config.schema[i]; - if (!group || !group.key) continue; - if ( - typeof values[group.key] !== "object" || - values[group.key] === null || - Array.isArray(values[group.key]) - ) { - values[group.key] = {}; - } - const options = Array.isArray(group.options) ? group.options : []; - for (let j = 0; j < options.length; j++) { - const option = options[j]; - if (!option || !option.key) continue; - if (typeof values[group.key][option.key] === "undefined") { - values[group.key][option.key] = option.default; - } - } - } - return values; - } - - function showSettingsManagerPopup(forceRefresh, onBack) { - if (document.querySelector(".luatools-settings-manager-overlay")) return; - - try { - const mainOverlay = document.querySelector(".luatools-settings-overlay"); - if (mainOverlay) mainOverlay.remove(); - } catch (_) { } - - ensureLuaToolsStyles(); - ensureFontAwesome(); - - const overlay = document.createElement("div"); - overlay.className = "luatools-settings-manager-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100000;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const settingsModalColors = getThemeColors(); - modal.style.cssText = `position:relative;background:${settingsModalColors.modalBg};color:${settingsModalColors.text};border:1px solid ${settingsModalColors.border};border-radius:16px;width:750px;max-height:88vh;padding:0;display:flex;flex-direction:column;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${settingsModalColors.shadowRgba};animation:slideUp 0.12s ease-out;overflow:hidden;`; - - const header = document.createElement("div"); - const settingsHeaderColors = getThemeColors(); - header.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:20px 24px 16px;border-bottom:1px solid ${settingsHeaderColors.border.replace("0.3", "0.15")};`; - - const title = document.createElement("div"); - const settingsTitleColors = getThemeColors(); - title.style.cssText = `font-size:22px;color:${settingsTitleColors.text};font-weight:600;`; - title.textContent = t("settings.title", "LuaTools · Settings"); - - const iconButtons = document.createElement("div"); - iconButtons.style.cssText = "display:flex;gap:12px;"; - - const discordIconBtn = document.createElement("a"); - discordIconBtn.href = "#"; - const discordBtnColors = getThemeColors(); - discordIconBtn.style.cssText = `display:flex;align-items:center;justify-content:center;width:36px;height:36px;background:rgba(${discordBtnColors.rgbString},0.08);border:1px solid ${discordBtnColors.border};border-radius:8px;color:${discordBtnColors.accent};font-size:16px;text-decoration:none;transition:all 0.2s ease;cursor:pointer;`; - discordIconBtn.innerHTML = ''; - discordIconBtn.title = t("menu.discord", "Discord"); - discordIconBtn.onmouseover = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.18)`; - this.style.transform = "translateY(-1px)"; - this.style.boxShadow = `0 4px 12px ${c.shadow}`; - this.style.borderColor = c.accent; - }; - discordIconBtn.onmouseout = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.08)`; - this.style.transform = "translateY(0)"; - this.style.boxShadow = "none"; - this.style.borderColor = c.border; - }; - iconButtons.appendChild(discordIconBtn); - - const closeIconBtn = document.createElement("a"); - closeIconBtn.href = "#"; - const closeBtnColors = getThemeColors(); - closeIconBtn.style.cssText = `display:flex;align-items:center;justify-content:center;width:36px;height:36px;background:rgba(${closeBtnColors.rgbString},0.08);border:1px solid ${closeBtnColors.border};border-radius:8px;color:${closeBtnColors.accent};font-size:16px;text-decoration:none;transition:all 0.2s ease;cursor:pointer;`; - closeIconBtn.innerHTML = ''; - closeIconBtn.title = t("settings.close", "Close"); - closeIconBtn.onmouseover = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.18)`; - this.style.transform = "translateY(-1px)"; - this.style.boxShadow = `0 4px 12px ${c.shadow}`; - this.style.borderColor = c.accent; - }; - closeIconBtn.onmouseout = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.08)`; - this.style.transform = "translateY(0)"; - this.style.boxShadow = "none"; - this.style.borderColor = c.border; - }; - iconButtons.appendChild(closeIconBtn); - - // Search bar container - const searchContainer = document.createElement("div"); - const searchColors = getThemeColors(); - searchContainer.style.cssText = - "padding:16px 24px;border-bottom:1px solid rgba(255,255,255,0.06);"; - - const searchWrap = document.createElement("div"); - searchWrap.style.cssText = `display:flex;align-items:center;gap:10px;padding:10px 14px;background:${searchColors.bgTertiary};border:1px solid ${searchColors.border};border-radius:10px;transition:all 0.2s ease;`; - - const searchIcon = document.createElement("i"); - searchIcon.className = "fa-solid fa-magnifying-glass"; - searchIcon.style.cssText = `color:${searchColors.textSecondary};font-size:14px;flex-shrink:0;`; - - const searchInput = document.createElement("input"); - searchInput.type = "text"; - searchInput.id = "luatools-settings-search"; - searchInput.placeholder = t( - "settings.search.placeholder", - "Search settings, games, fixes...", - ); - searchInput.style.cssText = `flex:1;background:transparent;border:none;outline:none;color:${searchColors.text};font-size:14px;`; - searchInput.setAttribute("autocomplete", "off"); - - const searchClear = document.createElement("a"); - searchClear.href = "#"; - searchClear.style.cssText = `display:none;color:${searchColors.textSecondary};font-size:14px;text-decoration:none;padding:4px;flex-shrink:0;`; - searchClear.innerHTML = ''; - searchClear.title = t("settings.search.clear", "Clear search"); - - searchWrap.onfocus = function () { - searchWrap.style.borderColor = searchColors.accent; - }; - searchInput.onfocus = function () { - const c = getThemeColors(); - searchWrap.style.borderColor = c.accent; - searchWrap.style.boxShadow = `0 0 0 2px rgba(${c.rgbString},0.2)`; - }; - searchInput.onblur = function () { - const c = getThemeColors(); - searchWrap.style.borderColor = c.border; - searchWrap.style.boxShadow = "none"; - }; - - searchWrap.appendChild(searchIcon); - searchWrap.appendChild(searchInput); - searchWrap.appendChild(searchClear); - searchContainer.appendChild(searchWrap); - - const contentWrap = document.createElement("div"); - contentWrap.id = "luatools-content-wrap"; - const contentColors = getThemeColors(); - contentWrap.style.cssText = `flex:1 1 auto;overflow-y:auto;overflow-x:hidden;padding:24px;margin:0;background:transparent;`; - - // Add mouse mode tip for Big Picture - if (window.__LUATOOLS_IS_BIG_PICTURE__) { - const tip = document.createElement("div"); - const tipColors = getThemeColors(); - tip.style.cssText = `background:rgba(${tipColors.rgbString},0.08);border:1px solid ${tipColors.border};padding:12px 16px;border-radius:8px;font-size:13px;color:${tipColors.textSecondary};margin-bottom:20px;line-height:1.5;display:flex;align-items:center;gap:10px;`; - tip.innerHTML = - '' + - t( - "bigpicture.mouseTip", - "To use mouse mode in Steam: Guide Button + Right Joystick, click with RB", - ); - contentWrap.appendChild(tip); - } - - const btnRow = document.createElement("div"); - btnRow.style.cssText = - "padding:16px 24px 20px;display:flex;gap:10px;justify-content:space-between;align-items:center;border-top:1px solid rgba(255,255,255,0.06);"; - - const backBtn = createSettingsButton( - "back", - "", - false, - '', - ); - const rightButtons = document.createElement("div"); - rightButtons.style.cssText = "display:flex;gap:10px;"; - const refreshBtn = createSettingsButton( - "refresh", - "", - false, - '', - ); - const saveBtn = createSettingsButton( - "save", - "", - true, - '', - ); - - modal.appendChild(header); - modal.appendChild(searchContainer); - modal.appendChild(contentWrap); - modal.appendChild(btnRow); - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - - const state = { - config: null, - draft: {}, - searchQuery: "", - }; - - // Search functionality - let searchDebounceTimer = null; - searchInput.addEventListener("input", function () { - const query = searchInput.value.trim().toLowerCase(); - searchClear.style.display = query ? "block" : "none"; - - // Debounce the search - if (searchDebounceTimer) clearTimeout(searchDebounceTimer); - searchDebounceTimer = setTimeout(function () { - state.searchQuery = query; - applySearchFilter(); - }, 150); - }); - - searchClear.addEventListener("click", function (e) { - e.preventDefault(); - searchInput.value = ""; - searchClear.style.display = "none"; - state.searchQuery = ""; - applySearchFilter(); - searchInput.focus(); - }); - - function applySearchFilter() { - const query = state.searchQuery; - - // Filter settings options - const optionEls = contentWrap.querySelectorAll("[data-setting-option]"); - optionEls.forEach(function (el) { - const searchText = (el.dataset.searchText || "").toLowerCase(); - if (!query || searchText.includes(query)) { - el.style.display = ""; - } else { - el.style.display = "none"; - } - }); - - // Filter settings groups (hide if all options hidden) - const groupEls = contentWrap.querySelectorAll("[data-setting-group]"); - groupEls.forEach(function (groupEl) { - const visibleOptions = groupEl.querySelectorAll( - '[data-setting-option]:not([style*="display: none"])', - ); - if (!query || visibleOptions.length > 0) { - groupEl.style.display = ""; - } else { - groupEl.style.display = "none"; - } - }); - - // Filter installed fixes - const fixItems = contentWrap.querySelectorAll("[data-fix-item]"); - let visibleFixes = 0; - fixItems.forEach(function (el) { - const searchText = (el.dataset.searchText || "").toLowerCase(); - if (!query || searchText.includes(query)) { - el.style.display = ""; - visibleFixes++; - } else { - el.style.display = "none"; - } - }); - - // Show/hide fixes empty state - const fixesSection = document.getElementById( - "luatools-installed-fixes-section", - ); - const fixesEmptySearch = fixesSection - ? fixesSection.querySelector(".search-empty-state") - : null; - if (fixesSection && query && fixItems.length > 0 && visibleFixes === 0) { - if (!fixesEmptySearch) { - const emptyEl = document.createElement("div"); - emptyEl.className = "search-empty-state"; - const emptyColors = getThemeColors(); - emptyEl.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};text-align:center;margin-top:10px;`; - emptyEl.textContent = t( - "settings.search.noResults", - "No matches found", - ); - const listContainer = fixesSection.querySelector( - "#luatools-fixes-list", - ); - if (listContainer) listContainer.appendChild(emptyEl); - } - } else if (fixesEmptySearch) { - fixesEmptySearch.remove(); - } - - // Filter installed lua scripts - const luaItems = contentWrap.querySelectorAll("[data-lua-item]"); - let visibleLua = 0; - luaItems.forEach(function (el) { - const searchText = (el.dataset.searchText || "").toLowerCase(); - if (!query || searchText.includes(query)) { - el.style.display = ""; - visibleLua++; - } else { - el.style.display = "none"; - } - }); - - // Show/hide lua empty state - const luaSection = document.getElementById( - "luatools-installed-lua-section", - ); - const luaEmptySearch = luaSection - ? luaSection.querySelector(".search-empty-state") - : null; - if (luaSection && query && luaItems.length > 0 && visibleLua === 0) { - if (!luaEmptySearch) { - const emptyEl = document.createElement("div"); - emptyEl.className = "search-empty-state"; - const emptyColors = getThemeColors(); - emptyEl.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};text-align:center;margin-top:10px;`; - emptyEl.textContent = t( - "settings.search.noResults", - "No matches found", - ); - const listContainer = luaSection.querySelector("#luatools-lua-list"); - if (listContainer) listContainer.appendChild(emptyEl); - } - } else if (luaEmptySearch) { - luaEmptySearch.remove(); - } - } - - let refreshDefaultLabel = ""; - let saveDefaultLabel = ""; - let closeDefaultLabel = ""; - let backDefaultLabel = ""; - - function createSettingsButton(id, text, isPrimary, iconHtml) { - const btn = document.createElement("a"); - btn.id = "lt-settings-" + id; - btn.href = "#"; - const btnColors = getThemeColors(); - const hasText = text && text.trim().length > 0; - if (iconHtml) { - btn.innerHTML = hasText - ? iconHtml + "" + text + "" - : iconHtml; - } else { - btn.innerHTML = "" + text + ""; - } - - const btnSize = hasText - ? "padding:9px 16px;" - : "width:38px;height:38px;padding:0;"; - btn.style.cssText = `display:inline-flex;align-items:center;justify-content:center;${btnSize}background:rgba(${btnColors.rgbString},0.1);border:1px solid ${btnColors.border};border-radius:8px;color:${btnColors.text};font-size:14px;text-decoration:none;transition:all 0.2s ease;cursor:pointer;`; - - if (isPrimary) { - btn.style.background = `linear-gradient(135deg, rgba(${btnColors.rgbString},0.25) 0%, rgba(${btnColors.rgbString},0.15) 100%)`; - btn.style.borderColor = btnColors.accent; - } - - btn.onmouseover = function () { - if (this.dataset.disabled === "1") { - this.style.opacity = "0.6"; - this.style.cursor = "not-allowed"; - return; - } - const c = getThemeColors(); - if (isPrimary) { - this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.35) 0%, rgba(${c.rgbString},0.2) 100%)`; - } else { - this.style.background = `rgba(${c.rgbString},0.18)`; - } - this.style.transform = "translateY(-1px)"; - this.style.boxShadow = `0 4px 12px ${c.shadow}`; - }; - - btn.onmouseout = function () { - if (this.dataset.disabled === "1") { - this.style.opacity = "0.5"; - this.style.transform = "none"; - this.style.boxShadow = "none"; - return; - } - const c = getThemeColors(); - if (isPrimary) { - this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.25) 0%, rgba(${c.rgbString},0.15) 100%)`; - } else { - this.style.background = `rgba(${c.rgbString},0.1)`; - } - this.style.transform = "translateY(0)"; - this.style.boxShadow = "none"; - }; - - if (isPrimary) { - btn.dataset.disabled = "1"; - btn.style.opacity = "0.5"; - btn.style.cursor = "not-allowed"; - } - - return btn; - } - - header.appendChild(title); - header.appendChild(iconButtons); - - // Inject scrollbar styles for content area - const scrollbarStyle = document.createElement("style"); - scrollbarStyle.textContent = - "#luatools-content-wrap::-webkit-scrollbar { width: 8px; } " + - "#luatools-content-wrap::-webkit-scrollbar-track { background: transparent; } " + - "#luatools-content-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } " + - "#luatools-content-wrap::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }"; - modal.appendChild(scrollbarStyle); - - function applyStaticTranslations() { - title.textContent = t("settings.title", "LuaTools · Settings"); - refreshBtn.title = t("settings.refresh", "Refresh"); - saveBtn.title = t("settings.save", "Save Settings"); - backBtn.title = t("Back", "Back"); - discordIconBtn.title = t("menu.discord", "Discord"); - closeIconBtn.title = t("settings.close", "Close"); - } - applyStaticTranslations(); - - function setStatus(text, color) { - let statusLine = contentWrap.querySelector(".luatools-settings-status"); - if (!statusLine) { - statusLine = document.createElement("div"); - statusLine.className = "luatools-settings-status"; - statusLine.style.cssText = - "font-size:13px;margin-bottom:16px;color:#c7d5e0;text-align:center;padding:6px 12px;background:rgba(255,255,255,0.03);border-radius:6px;"; - contentWrap.insertBefore(statusLine, contentWrap.firstChild); - } - if (!text || text.trim() === "") { - statusLine.style.display = "none"; - return; - } - statusLine.style.display = ""; - statusLine.textContent = text; - statusLine.style.color = color || "#c7d5e0"; - } - - function ensureDraftGroup(groupKey) { - if (!state.draft[groupKey] || typeof state.draft[groupKey] !== "object") { - state.draft[groupKey] = {}; - } - return state.draft[groupKey]; - } - - function collectChanges() { - if (!state.config || !Array.isArray(state.config.schema)) { - return {}; - } - const changes = {}; - for (let i = 0; i < state.config.schema.length; i++) { - const group = state.config.schema[i]; - if (!group || !group.key) continue; - const options = Array.isArray(group.options) ? group.options : []; - const draftGroup = state.draft[group.key] || {}; - const originalGroup = - (state.config.values && state.config.values[group.key]) || {}; - const groupChanges = {}; - for (let j = 0; j < options.length; j++) { - const option = options[j]; - if (!option || !option.key) continue; - const newValue = draftGroup.hasOwnProperty(option.key) - ? draftGroup[option.key] - : option.default; - const oldValue = originalGroup.hasOwnProperty(option.key) - ? originalGroup[option.key] - : option.default; - if (newValue !== oldValue) { - groupChanges[option.key] = newValue; - } - } - if (Object.keys(groupChanges).length > 0) { - changes[group.key] = groupChanges; - } - } - return changes; - } - - function updateSaveState() { - const hasChanges = Object.keys(collectChanges()).length > 0; - const isBusy = saveBtn.dataset.busy === "1"; - - let hubcapKey = ""; - let foundHubcapKey = false; - for (const group in state.draft) { - if ( - state.draft[group] && - state.draft[group].hasOwnProperty("morrenusApiKey") - ) { - hubcapKey = state.draft[group].morrenusApiKey; - foundHubcapKey = true; - break; - } - } - - let isValid = true; - if (foundHubcapKey && hubcapKey) { - isValid = /^smm_[0-9a-f]{96}$/.test(hubcapKey); - } - - if (hasChanges && !isBusy && isValid) { - saveBtn.dataset.disabled = "0"; - saveBtn.style.opacity = ""; - saveBtn.style.cursor = "pointer"; - } else { - saveBtn.dataset.disabled = "1"; - saveBtn.style.opacity = "0.6"; - saveBtn.style.cursor = "not-allowed"; - } - - if (foundHubcapKey && hubcapKey && !isValid) { - setStatus(lt("Invalid Morrenus API Key format"), "#ff5c5c"); - } - } - - function optionLabelKey(groupKey, optionKey) { - if (groupKey === "general") { - if (optionKey === "language") return "settings.language.label"; - if (optionKey === "useSteamLanguage") - return "settings.useSteamLanguage.label"; - if (optionKey === "donateKeys") return "settings.donateKeys.label"; - if (optionKey === "theme") return "settings.theme.label"; - if (optionKey === "fastDownload") return "settings.fastDownload.label"; - if (optionKey === "morrenusApiKey") return "settings.morrenusApiKey.label"; - } - return null; - } - - function optionDescriptionKey(groupKey, optionKey) { - if (groupKey === "general") { - if (optionKey === "language") return "settings.language.description"; - if (optionKey === "useSteamLanguage") - return "settings.useSteamLanguage.description"; - if (optionKey === "donateKeys") - return "settings.donateKeys.description"; - if (optionKey === "theme") return "settings.theme.description"; - if (optionKey === "fastDownload") - return "settings.fastDownload.description"; - if (optionKey === "morrenusApiKey") - return "settings.morrenusApiKey.description"; - } - return null; - } - - function optionPlaceholderKey(groupKey, optionKey) { - if (groupKey === "general") { - if (optionKey === "morrenusApiKey") - return "settings.morrenusApiKey.placeholder"; - } - return null; - } - - function renderSettings() { - contentWrap.innerHTML = ""; - if ( - !state.config || - !Array.isArray(state.config.schema) || - state.config.schema.length === 0 - ) { - const emptyState = document.createElement("div"); - const emptyColors = getThemeColors(); - emptyState.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};`; - emptyState.textContent = t( - "settings.empty", - "No settings available yet.", - ); - contentWrap.appendChild(emptyState); - updateSaveState(); - return; - } - - for (let i = 0; i < state.config.schema.length; i++) { - const group = state.config.schema[i]; - if (!group || !group.key) continue; - - const groupEl = document.createElement("div"); - const groupCardColors = getThemeColors(); - groupEl.style.cssText = `background:rgba(${groupCardColors.rgbString},0.04);border:1px solid ${groupCardColors.border};border-radius:10px;padding:18px 20px;margin-bottom:16px;`; - groupEl.dataset.settingGroup = group.key; - - const groupTitle = document.createElement("div"); - const titleText = t("settings." + group.key, group.label || group.key); - if (group.key === "general") { - const generalTitleColors = getThemeColors(); - groupTitle.innerHTML = `${titleText}`; - groupTitle.style.cssText = `font-size:19px;color:${generalTitleColors.text};margin-bottom:14px;font-weight:600;display:flex;align-items:center;`; - } else { - const otherTitleColors = getThemeColors(); - groupTitle.style.cssText = `font-size:15px;font-weight:600;color:${otherTitleColors.accent};margin-bottom:6px;`; - } - groupEl.appendChild(groupTitle); - - if (group.description && group.key !== "general") { - const groupDesc = document.createElement("div"); - const descColors = getThemeColors(); - groupDesc.style.cssText = `margin-bottom:14px;font-size:12px;color:${descColors.textSecondary};line-height:1.5;`; - groupDesc.textContent = t( - "settings." + group.key + "Description", - group.description, - ); - groupEl.appendChild(groupDesc); - } - - const options = Array.isArray(group.options) ? group.options : []; - for (let j = 0; j < options.length; j++) { - const option = options[j]; - if (!option || !option.key) continue; - - ensureDraftGroup(group.key); - if (!state.draft[group.key].hasOwnProperty(option.key)) { - const sourceGroup = - (state.config.values && state.config.values[group.key]) || {}; - const initialValue = sourceGroup.hasOwnProperty(option.key) - ? sourceGroup[option.key] - : option.default; - state.draft[group.key][option.key] = initialValue; - } - - const optionEl = document.createElement("div"); - const optionColors = getThemeColors(); - const alignItems = - option.type === "select" || option.type === "text" - ? "center" - : "flex-start"; - optionEl.style.cssText = - j === 0 - ? `padding-top:0;display:flex;justify-content:space-between;align-items:${alignItems};gap:16px;` - : `margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05);display:flex;justify-content:space-between;align-items:${alignItems};gap:16px;`; - optionEl.dataset.settingOption = option.key; - - const labelWrap = document.createElement("div"); - labelWrap.className = "luatools-toggle-label-wrap"; - labelWrap.style.flex = "1"; - - const optionLabel = document.createElement("div"); - const optLabelColors = getThemeColors(); - optionLabel.style.cssText = `font-size:14px;font-weight:500;color:${optLabelColors.text};`; - const labelKey = optionLabelKey(group.key, option.key); - const labelText = t( - labelKey || "settings." + group.key + "." + option.key + ".label", - option.label || option.key, - ); - optionLabel.textContent = labelText; - - // Build search text from label, description, and key - const descText = option.description || ""; - optionEl.dataset.searchText = ( - labelText + - " " + - descText + - " " + - option.key + - " " + - group.key - ).toLowerCase(); - labelWrap.appendChild(optionLabel); - - if (option.description) { - const optionDesc = document.createElement("div"); - const optDescColors = getThemeColors(); - optionDesc.style.cssText = `margin-top:3px;font-size:12px;color:${optDescColors.textSecondary};line-height:1.45;`; - const descKey = optionDescriptionKey(group.key, option.key); - let descTextVal = t( - descKey || - "settings." + group.key + "." + option.key + ".description", - option.description, - ); - - // Special handling for hubcap link - if ( - descTextVal.includes("hubcapmanifest.com") || - descTextVal.includes("{link}") - ) { - const url = "https://hubcapmanifest.com"; - const linkHtml = `hubcapmanifest.com`; - if (descTextVal.includes("{link}")) { - descTextVal = descTextVal.replace("{link}", linkHtml); - } else { - descTextVal = descTextVal.replace( - "hubcapmanifest.com", - linkHtml, - ); - } - optionDesc.innerHTML = descTextVal; - - // Add event listener after appending to document or wait? - // Better: use a selector later or add it now if possible. - setTimeout(() => { - const link = document.getElementById("lt-hubcap-link"); - if (link) { - link.onclick = (e) => { - e.preventDefault(); - Millennium.callServerMethod("luatools", "OpenExternalUrl", { - url, - contentScriptQuery: "", - }); - }; - } - }, 0); - } else { - optionDesc.textContent = descTextVal; - } - labelWrap.appendChild(optionDesc); - } - - if (option.type === "toggle") { - optionEl.classList.add("luatools-toggle-container"); - optionEl.appendChild(labelWrap); - - const toggleWrap = document.createElement("div"); - toggleWrap.style.cssText = - "display:flex;align-items:center;flex-shrink:0;"; - - const toggleLabel = document.createElement("label"); - toggleLabel.className = "luatools-toggle"; - - const toggleInput = document.createElement("input"); - toggleInput.type = "checkbox"; - toggleInput.checked = state.draft[group.key][option.key] === true; - - const slider = document.createElement("span"); - slider.className = "luatools-slider"; - - toggleInput.addEventListener("change", function () { - state.draft[group.key][option.key] = toggleInput.checked; - updateSaveState(); - if (option.key === "useSteamLanguage") refreshDependencies(); - setStatus(t("settings.unsaved", "Unsaved changes"), "#c7d5e0"); - }); - - toggleLabel.appendChild(toggleInput); - toggleLabel.appendChild(slider); - toggleWrap.appendChild(toggleLabel); - optionEl.appendChild(toggleWrap); - } else { - optionEl.appendChild(labelWrap); - const controlWrap = document.createElement("div"); - - // If it's a select or any text input, align right like toggles - const isRightAligned = - option.type === "select" || option.type === "text"; - if (isRightAligned) { - optionEl.classList.add("luatools-toggle-container"); - optionEl.style.width = "100%"; - controlWrap.style.setProperty("width", "180px", "important"); - controlWrap.style.setProperty("flex-shrink", "0", "important"); - } else { - controlWrap.style.cssText = "margin-top:8px;"; - } - - optionEl.appendChild(controlWrap); - - if (option.type === "select") { - const selectEl = document.createElement("select"); - const selectColors = getThemeColors(); - selectEl.style.cssText = `width:100%;padding:7px 32px 7px 10px !important;background:${selectColors.bgTertiary} !important;color:${selectColors.text} !important;border:1px solid ${selectColors.border} !important;border-radius:6px !important;font-size:13px !important;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='${encodeURIComponent(selectColors.textSecondary)}' stroke-width='1.5' fill='none'/%3E%3C/svg%3E") !important;background-repeat:no-repeat !important;background-position:right 10px center !important;transition:border-color 0.2s ease,box-shadow 0.2s ease;`; - selectEl.onfocus = function () { - const c = getThemeColors(); - this.style.borderColor = c.accent + " !important"; - this.style.boxShadow = `0 0 0 2px rgba(${c.rgbString},0.2)`; - }; - selectEl.onblur = function () { - const c = getThemeColors(); - this.style.borderColor = c.border + " !important"; - this.style.boxShadow = "none"; - }; - - const choices = Array.isArray(option.choices) - ? option.choices - : []; - for (let c = 0; c < choices.length; c++) { - const choice = choices[c]; - if (!choice) continue; - const choiceOption = document.createElement("option"); - choiceOption.value = String(choice.value); - choiceOption.textContent = choice.label || choice.value; - selectEl.appendChild(choiceOption); - } - - const currentValue = state.draft[group.key][option.key]; - if (typeof currentValue !== "undefined") { - selectEl.value = String(currentValue); - } - - selectEl.addEventListener("change", function () { - state.draft[group.key][option.key] = selectEl.value; - try { - backendLog( - "LuaTools: " + - option.key + - " select changed to " + - selectEl.value, - ); - } catch (_) { } - - // If theme changed, apply it immediately - if (group.key === "general" && option.key === "theme") { - try { - backendLog( - "LuaTools: Theme change detected, new value: " + - selectEl.value, - ); - } catch (_) { } - // Update the settings cache so getCurrentTheme() returns the new value - if ( - window.__LuaToolsSettings && - window.__LuaToolsSettings.values - ) { - if (!window.__LuaToolsSettings.values.general) { - window.__LuaToolsSettings.values.general = {}; - } - window.__LuaToolsSettings.values.general.theme = - selectEl.value; - try { - backendLog( - "LuaTools: Updated cache, theme is now: " + - window.__LuaToolsSettings.values.general.theme, - ); - } catch (_) { } - } - // Reload styles immediately - ensureLuaToolsStyles(); - - // Update all modal elements with new theme colors - setTimeout(function () { - const colors = getThemeColors(); - - // Update modal background and border - const modalEl = - overlay && - overlay.querySelector( - '[style*="background:linear-gradient"]', - ); - if (modalEl) { - modalEl.style.background = colors.modalBg; - modalEl.style.borderColor = colors.border; - } - - // Update header border - const headerEl = - overlay && - overlay.querySelector('[style*="border-bottom"]'); - if (headerEl) { - headerEl.style.borderBottomColor = colors.border.replace( - "0.3", - "0.2", - ); - } - - // Update all title and text colors - const titles = - overlay && - overlay.querySelectorAll('[style*="text-shadow"]'); - if (titles) { - titles.forEach(function (title) { - title.style.backgroundImage = colors.gradientLight; - }); - } - - // Update content wrapper border - const contentWrapEl = - overlay && - overlay.querySelector("#luatools-content-wrap"); - if (contentWrapEl) { - contentWrapEl.style.borderColor = colors.border; - contentWrapEl.style.background = colors.bgContainer; - } - - // Re-render the settings content - renderSettings(); - }, 50); - - // Auto-save theme changes after a brief delay - setTimeout(function () { - if ( - saveBtn && - saveBtn.dataset.disabled !== "1" && - saveBtn.dataset.busy !== "1" - ) { - saveBtn.click(); - } - }, 150); - } - - updateSaveState(); - setStatus(t("settings.unsaved", "Unsaved changes"), "#c7d5e0"); - }); - - controlWrap.appendChild(selectEl); - } else if (option.type === "text") { - const textInput = document.createElement("input"); - textInput.type = - option.key === "morrenusApiKey" ? "password" : "text"; - const textColors = getThemeColors(); - const placeholderKey = optionPlaceholderKey(group.key, option.key); - const placeholder = t( - placeholderKey || "", - option.metadata && option.metadata.placeholder - ? String(option.metadata.placeholder) - : "", - ); - textInput.placeholder = placeholder; - textInput.style.cssText = `width:180px !important;padding:7px 12px !important;background:${textColors.bgTertiary} !important;color:${textColors.text} !important;border:1px solid ${textColors.border} !important;border-radius:6px !important;font-size:13px !important;box-sizing:border-box !important;transition:border-color 0.2s ease, box-shadow 0.2s ease;`; - - const currentValue = state.draft[group.key][option.key]; - if ( - typeof currentValue !== "undefined" && - currentValue !== null - ) { - textInput.value = String(currentValue); - } - - textInput.addEventListener("input", function () { - state.draft[group.key][option.key] = textInput.value; - updateSaveState(); - setStatus(t("settings.unsaved", "Unsaved changes"), "#c7d5e0"); - }); - - textInput.addEventListener("focus", function () { - textInput.style.borderColor = textColors.accent + " !important"; - textInput.style.boxShadow = `0 0 0 2px rgba(${textColors.rgbString},0.2)`; - textInput.style.outline = "none"; - }); - - textInput.addEventListener("blur", function () { - textInput.style.borderColor = textColors.border + " !important"; - textInput.style.boxShadow = "none"; - }); - - controlWrap.appendChild(textInput); - - if (option.key === "morrenusApiKey") { - const statsDiv = document.createElement("div"); - statsDiv.style.cssText = - "margin-top:8px;font-size:12px;color:" + - textColors.textSecondary + - ";width:180px;word-break:break-word;"; - controlWrap.appendChild(statsDiv); - - const updateStats = function (key) { - if (!key || key.trim() === "") { - statsDiv.innerHTML = ""; - return; - } - if (!/^smm_[0-9a-f]{96}$/.test(key)) { - statsDiv.innerHTML = - "" + - lt("Invalid key format") + - ""; - return; - } - statsDiv.innerHTML = - "" + - lt("Checking key..."); - Millennium.callServerMethod("luatools", "GetMorrenusStats", { - api_key: key, - contentScriptQuery: "", - }) - .then((r) => (typeof r === "string" ? JSON.parse(r) : r)) - .then((res) => { - if (res && res.username) { - let expiryText = ""; - if (res.api_key_expires_at) { - const expiry = new Date(res.api_key_expires_at); - const now = new Date(); - const days = Math.max( - 0, - Math.ceil((expiry - now) / (1000 * 60 * 60 * 24)), - ); - expiryText = days + " " + lt("days left"); - } - const usage = - typeof res.daily_usage !== "undefined" - ? res.daily_usage - : "?"; - const limit = - typeof res.daily_limit !== "undefined" - ? res.daily_limit - : "?"; - - const usageColor = - typeof res.daily_usage !== "undefined" && - typeof res.daily_limit !== "undefined" && - res.daily_usage >= res.daily_limit - ? "#ff5c5c" - : textColors.accent; - - statsDiv.innerHTML = ` -
-
${res.username}
-
- ${lt("Usage")} - ${usage} / ${limit} -
-
- ${lt("Expires")} - ${expiryText} -
-
- `; - } else { - statsDiv.innerHTML = - "" + - lt("Invalid or rejected key") + - ""; - } - }) - .catch((e) => { - statsDiv.innerHTML = - "" + - lt("Failed to verify key") + - ""; - }); - }; - - updateStats(textInput.value); - - textInput.addEventListener("input", function () { - if (textInput.apiDebounce) - clearTimeout(textInput.apiDebounce); - textInput.apiDebounce = setTimeout(() => { - updateStats(this.value); - }, 800); - }); - } - } else { - const unsupported = document.createElement("div"); - unsupported.style.cssText = "font-size:12px;color:#ffb347;"; - unsupported.textContent = lt( - "common.error.unsupportedOption", - ).replace("{type}", option.type); - controlWrap.appendChild(unsupported); - } - } - groupEl.appendChild(optionEl); - } - - contentWrap.appendChild(groupEl); - } - - // Render Installed Fixes section - renderInstalledFixesSection(); - - // Render Installed Lua Scripts section - renderInstalledLuaSection(); - - updateSaveState(); - refreshDependencies(); - } - - function refreshDependencies() { - try { - const languageEl = overlay.querySelector( - '[data-setting-option="language"]', - ); - if (languageEl) { - const useSteam = - state.draft && - state.draft.general && - state.draft.general.useSteamLanguage; - if (useSteam !== false) { - languageEl.style.display = "none"; - } else { - languageEl.style.display = "flex"; - } - } - } catch (_) { } - } - - function renderInstalledFixesSection() { - const sectionEl = document.createElement("div"); - sectionEl.id = "luatools-installed-fixes-section"; - const sectionColors = getThemeColors(); - sectionEl.style.cssText = `margin-top:28px;padding:20px;background:rgba(${sectionColors.rgbString},0.04);border:1px solid ${sectionColors.border};border-radius:10px;`; - - const sectionTitle = document.createElement("div"); - const titleColors = getThemeColors(); - sectionTitle.style.cssText = `font-size:16px;color:${titleColors.text};margin-bottom:14px;font-weight:600;`; - sectionTitle.innerHTML = - '' + - t("settings.installedFixes.title", "Installed Fixes"); - sectionEl.appendChild(sectionTitle); - - const listContainer = document.createElement("div"); - listContainer.id = "luatools-fixes-list"; - listContainer.style.cssText = "min-height:50px;"; - sectionEl.appendChild(listContainer); - - contentWrap.appendChild(sectionEl); - - loadInstalledFixes(listContainer); - } - - function loadInstalledFixes(container) { - const loadingColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedFixes.loading", "Scanning for installed fixes...")}
`; - - Millennium.callServerMethod("luatools", "GetInstalledFixes", { - contentScriptQuery: "", - }) - .then(function (res) { - const response = typeof res === "string" ? JSON.parse(res) : res; - backendLog( - "LuaTools: GetInstalledFixes response: " + - JSON.stringify(response).substring(0, 200), - ); - if (!response || !response.success) { - backendLog( - "LuaTools: GetInstalledFixes failed - response: " + - JSON.stringify(response), - ); - const errColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedFixes.error", "Failed to load installed fixes.")}
`; - return; - } - - const fixes = Array.isArray(response.fixes) ? response.fixes : []; - if (fixes.length === 0) { - const emptyColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedFixes.empty", "No fixes installed yet.")}
`; - return; - } - - container.innerHTML = ""; - for (let i = 0; i < fixes.length; i++) { - const fix = fixes[i]; - const fixEl = createFixListItem(fix, container); - container.appendChild(fixEl); - } - - // Re-apply search filter after loading - if (state.searchQuery) { - setTimeout(applySearchFilter, 50); - } - }) - .catch(function (err) { - backendLog("LuaTools: GetInstalledFixes catch error: " + err); - const catchColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedFixes.error", "Failed to load installed fixes.")}
`; - }); - } - - function createFixListItem(fix, container) { - const itemEl = document.createElement("div"); - const itemColors = getThemeColors(); - const accentColor = itemColors.accent || "#1a9fff"; - itemEl.style.cssText = `padding:14px 16px;background:rgba(${itemColors.rgbString},0.04);border:1px solid ${itemColors.border};border-radius:8px;display:flex;justify-content:space-between;align-items:center;transition:all 0.15s ease;`; - - itemEl.onmouseover = function () { - const c = getThemeColors(); - this.style.borderColor = c.accent; - this.style.background = `rgba(${c.rgbString},0.08)`; - }; - itemEl.onmouseout = function () { - const c = getThemeColors(); - this.style.borderColor = c.border; - this.style.background = `rgba(${c.rgbString},0.04)`; - }; - - // Add search data attributes - itemEl.dataset.fixItem = fix.appid; - const gameNameText = fix.gameName || "Unknown Game"; - itemEl.dataset.searchText = ( - gameNameText + - " " + - fix.appid + - " " + - (fix.fixType || "") + - " fix" - ).toLowerCase(); - - const infoDiv = document.createElement("div"); - infoDiv.style.cssText = "flex:1;padding-right:15px;"; - - const gameName = document.createElement("div"); - const nameColors = getThemeColors(); - gameName.style.cssText = `font-size:15px;font-weight:600;color:${nameColors.text};margin-bottom:3px;`; - gameName.textContent = gameNameText; - infoDiv.appendChild(gameName); - - if (!fix.gameName || fix.gameName.startsWith("Unknown Game")) { - fetchSteamGameName(fix.appid).then(function (name) { - if (name) { - fix.gameName = name; - gameName.textContent = name; - itemEl.dataset.searchText = ( - name + - " " + - fix.appid + - " " + - (fix.fixType || "") + - " fix" - ).toLowerCase(); - } - }); - } - - const detailsDiv = document.createElement("div"); - const detailsColors = getThemeColors(); - detailsDiv.style.cssText = `font-size:12px;color:${detailsColors.textSecondary};display:flex;flex-wrap:wrap;gap:10px;`; - - if (fix.fixType) { - const typeSpan = document.createElement("div"); - const typeColors = getThemeColors(); - typeSpan.innerHTML = `${fix.fixType}`; - detailsDiv.appendChild(typeSpan); - } - - if (fix.date) { - const dateSpan = document.createElement("div"); - const dateColors = getThemeColors(); - dateSpan.innerHTML = `${fix.date}`; - detailsDiv.appendChild(dateSpan); - } - - if (fix.filesCount > 0) { - const filesSpan = document.createElement("div"); - const filesColors = getThemeColors(); - filesSpan.innerHTML = `${t("settings.installedFixes.files", "{count} files").replace("{count}", fix.filesCount)}`; - detailsDiv.appendChild(filesSpan); - } - - infoDiv.appendChild(detailsDiv); - itemEl.appendChild(infoDiv); - - const fixDeleteBtn = document.createElement("a"); - fixDeleteBtn.href = "#"; - fixDeleteBtn.style.cssText = - "display:flex;align-items:center;justify-content:center;width:38px;height:38px;background:rgba(255,80,80,0.1);border:1px solid rgba(255,80,80,0.3);border-radius:8px;color:#ff5050;font-size:15px;text-decoration:none;transition:all 0.15s ease;cursor:pointer;flex-shrink:0;"; - fixDeleteBtn.innerHTML = ''; - fixDeleteBtn.title = t("settings.installedFixes.delete", "Remove"); - fixDeleteBtn.onmouseover = function () { - this.style.background = "rgba(255,80,80,0.2)"; - this.style.borderColor = "rgba(255,80,80,0.5)"; - this.style.color = "#ff6b6b"; - }; - fixDeleteBtn.onmouseout = function () { - this.style.background = "rgba(255,80,80,0.1)"; - this.style.borderColor = "rgba(255,80,80,0.3)"; - this.style.color = "#ff5050"; - }; - - fixDeleteBtn.addEventListener("click", function (e) { - e.preventDefault(); - if (fixDeleteBtn.dataset.busy === "1") return; - - showLuaToolsConfirm( - fix.gameName || "LuaTools", - t( - "settings.installedFixes.deleteConfirm", - "Are you sure you want to remove this fix? This will delete fix files and run Steam verification.", - ), - function () { - // User confirmed - fixDeleteBtn.dataset.busy = "1"; - fixDeleteBtn.style.opacity = "0.6"; - fixDeleteBtn.innerHTML = - ''; - - Millennium.callServerMethod("luatools", "UnFixGame", { - appid: fix.appid, - installPath: fix.installPath || "", - fixDate: fix.date || "", - contentScriptQuery: "", - }) - .then(function (res) { - const response = - typeof res === "string" ? JSON.parse(res) : res; - if (!response || !response.success) { - alert( - t( - "settings.installedFixes.deleteError", - "Failed to remove fix.", - ), - ); - fixDeleteBtn.dataset.busy = "0"; - fixDeleteBtn.style.opacity = "1"; - fixDeleteBtn.innerHTML = - ' ' + - t("settings.installedFixes.delete", "Delete") + - ""; - return; - } - - // Poll for unfix status - pollUnfixStatus(fix.appid, itemEl, fixDeleteBtn, container); - }) - .catch(function (err) { - alert( - t( - "settings.installedFixes.deleteError", - "Failed to remove fix.", - ) + - " " + - (err && err.message ? err.message : ""), - ); - fixDeleteBtn.dataset.busy = "0"; - fixDeleteBtn.style.opacity = "1"; - fixDeleteBtn.innerHTML = ''; - }); - }, - function () { - // User cancelled - do nothing - }, - ); - }); - - itemEl.appendChild(fixDeleteBtn); - return itemEl; - } - - function pollUnfixStatus(appid, itemEl, deleteBtn, container) { - let pollCount = 0; - const maxPolls = 60; - - function checkStatus() { - if (pollCount >= maxPolls) { - alert( - t("settings.installedFixes.deleteError", "Failed to remove fix.") + - " (Timeout)", - ); - deleteBtn.dataset.busy = "0"; - deleteBtn.style.opacity = "1"; - deleteBtn.innerHTML = - ' ' + - t("settings.installedFixes.delete", "Delete") + - ""; - return; - } - - pollCount++; - - Millennium.callServerMethod("luatools", "GetUnfixStatus", { - appid: appid, - contentScriptQuery: "", - }) - .then(function (res) { - const response = typeof res === "string" ? JSON.parse(res) : res; - if (!response || !response.success) { - setTimeout(checkStatus, 500); - return; - } - - const state = response.state || {}; - const status = state.status; - - if (status === "done" && state.success) { - // Success - remove item from list with animation - itemEl.style.transition = "all 0.3s ease"; - itemEl.style.opacity = "0"; - itemEl.style.transform = "translateX(-20px)"; - setTimeout(function () { - itemEl.remove(); - // Check if list is now empty - if (container.children.length === 0) { - const emptyFixesColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedFixes.empty", "No fixes installed yet.")}
`; - } - }, 300); - - // Trigger Steam verification after a short delay - setTimeout(function () { - try { - const verifyUrl = "steam://validate/" + appid; - window.location.href = verifyUrl; - backendLog("LuaTools: Running verify for appid " + appid); - } catch (_) { } - }, 1000); - - return; - } else if ( - status === "failed" || - (status === "done" && !state.success) - ) { - alert( - t( - "settings.installedFixes.deleteError", - "Failed to remove fix.", - ) + - " " + - (state.error || ""), - ); - fixDeleteBtn.dataset.busy = "1"; - fixDeleteBtn.style.opacity = "0.6"; - fixDeleteBtn.innerHTML = - ' ' + - t("settings.installedFixes.delete", "Delete") + - ""; - return; - } else { - // Still in progress - setTimeout(checkStatus, 500); - } - }) - .catch(function (err) { - setTimeout(checkStatus, 500); - }); - } - - checkStatus(); - } - - function renderInstalledLuaSection() { - const sectionEl = document.createElement("div"); - sectionEl.id = "luatools-installed-lua-section"; - const sectionLuaColors = getThemeColors(); - sectionEl.style.cssText = `margin-top:28px;padding:20px;background:rgba(${sectionLuaColors.rgbString},0.04);border:1px solid ${sectionLuaColors.border};border-radius:10px;`; - - const sectionTitle = document.createElement("div"); - const luaTitleColors = getThemeColors(); - sectionTitle.style.cssText = `font-size:16px;color:${luaTitleColors.text};margin-bottom:14px;font-weight:600;`; - sectionTitle.innerHTML = - '' + - t("settings.installedLua.title", "Installed Lua Scripts"); - sectionEl.appendChild(sectionTitle); - - const listContainer = document.createElement("div"); - listContainer.id = "luatools-lua-list"; - listContainer.style.cssText = "min-height:50px;"; - sectionEl.appendChild(listContainer); - - contentWrap.appendChild(sectionEl); - - loadInstalledLuaScripts(listContainer); - } - - function loadInstalledLuaScripts(container) { - const loadingLuaColors = getThemeColors(); - container.innerHTML = - `
` + - t( - "settings.installedLua.loading", - "Scanning for installed Lua scripts...", - ) + - "
"; - - Millennium.callServerMethod("luatools", "GetInstalledLuaScripts", { - contentScriptQuery: "", - }) - .then(function (res) { - const response = typeof res === "string" ? JSON.parse(res) : res; - if (!response || !response.success) { - const errLuaColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedLua.error", "Failed to load installed Lua scripts.")}
`; - return; - } - - const scripts = Array.isArray(response.scripts) - ? response.scripts - : []; - if (scripts.length === 0) { - const emptyLuaColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedLua.empty", "No Lua scripts installed yet.")}
`; - return; - } - - container.innerHTML = ""; - - // Check if there are any unknown games - const hasUnknownGames = scripts.some(function (s) { - return s.gameName && s.gameName.startsWith("Unknown Game"); - }); - - // Show info banner if there are unknown games - if (hasUnknownGames) { - const infoBanner = document.createElement("div"); - infoBanner.style.cssText = - "margin-bottom:16px;padding:12px 14px;background:rgba(255,193,7,0.1);border:1px solid rgba(255,193,7,0.3);border-radius:6px;color:#ffc107;font-size:13px;display:flex;align-items:center;gap:10px;"; - infoBanner.innerHTML = - '' + - t( - "settings.installedLua.unknownInfo", - "Games showing 'Unknown Game' were installed manually (not via LuaTools).", - ) + - ""; - container.appendChild(infoBanner); - } - - for (let i = 0; i < scripts.length; i++) { - const script = scripts[i]; - const scriptEl = createLuaListItem(script, container); - container.appendChild(scriptEl); - } - - // Re-apply search filter after loading - if (state.searchQuery) { - setTimeout(applySearchFilter, 50); - } - }) - .catch(function (err) { - const catchLuaColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedLua.error", "Failed to load installed Lua scripts.")}
`; - }); - } - - function createLuaListItem(script, container) { - const itemEl = document.createElement("div"); - const itemLuaColors = getThemeColors(); - itemEl.style.cssText = `padding:14px 16px;background:rgba(${itemLuaColors.rgbString},0.04);border:1px solid ${itemLuaColors.border};border-radius:8px;display:flex;justify-content:space-between;align-items:center;transition:all 0.15s ease;`; - - itemEl.onmouseover = function () { - const c = getThemeColors(); - this.style.borderColor = c.accent; - this.style.background = `rgba(${c.rgbString},0.08)`; - }; - itemEl.onmouseout = function () { - const c = getThemeColors(); - this.style.borderColor = c.border; - this.style.background = `rgba(${c.rgbString},0.04)`; - }; - - // Add search data attributes - itemEl.dataset.luaItem = script.appid; - const gameNameText = script.gameName || "Unknown Game"; - itemEl.dataset.searchText = ( - gameNameText + - " " + - script.appid + - " lua script" + - (script.isDisabled ? " disabled" : "") - ).toLowerCase(); - - const infoDiv = document.createElement("div"); - infoDiv.style.cssText = "flex:1;padding-right:15px;"; - - const gameName = document.createElement("div"); - const gameNameLuaColors = getThemeColors(); - gameName.style.cssText = `font-size:15px;font-weight:600;color:${gameNameLuaColors.text};margin-bottom:3px;display:flex;align-items:center;flex-wrap:wrap;`; - gameName.textContent = gameNameText; - - if (!script.gameName || script.gameName.startsWith("Unknown Game")) { - fetchSteamGameName(script.appid).then(function (name) { - if (name) { - script.gameName = name; - gameName.textContent = name; - itemEl.dataset.searchText = ( - name + - " " + - script.appid + - " lua script" + - (script.isDisabled ? " disabled" : "") - ).toLowerCase(); - } - }); - } - - if (script.isDisabled) { - const disabledBadge = document.createElement("span"); - disabledBadge.style.cssText = - "margin-left:10px;padding:3px 10px;background:rgba(255,193,7,0.15);border:1px solid rgba(255,193,7,0.4);border-radius:20px;font-size:11px;color:#ffc107;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;"; - disabledBadge.textContent = t( - "settings.installedLua.disabled", - "Disabled", - ); - gameName.appendChild(disabledBadge); - } - - infoDiv.appendChild(gameName); - - const detailsDiv = document.createElement("div"); - const detailsLuaColors = getThemeColors(); - detailsDiv.style.cssText = `font-size:12px;color:${detailsLuaColors.textSecondary};display:flex;flex-wrap:wrap;gap:10px;`; - - if (script.modifiedDate) { - const dateSpan = document.createElement("div"); - const dateLuaColors = getThemeColors(); - dateSpan.innerHTML = `${t("settings.installedLua.modified", "Modified:")} ${script.modifiedDate}`; - detailsDiv.appendChild(dateSpan); - } - - infoDiv.appendChild(detailsDiv); - itemEl.appendChild(infoDiv); - - const luaDeleteBtn = document.createElement("a"); - luaDeleteBtn.href = "#"; - luaDeleteBtn.style.cssText = - "display:flex;align-items:center;justify-content:center;width:38px;height:38px;background:rgba(255,80,80,0.1);border:1px solid rgba(255,80,80,0.3);border-radius:8px;color:#ff5050;font-size:15px;text-decoration:none;transition:all 0.15s ease;cursor:pointer;flex-shrink:0;"; - luaDeleteBtn.innerHTML = ''; - luaDeleteBtn.title = t("settings.installedLua.delete", "Remove"); - luaDeleteBtn.onmouseover = function () { - this.style.background = "rgba(255,80,80,0.2)"; - this.style.borderColor = "rgba(255,80,80,0.5)"; - this.style.color = "#ff6b6b"; - }; - luaDeleteBtn.onmouseout = function () { - this.style.background = "rgba(255,80,80,0.1)"; - this.style.borderColor = "rgba(255,80,80,0.3)"; - this.style.color = "#ff5050"; - this.style.transform = "translateY(0) scale(1)"; - this.style.boxShadow = "none"; - }; - - luaDeleteBtn.addEventListener("click", function (e) { - e.preventDefault(); - if (luaDeleteBtn.dataset.busy === "1") return; - - showLuaToolsConfirm( - script.gameName || "LuaTools", - t( - "settings.installedLua.deleteConfirm", - "Remove via LuaTools for this game?", - ), - function () { - // User confirmed - luaDeleteBtn.dataset.busy = "1"; - luaDeleteBtn.style.opacity = "0.6"; - luaDeleteBtn.innerHTML = - ''; - - Millennium.callServerMethod("luatools", "DeleteLuaToolsForApp", { - appid: script.appid, - contentScriptQuery: "", - }) - .then(function (res) { - const response = - typeof res === "string" ? JSON.parse(res) : res; - if (!response || !response.success) { - alert( - t( - "settings.installedLua.deleteError", - "Failed to remove Lua script.", - ), - ); - luaDeleteBtn.dataset.busy = "0"; - luaDeleteBtn.style.opacity = "1"; - luaDeleteBtn.innerHTML = - ' ' + - t("settings.installedLua.delete", "Delete") + - ""; - return; - } - - // Success - remove item from list with animation - itemEl.style.transition = "all 0.3s ease"; - itemEl.style.opacity = "0"; - itemEl.style.transform = "translateX(-20px)"; - setTimeout(function () { - itemEl.remove(); - // Check if list is now empty - if (container.children.length === 0) { - const emptyLuaColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedLua.empty", "No Lua scripts installed yet.")}
`; - } - }, 300); - }) - .catch(function (err) { - alert( - t( - "settings.installedLua.deleteError", - "Failed to remove Lua script.", - ) + - " " + - (err && err.message ? err.message : ""), - ); - luaDeleteBtn.dataset.busy = "0"; - luaDeleteBtn.style.opacity = "1"; - luaDeleteBtn.innerHTML = - ' ' + - t("settings.installedLua.delete", "Delete") + - ""; - }); - }, - function () { - // User cancelled - do nothing - }, - ); - }); - - itemEl.appendChild(luaDeleteBtn); - return itemEl; - } - - function handleLoad(force) { - setStatus(t("settings.loading", "Loading settings..."), "#c7d5e0"); - saveBtn.dataset.disabled = "1"; - saveBtn.style.opacity = "0.6"; - contentWrap.innerHTML = - '
' + - t("common.status.loading", "Loading...") + - "
"; - - return fetchSettingsConfig(force) - .then(function (config) { - state.config = { - schemaVersion: config.schemaVersion, - schema: Array.isArray(config.schema) ? config.schema : [], - values: initialiseSettingsDraft(config), - language: config.language, - locales: config.locales, - }; - state.draft = initialiseSettingsDraft(config); - applyStaticTranslations(); - renderSettings(); - setStatus("", "#c7d5e0"); - }) - .catch(function (err) { - const message = - err && err.message - ? err.message - : t("settings.error", "Failed to load settings."); - contentWrap.innerHTML = - '
' + message + "
"; - setStatus( - t("common.status.error", "Error") + ": " + message, - "#ff5c5c", - ); - }); - } - - backBtn.addEventListener("click", function (e) { - e.preventDefault(); - if (typeof onBack === "function") { - overlay.remove(); - onBack(); - } - }); - - rightButtons.appendChild(refreshBtn); - rightButtons.appendChild(saveBtn); - btnRow.appendChild(backBtn); - btnRow.appendChild(rightButtons); - - refreshBtn.addEventListener("click", function (e) { - e.preventDefault(); - if (refreshBtn.dataset.busy === "1") return; - refreshBtn.dataset.busy = "1"; - handleLoad(true).finally(function () { - refreshBtn.dataset.busy = "0"; - refreshBtn.style.opacity = "1"; - applyStaticTranslations(); - }); - }); - - saveBtn.addEventListener("click", function (e) { - e.preventDefault(); - if (saveBtn.dataset.disabled === "1" || saveBtn.dataset.busy === "1") - return; - - const changes = collectChanges(); - try { - backendLog( - "LuaTools: collectChanges payload " + JSON.stringify(changes), - ); - } catch (_) { } - if (!changes || Object.keys(changes).length === 0) { - setStatus(t("settings.noChanges", "No changes to save."), "#c7d5e0"); - updateSaveState(); - return; - } - - saveBtn.dataset.busy = "1"; - saveBtn.style.opacity = "0.6"; - setStatus(t("settings.saving", "Saving..."), "#c7d5e0"); - saveBtn.style.opacity = "0.6"; - - const payloadToSend = JSON.parse(JSON.stringify(changes)); - try { - backendLog( - "LuaTools: sending settings payload " + JSON.stringify(payloadToSend), - ); - } catch (_) { } - // Pass flattened keys so Millennium handles the RPC arguments as expected. - Millennium.callServerMethod("luatools", "ApplySettingsChanges", { - contentScriptQuery: "", - changesJson: JSON.stringify(payloadToSend), - }) - .then(function (res) { - const response = typeof res === "string" ? JSON.parse(res) : res; - if (!response || response.success !== true) { - if (response && response.errors) { - const errorParts = []; - for (const groupKey in response.errors) { - if ( - !Object.prototype.hasOwnProperty.call( - response.errors, - groupKey, - ) - ) - continue; - const optionErrors = response.errors[groupKey]; - for (const optionKey in optionErrors) { - if ( - !Object.prototype.hasOwnProperty.call( - optionErrors, - optionKey, - ) - ) - continue; - const errorMsg = optionErrors[optionKey]; - errorParts.push(groupKey + "." + optionKey + ": " + errorMsg); - } - } - const errText = errorParts.length - ? errorParts.join("\n") - : "Validation failed."; - setStatus(errText, "#ff5c5c"); - } else { - const message = - response && response.error - ? response.error - : t("settings.saveError", "Failed to save settings."); - setStatus(message, "#ff5c5c"); - } - return; - } - - const newValues = - response && response.values && typeof response.values === "object" - ? response.values - : state.draft; - state.config.values = initialiseSettingsDraft({ - schema: state.config.schema, - values: newValues, - }); - state.draft = initialiseSettingsDraft({ - schema: state.config.schema, - values: newValues, - }); - - try { - if (window.__LuaToolsSettings) { - window.__LuaToolsSettings.values = JSON.parse( - JSON.stringify(state.config.values), - ); - window.__LuaToolsSettings.schemaVersion = - state.config.schemaVersion; - window.__LuaToolsSettings.lastFetched = Date.now(); - if ( - response && - response.translations && - typeof response.translations === "object" - ) { - window.__LuaToolsSettings.translations = response.translations; - } - if (response && response.language) { - window.__LuaToolsSettings.language = response.language; - } - } - } catch (_) { } - - // Invalidate the settings cache to force a fresh fetch on next settings load - // This ensures any changes persist across page navigations - try { - if (window.__LuaToolsSettings) { - window.__LuaToolsSettings.schema = null; - } - } catch (_) { } - - if ( - response && - response.translations && - typeof response.translations === "object" - ) { - applyTranslationBundle({ - language: - response.language || - (window.__LuaToolsI18n && window.__LuaToolsI18n.language) || - "en", - locales: - (window.__LuaToolsI18n && window.__LuaToolsI18n.locales) || - (state.config && state.config.locales) || - [], - strings: response.translations, - }); - applyStaticTranslations(); - updateButtonTranslations(); - } - - renderSettings(); - setStatus( - t("settings.saveSuccess", "Settings saved successfully."), - "#8bc34a", - ); - - // Reload theme if it changed - const oldTheme = state.config.values?.general?.theme; - const newTheme = state.draft?.general?.theme; - if (oldTheme !== newTheme) { - ensureLuaToolsStyles(); - } - }) - .catch(function (err) { - const message = - err && err.message - ? err.message - : t("settings.saveError", "Failed to save settings."); - setStatus(message, "#ff5c5c"); - }) - .finally(function () { - saveBtn.dataset.busy = "0"; - applyStaticTranslations(); - updateSaveState(); - }); - }); - - closeIconBtn.addEventListener("click", function (e) { - e.preventDefault(); - overlay.remove(); - }); - - discordIconBtn.addEventListener("click", function (e) { - e.preventDefault(); - const url = "https://discord.gg/luatools"; - try { - Millennium.callServerMethod("luatools", "OpenExternalUrl", { - url, - contentScriptQuery: "", - }); - } catch (_) { } - }); - - overlay.addEventListener("click", function (e) { - if (e.target === overlay) { - overlay.remove(); - } - }); - - handleLoad(!!forceRefresh); - } - - // Force-close any open settings overlays to avoid stacking - function closeSettingsOverlay() { - try { - // Remove all settings overlays (robust against older NodeList forEach support) - var list = document.getElementsByClassName("luatools-settings-overlay"); - while (list && list.length > 0) { - try { - list[0].remove(); - } catch (_) { - break; - } - } - // Also remove any download/progress overlays if present - var list2 = document.getElementsByClassName("luatools-overlay"); - while (list2 && list2.length > 0) { - try { - list2[0].remove(); - } catch (_) { - break; - } - } - } catch (_) { } - } - - // Custom modern alert dialog - function showLuaToolsAlert(title, message, onClose) { - if (document.querySelector(".luatools-alert-overlay")) return; - - ensureLuaToolsStyles(); - ensureFontAwesome(); - const overlay = document.createElement("div"); - overlay.className = "luatools-alert-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const alertModalColors = getThemeColors(); - modal.style.cssText = `background:${alertModalColors.modalBg};color:${alertModalColors.text};border:1px solid ${alertModalColors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${alertModalColors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const alertIconWrap = document.createElement("div"); - alertIconWrap.style.cssText = "text-align:center;margin-bottom:12px;"; - const alertIcon = document.createElement("i"); - alertIcon.className = "fa-solid fa-circle-info"; - alertIcon.style.cssText = `color:${alertModalColors.accent};font-size:32px;`; - alertIconWrap.appendChild(alertIcon); - - const titleEl = document.createElement("div"); - titleEl.style.cssText = `font-size:20px;color:${alertModalColors.text};margin-bottom:12px;font-weight:600;text-align:center;`; - titleEl.textContent = String(title || "LuaTools"); - - const messageEl = document.createElement("div"); - messageEl.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:24px;color:${alertModalColors.textSecondary};text-align:center;`; - messageEl.textContent = String(message || ""); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;justify-content:center;"; - - const okBtn = document.createElement("a"); - okBtn.href = "#"; - okBtn.className = "luatools-btn primary"; - okBtn.style.cssText = - "min-width:140px;display:flex;align-items:center;justify-content:center;text-align:center;"; - okBtn.innerHTML = `${lt("Close")}`; - okBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - try { - onClose && onClose(); - } catch (_) { } - }; - - btnRow.appendChild(okBtn); - - modal.appendChild(alertIconWrap); - modal.appendChild(titleEl); - modal.appendChild(messageEl); - modal.appendChild(btnRow); - overlay.appendChild(modal); - - overlay.addEventListener("click", function (e) { - if (e.target === overlay) { - overlay.remove(); - try { - onClose && onClose(); - } catch (_) { } - } - }); - - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - } - - // Helper to show alert with fallback - function ShowLuaToolsAlert(title, message) { - try { - showLuaToolsAlert(title, message); - } catch (err) { - backendLog("LuaTools: Alert error, falling back: " + err); - try { - alert(String(title) + "\n\n" + String(message)); - } catch (_) { } - } - } - - // Steam-style confirm helper (ShowConfirmDialog only) - function showLuaToolsConfirm(title, message, onConfirm, onCancel) { - // Always close settings popup first so the confirm is visible on top - closeSettingsOverlay(); - - // Create custom modern confirmation dialog - if (document.querySelector(".luatools-confirm-overlay")) return; - - ensureLuaToolsStyles(); - ensureFontAwesome(); - const overlay = document.createElement("div"); - overlay.className = "luatools-confirm-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const confirmColors = getThemeColors(); - modal.style.cssText = `background:${confirmColors.modalBg};color:${confirmColors.text};border:1px solid ${confirmColors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${confirmColors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const confirmIconWrap = document.createElement("div"); - confirmIconWrap.style.cssText = "text-align:center;margin-bottom:12px;"; - const confirmIcon = document.createElement("i"); - confirmIcon.className = "fa-solid fa-circle-question"; - confirmIcon.style.cssText = `color:${confirmColors.accent};font-size:32px;`; - confirmIconWrap.appendChild(confirmIcon); - - const titleEl = document.createElement("div"); - titleEl.style.cssText = `font-size:20px;color:${confirmColors.text};margin-bottom:12px;font-weight:600;text-align:center;`; - titleEl.textContent = String(title || "LuaTools"); - - const messageEl = document.createElement("div"); - messageEl.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:24px;color:${confirmColors.textSecondary};text-align:center;`; - messageEl.textContent = String(message || lt("Are you sure?")); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;gap:12px;justify-content:center;"; - - const cancelBtn = document.createElement("a"); - cancelBtn.href = "#"; - cancelBtn.className = "luatools-btn"; - cancelBtn.style.cssText = - "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; - cancelBtn.innerHTML = `${lt("Cancel")}`; - cancelBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - try { - onCancel && onCancel(); - } catch (_) { } - }; - const confirmBtn = document.createElement("a"); - confirmBtn.href = "#"; - confirmBtn.className = "luatools-btn primary"; - confirmBtn.style.cssText = - "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; - confirmBtn.innerHTML = `${lt("Confirm")}`; - confirmBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - try { - onConfirm && onConfirm(); - } catch (_) { } - }; - - btnRow.appendChild(cancelBtn); - btnRow.appendChild(confirmBtn); - - modal.appendChild(confirmIconWrap); - modal.appendChild(titleEl); - modal.appendChild(messageEl); - modal.appendChild(btnRow); - overlay.appendChild(modal); - - overlay.addEventListener("click", function (e) { - if (e.target === overlay) { - overlay.remove(); - try { - onCancel && onCancel(); - } catch (_) { } - } - }); - - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - } - - // DLC warning modal - function showDlcWarning(appid, fullgameAppid, fullgameName) { - // Close settings so modal is visible - closeSettingsOverlay(); - if (document.querySelector(".luatools-dlc-warning-overlay")) return; - - ensureLuaToolsStyles(); - ensureFontAwesome(); - - const overlay = document.createElement("div"); - overlay.className = "luatools-dlc-warning-overlay luatools-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const colors = getThemeColors(); - modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const header = document.createElement("div"); - header.style.cssText = "text-align:center;margin-bottom:16px;"; - const icon = document.createElement("i"); - icon.className = "fa-solid fa-circle-info"; - icon.style.cssText = `color:${colors.accent};font-size:32px;`; - header.appendChild(icon); - - const titleEl = document.createElement("div"); - titleEl.style.cssText = `font-size:20px;font-weight:600;text-align:center;margin-bottom:12px;color:${colors.text};`; - titleEl.textContent = lt("DLC Detected"); - - const messageEl = document.createElement("div"); - messageEl.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:24px;color:${colors.textSecondary};text-align:center;`; - messageEl.innerHTML = lt( - "DLCs are added together with the base game. To add fixes for this DLC, please go to the base game page:

{gameName}", - ).replace("{gameName}", fullgameName || lt("Base Game")); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;gap:12px;justify-content:center;"; - - const cancelBtn = document.createElement("a"); - cancelBtn.href = "#"; - cancelBtn.className = "luatools-btn"; - cancelBtn.style.cssText = - "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; - cancelBtn.innerHTML = `${lt("Cancel")}`; - cancelBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - }; - - const goBtn = document.createElement("a"); - goBtn.href = "https://store.steampowered.com/app/" + fullgameAppid; - goBtn.className = "luatools-btn primary"; - goBtn.style.cssText = - "flex:1.5;display:flex;align-items:center;justify-content:center;text-align:center;"; - goBtn.innerHTML = `${lt("Go to Base Game")}`; - goBtn.onclick = function (e) { - // Let the default link behavior happen (navigation) - // But we can also remove the overlay - setTimeout(() => overlay.remove(), 100); - }; - - btnRow.appendChild(cancelBtn); - btnRow.appendChild(goBtn); - - modal.appendChild(header); - modal.appendChild(titleEl); - modal.appendChild(messageEl); - modal.appendChild(btnRow); - overlay.appendChild(modal); - - overlay.addEventListener("click", function (e) { - if (e.target === overlay) overlay.remove(); - }); - - document.body.appendChild(overlay); - - setTimeout(function () { - if (window.GamepadNav) window.GamepadNav.scanElements(); - }, 150); - } - - function showLuaToolsPlayableWarning(message, onProceed, onCancel) { - // Close settings so modal is visible - closeSettingsOverlay(); - if (document.querySelector(".luatools-playable-warning-overlay")) return; - - ensureLuaToolsStyles(); - ensureFontAwesome(); - - const overlay = document.createElement("div"); - overlay.className = "luatools-playable-warning-overlay luatools-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const playableColors = getThemeColors(); - modal.style.cssText = `background:${playableColors.modalBg};color:${playableColors.text};border:1px solid ${playableColors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${playableColors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const header = document.createElement("div"); - header.style.cssText = - "display:flex;align-items:center;gap:12px;margin-bottom:14px;justify-content:center;"; - const icon = document.createElement("i"); - icon.className = "fa-solid fa-triangle-exclamation"; - icon.style.cssText = `color:${playableColors.accent};font-size:22px;`; - const titleEl = document.createElement("div"); - titleEl.style.cssText = `font-size:18px;font-weight:600;text-align:center;color:${playableColors.text};`; - titleEl.textContent = t("common.warning", "Warning"); - header.appendChild(icon); - header.appendChild(titleEl); - - const messageEl = document.createElement("div"); - messageEl.style.cssText = `font-size:14px;line-height:1.5;margin-bottom:20px;color:${playableColors.textSecondary};text-align:center;padding:0 6px;`; - messageEl.textContent = String( - message || - "This game may not work, support for it wont be given in our discord", - ); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;gap:12px;justify-content:center;"; - - const cancelBtn = document.createElement("a"); - cancelBtn.href = "#"; - cancelBtn.className = "luatools-btn"; - cancelBtn.style.cssText = - "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; - cancelBtn.innerHTML = `${lt("Cancel")}`; - cancelBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - try { - onCancel && onCancel(); - } catch (_) { } - }; - - const proceedBtn = document.createElement("a"); - proceedBtn.href = "#"; - proceedBtn.className = "luatools-btn primary"; - proceedBtn.style.cssText = - "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; - proceedBtn.innerHTML = `${lt("Proceed")}`; - proceedBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); - try { - onProceed && onProceed(); - } catch (_) { } - }; - - btnRow.appendChild(cancelBtn); - btnRow.appendChild(proceedBtn); - - modal.appendChild(header); - modal.appendChild(messageEl); - modal.appendChild(btnRow); - overlay.appendChild(modal); - - overlay.addEventListener("click", function (e) { - if (e.target === overlay) { - overlay.remove(); - try { - onCancel && onCancel(); - } catch (_) { } - } - }); - - document.body.appendChild(overlay); - - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - } - - // Millennium disclaimer modal - function showMillenniumDisclaimerModal() { - if (document.querySelector(".luatools-disclaimer-overlay")) return; - - ensureLuaToolsStyles(); - ensureFontAwesome(); - - const overlay = document.createElement("div"); - overlay.className = "luatools-disclaimer-overlay luatools-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100005;display:flex;align-items:center;justify-content:center;"; - - const modal = document.createElement("div"); - const disclaimerColors = getThemeColors(); - modal.style.cssText = `background:${disclaimerColors.modalBg};color:${disclaimerColors.text};border:1px solid ${disclaimerColors.border};border-radius:16px;width:460px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${disclaimerColors.shadowRgba};animation:slideUp 0.12s ease-out;`; - - const iconContainer = document.createElement("div"); - iconContainer.style.cssText = "text-align:center;margin-bottom:16px;"; - const icon = document.createElement("i"); - icon.className = "fa-solid fa-triangle-exclamation"; - icon.style.cssText = `color:#FFD54F;font-size:32px;`; - iconContainer.appendChild(icon); - - const titleEl = document.createElement("div"); - titleEl.style.cssText = `font-size:20px;font-weight:600;text-align:center;margin-bottom:16px;color:#FFD54F;`; - titleEl.textContent = t("disclaimer.title", "Quick Note"); - - const messageEl = document.createElement("div"); - messageEl.style.cssText = `font-size:13px;line-height:1.6;margin-bottom:20px;color:${disclaimerColors.textSecondary};text-align:center;`; - - const line1 = document.createElement("div"); - line1.style.cssText = `margin-bottom:8px;font-weight:500;color:${disclaimerColors.text};font-size:14px;`; - line1.textContent = t( - "disclaimer.line1", - "LuaTools is not affiliated with Millennium", - ); - - const line2 = document.createElement("div"); - line2.style.cssText = "margin-bottom:8px;"; - line2.textContent = t( - "disclaimer.line2", - "Millennium will not offer support for this plugin on their server", - ); - - const line3 = document.createElement("div"); - line3.style.cssText = `font-weight:500;color:#FFD54F;font-size:13px;`; - line3.textContent = t( - "disclaimer.line3", - "Please use our Discord for any questions — asking in Millennium servers may result in a ban", - ); - - messageEl.appendChild(line1); - messageEl.appendChild(line2); - messageEl.appendChild(line3); - - const inputGroup = document.createElement("div"); - inputGroup.style.cssText = "margin-bottom:16px;"; - - const inputLabel = document.createElement("div"); - inputLabel.style.cssText = `font-size:11px;color:${disclaimerColors.textSecondary};margin-bottom:8px;text-align:center;text-transform:uppercase;letter-spacing:1px;`; - inputLabel.textContent = t( - "disclaimer.inputLabel", - 'type "I Understand" in the box bellow to continue', - ); - - const input = document.createElement("input"); - input.type = "text"; - input.placeholder = t("disclaimer.inputPlaceholder", "I Understand"); - input.style.cssText = `width:100%;box-sizing:border-box;background:${disclaimerColors.bgTertiary};border:1px solid ${disclaimerColors.borderRgba};border-radius:10px;padding:10px 14px;color:${disclaimerColors.text};font-size:14px;outline:none;text-align:center;transition:all 0.2s ease;`; - input.onfocus = function () { - this.style.borderColor = disclaimerColors.accent; - this.style.boxShadow = `0 0 0 2px rgba(${disclaimerColors.rgbString},0.2)`; - }; - input.onblur = function () { - this.style.borderColor = disclaimerColors.borderRgba; - this.style.boxShadow = "none"; - }; - - inputGroup.appendChild(inputLabel); - inputGroup.appendChild(input); - - const btnRow = document.createElement("div"); - btnRow.style.cssText = "display:flex;justify-content:center;"; - - const confirmBtn = document.createElement("a"); - confirmBtn.href = "#"; - confirmBtn.className = "luatools-btn primary"; - confirmBtn.style.minWidth = "160px"; - confirmBtn.style.justifyContent = "center"; - confirmBtn.style.textAlign = "center"; - confirmBtn.style.display = "flex"; - confirmBtn.innerHTML = `${lt("Confirm")}`; - confirmBtn.style.opacity = "0.5"; - confirmBtn.style.pointerEvents = "none"; - - var expectedPhrase = t("disclaimer.inputPlaceholder", "I Understand") - .trim() - .toLowerCase(); - input.oninput = function () { - if (this.value.trim().toLowerCase() === expectedPhrase) { - confirmBtn.style.opacity = "1"; - confirmBtn.style.pointerEvents = "auto"; - confirmBtn.style.boxShadow = `0 4px 12px ${disclaimerColors.shadow}`; - } else { - confirmBtn.style.opacity = "0.5"; - confirmBtn.style.pointerEvents = "none"; - confirmBtn.style.boxShadow = "none"; - } - }; - - confirmBtn.onclick = function (e) { - e.preventDefault(); - if (input.value.trim().toLowerCase() === expectedPhrase) { - localStorage.setItem("luatools millennium disclaimer accepted", "1"); - overlay.remove(); - } - }; - - btnRow.appendChild(confirmBtn); - - modal.appendChild(iconContainer); - modal.appendChild(titleEl); - modal.appendChild(messageEl); - modal.appendChild(inputGroup); - modal.appendChild(btnRow); - overlay.appendChild(modal); - - document.body.appendChild(overlay); - - // Focus input after a short delay - setTimeout(() => input.focus(), 300); - - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - } - - // Ensure consistent spacing for our buttons - function ensureStyles() { - if (!document.getElementById("luatools-spacing-styles")) { - const style = document.createElement("style"); - style.id = "luatools-spacing-styles"; - style.textContent = ` - .luatools-restart-button { margin-left: 6px !important; margin-right: 6px !important; } - .luatools-button { margin-right: 0 !important; position: relative !important; } - .luatools-pills-container { - position: absolute !important; - top: -25px !important; - left: 50% !important; - transform: translateX(-50%) !important; - display: inline-flex; - gap: 4px; - align-items: center; - pointer-events: none; - z-index: 10; - white-space: nowrap; - } - .luatools-pill { - padding: 2px 6px; - border-radius: 4px; - font-size: 9px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - display: inline-flex; - align-items: center; - height: 16px; - line-height: 1; - box-shadow: 0 2px 4px rgba(0,0,0,0.2); - cursor: default; - } - .luatools-pill.red { background: rgba(255, 80, 80, 0.15); color: #ff5050; border: 1px solid rgba(255, 80, 80, 0.3); } - .luatools-pill.green { background: rgba(92, 184, 92, 0.15); color: #5cb85c; border: 1px solid rgba(92, 184, 92, 0.3); } - .luatools-pill.yellow { background: rgba(255, 193, 7, 0.15); color: #ffc107; border: 1px solid rgba(255, 193, 7, 0.3); } - .luatools-pill.orange { background: rgba(255, 136, 0, 0.15); color: #ff8800; border: 1px solid rgba(255, 136, 0, 0.3); } - .luatools-pill.gray { background: rgba(150, 150, 150, 0.15); color: #a0a0a0; border: 1px solid rgba(150, 150, 150, 0.3); } - `; - document.head.appendChild(style); // This is now separate from the main style block - } - } - - // Function to update button text with current translations - function updateButtonTranslations() { - try { - // Update Restart Steam button - const restartBtn = document.querySelector(".luatools-restart-button"); - if (restartBtn) { - const restartText = lt("Restart Steam"); - restartBtn.title = restartText; - restartBtn.setAttribute("data-tooltip-text", restartText); - const rspan = restartBtn.querySelector("span"); - if (rspan) { - rspan.textContent = restartText; - } - } - - // Update Add via LuaTools button - const luatoolsBtn = document.querySelector(".luatools-button"); - if (luatoolsBtn) { - const addViaText = lt("Add via LuaTools"); - luatoolsBtn.title = addViaText; - luatoolsBtn.setAttribute("data-tooltip-text", addViaText); - const span = luatoolsBtn.querySelector("span"); - if (span) { - span.textContent = addViaText; - } - } - } catch (err) { - backendLog("LuaTools: updateButtonTranslations error: " + err); - } - } - - // Function to add the LuaTools button - // Add throttle to prevent excessive executions - let lastButtonCheckTime = 0; - const BUTTON_CHECK_THROTTLE = 500; // Only run once every 500ms - - function addLuaToolsButton() { - // Throttle to prevent blocking gamepad input - const now = Date.now(); - if (now - lastButtonCheckTime < BUTTON_CHECK_THROTTLE) { - return; // Skip this execution, too soon - } - lastButtonCheckTime = now; - - // Track current URL to detect page changes - const currentUrl = window.location.href; - if (window.__LuaToolsLastUrl !== currentUrl) { - // Page changed - reset button insertion flag and update translations - window.__LuaToolsLastUrl = currentUrl; - window.__LuaToolsButtonInserted = false; - window.__LuaToolsRestartInserted = false; - window.__LuaToolsIconInserted = false; - window.__LuaToolsHeaderInserted = false; - window.__LuaToolsPresenceCheckInFlight = false; - window.__LuaToolsPresenceCheckAppId = undefined; - // Ensure translations are loaded and update existing buttons - ensureTranslationsLoaded(false).then(function () { - updateButtonTranslations(); - }); - } - - // Store Header Button Logic (always visible) - const headerContainer = document.querySelector("._1wn1lBlAzl3HMRqS1llwie"); - if ( - headerContainer && - !document.querySelector(".luatools-header-button") && - !window.__LuaToolsHeaderInserted - ) { - ensureLuaToolsStyles(); - const headerBtn = document.createElement("button"); - headerBtn.type = "button"; - headerBtn.className = "luatools-header-button Focusable"; - headerBtn.tabIndex = "0"; - headerBtn.title = "LuaTools Settings"; - headerBtn.setAttribute("data-tooltip-text", "LuaTools Settings"); - - const img = document.createElement("img"); - img.style.height = "18px"; - img.style.width = "18px"; - img.style.verticalAlign = "middle"; - - img.onerror = function () { - // cogwheel fallback - headerBtn.innerHTML = - ''; - }; - - img.src = "LuaTools/luatools-icon.png"; - - Millennium.callServerMethod("luatools", "GetIconDataUrl", {}) - .then(function (res) { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success && payload.dataUrl) { - img.src = payload.dataUrl; - } - }) - .catch(function () { }); - - headerBtn.appendChild(img); - - headerBtn.onclick = function (e) { - e.preventDefault(); - showSettingsPopup(); - }; - - headerContainer.appendChild(headerBtn); - window.__LuaToolsHeaderInserted = true; - backendLog("Inserted store header button"); - } - - // Check if we're in Big Picture mode - const isBigPicture = window.__LUATOOLS_IS_BIG_PICTURE__; - - // Look for the appropriate container based on mode - let targetContainer; - if (isBigPicture) { - // In Big Picture mode, use the queue button's parent as reference - const queueBtn = document.querySelector("#queueBtnFollow"); - targetContainer = queueBtn ? queueBtn.parentElement : null; - } else { - // In normal mode, use the SteamDB buttons container - targetContainer = - document.querySelector(".steamdb-buttons") || - document.querySelector("[data-steamdb-buttons]") || - document.querySelector(".apphub_OtherSiteInfo"); - } - - if (targetContainer) { - const steamdbContainer = targetContainer; - - // Insert a Restart Steam button between Community Hub and our LuaTools button - try { - if ( - !document.querySelector(".luatools-restart-button") && - !window.__LuaToolsRestartInserted - ) { - ensureStyles(); - // In Big Picture mode, use queue button as reference; otherwise use first link in container - const referenceBtn = isBigPicture - ? document.querySelector("#queueBtnFollow") - : steamdbContainer.querySelector("a"); - - // Use same custom button for both modes - const restartBtn = document.createElement("a"); - if (referenceBtn && referenceBtn.className) { - restartBtn.className = - referenceBtn.className + " luatools-restart-button"; - } else { - restartBtn.className = - "btnv6_blue_hoverfade btn_medium luatools-restart-button"; - } - restartBtn.href = "#"; - const restartText = lt("Restart Steam"); - restartBtn.title = restartText; - restartBtn.setAttribute("data-tooltip-text", restartText); - const rspan = document.createElement("span"); - rspan.textContent = restartText; - restartBtn.appendChild(rspan); - - // Normalize margins to match native buttons - try { - if (referenceBtn) { - const cs = window.getComputedStyle(referenceBtn); - restartBtn.style.marginLeft = cs.marginLeft; - restartBtn.style.marginRight = cs.marginRight; - } - } catch (_) { } - - restartBtn.addEventListener("click", function (e) { - e.preventDefault(); - try { - // Ensure any settings overlays are closed before confirm - closeSettingsOverlay(); - askRestartConfirmation(); - } catch (_) { - askRestartConfirmation(); - } - }); - - if (referenceBtn && referenceBtn.parentElement) { - referenceBtn.after(restartBtn); - } else { - steamdbContainer.appendChild(restartBtn); - } - window.__LuaToolsRestartInserted = true; - backendLog("Inserted Restart Steam button"); - } - } catch (_) { } - - // Status Pills Logic - // Always update translations for existing buttons (even if not a page change) - const existingBtn = document.querySelector(".luatools-button"); - if (existingBtn) { - ensureTranslationsLoaded(false).then(function () { - updateButtonTranslations(); - }); - } - - // Check if button already exists to avoid duplicates - if (!existingBtn && !window.__LuaToolsButtonInserted) { - // Create the LuaTools button modeled after existing SteamDB/PCGW buttons - // In Big Picture mode, use queue button as reference; otherwise use first link in container - let referenceBtn = isBigPicture - ? document.querySelector("#queueBtnFollow") - : steamdbContainer.querySelector("a"); - - // Use same custom button for both modes - const luatoolsButton = document.createElement("a"); - luatoolsButton.href = "#"; - // Copy classes from an existing button to match look-and-feel, but set our own label - if (referenceBtn && referenceBtn.className) { - luatoolsButton.className = - referenceBtn.className + " luatools-button"; - } else { - luatoolsButton.className = - "btnv6_blue_hoverfade btn_medium luatools-button"; - } - const span = document.createElement("span"); - const addViaText = lt("Add via LuaTools"); - span.textContent = addViaText; - luatoolsButton.appendChild(span); - // Tooltip/title - luatoolsButton.title = addViaText; - luatoolsButton.setAttribute("data-tooltip-text", addViaText); - - // Normalize margins to match native buttons - try { - if (referenceBtn) { - const cs = window.getComputedStyle(referenceBtn); - luatoolsButton.style.marginLeft = cs.marginLeft; - luatoolsButton.style.marginRight = cs.marginRight; - } - } catch (_) { } - - // Local click handler suppressed; delegated handler manages actions - luatoolsButton.addEventListener("click", function (e) { - e.preventDefault(); - backendLog( - "LuaTools button clicked (delegated handler will process)", - ); - }); - - // Before inserting, ask backend if LuaTools already exists for this appid - try { - const match = - window.location.href.match( - /https:\/\/store\.steampowered\.com\/app\/(\d+)/, - ) || - window.location.href.match( - /https:\/\/steamcommunity\.com\/app\/(\d+)/, - ); - const appid = match ? parseInt(match[1], 10) : NaN; - if ( - !isNaN(appid) && - typeof Millennium !== "undefined" && - typeof Millennium.callServerMethod === "function" - ) { - // prevent multiple concurrent checks - if ( - window.__LuaToolsPresenceCheckInFlight && - window.__LuaToolsPresenceCheckAppId === appid - ) { - return; - } - window.__LuaToolsPresenceCheckInFlight = true; - window.__LuaToolsPresenceCheckAppId = appid; - window.__LuaToolsCurrentAppId = appid; - Millennium.callServerMethod("luatools", "HasLuaToolsForApp", { - appid, - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.success && payload.exists === true) { - backendLog( - "LuaTools already present for this app; not inserting button", - ); - window.__LuaToolsPresenceCheckInFlight = false; - return; // do not insert - } - // Re-check in case another caller inserted during async - if ( - !document.querySelector(".luatools-button") && - !window.__LuaToolsButtonInserted - ) { - // Insert after restart button (order: Restart → Add) - const restartExisting = steamdbContainer.querySelector( - ".luatools-restart-button", - ); - if (restartExisting && restartExisting.after) { - restartExisting.after(luatoolsButton); - } else if (referenceBtn && referenceBtn.after) { - referenceBtn.after(luatoolsButton); - } else { - steamdbContainer.appendChild(luatoolsButton); - } - window.__LuaToolsButtonInserted = true; - backendLog("LuaTools button inserted"); - } - window.__LuaToolsPresenceCheckInFlight = false; - } catch (_) { - if ( - !document.querySelector(".luatools-button") && - !window.__LuaToolsButtonInserted - ) { - steamdbContainer.appendChild(luatoolsButton); - window.__LuaToolsButtonInserted = true; - backendLog("LuaTools button inserted"); - } - window.__LuaToolsPresenceCheckInFlight = false; - } - }); - } else { - if ( - !document.querySelector(".luatools-button") && - !window.__LuaToolsButtonInserted - ) { - // Insert after restart button (order: Restart → Add) - const restartExisting = steamdbContainer.querySelector( - ".luatools-restart-button", - ); - if (restartExisting && restartExisting.after) { - restartExisting.after(luatoolsButton); - } else if (referenceBtn && referenceBtn.after) { - referenceBtn.after(luatoolsButton); - } else { - steamdbContainer.appendChild(luatoolsButton); - } - window.__LuaToolsButtonInserted = true; - backendLog("LuaTools button inserted"); - } - } - } catch (_) { - if ( - !document.querySelector(".luatools-button") && - !window.__LuaToolsButtonInserted - ) { - const restartExisting = steamdbContainer.querySelector( - ".luatools-restart-button", - ); - if (restartExisting && restartExisting.after) { - restartExisting.after(luatoolsButton); - } else if (referenceBtn && referenceBtn.after) { - referenceBtn.after(luatoolsButton); - } else { - steamdbContainer.appendChild(luatoolsButton); - } - window.__LuaToolsButtonInserted = true; - backendLog("LuaTools button inserted"); - } - } - } - - // status pills — only run once per appid - try { - const match = - window.location.href.match( - /https:\/\/store\.steampowered\.com\/app\/(\d+)/, - ) || - window.location.href.match( - /https:\/\/steamcommunity\.com\/app\/(\d+)/, - ); - const appid = match - ? parseInt(match[1], 10) - : window.__LuaToolsCurrentAppId || NaN; - - if (!isNaN(appid)) { - const pillBtn = steamdbContainer.querySelector(".luatools-button"); - if (pillBtn) { - // Skip if pills already built for this appid - var existingPills = pillBtn.querySelector( - ".luatools-pills-container", - ); - if ( - !( - existingPills && - existingPills.dataset.appid === String(appid) && - existingPills.dataset.content - ) - ) { - fetchGamesDatabase().then(function (db) { - const btn = steamdbContainer.querySelector(".luatools-button"); - if (!btn) return; - - let pillsContainer = btn.querySelector( - ".luatools-pills-container", - ); - - if (!pillsContainer) { - pillsContainer = document.createElement("div"); - pillsContainer.className = "luatools-pills-container"; - btn.appendChild(pillsContainer); - } - pillsContainer.dataset.appid = String(appid); - - const key = String(appid); - const gameData = db && db[key] ? db[key] : null; - - // check denuvo - const drmNotice = document.querySelector(".DRM_notice"); - const hasDenuvo = - drmNotice && drmNotice.textContent.includes("Denuvo"); - - fetchFixes(appid).then(function (fixesData) { - const hasFixes = - fixesData && - ((fixesData.genericFix && - fixesData.genericFix.status === 200) || - (fixesData.onlineFix && - fixesData.onlineFix.status === 200)); - const showDenuvoPill = hasDenuvo && !hasFixes; - - const cacheKey = JSON.stringify({ - d: gameData || "untested", - showDenuvo: showDenuvoPill, - hasFixes: hasFixes, - }); - - if (pillsContainer.dataset.content === cacheKey) return; - pillsContainer.dataset.content = cacheKey; - - pillsContainer.innerHTML = ""; - - let status = "untested"; - if (gameData && typeof gameData.playable !== "undefined") { - if (gameData.playable === 1) status = "playable"; - else if (gameData.playable === 0) status = "unplayable"; - else if (gameData.playable === 2) status = "needs_fixes"; - } - - if (status === "untested" && hasFixes) { - status = "needs_fixes"; - } - - if (status !== "untested") { - const pill = document.createElement("span"); - pill.className = "luatools-pill"; - if (status === "playable") { - pill.classList.add("green"); - pill.textContent = t("gameStatus.playable", "Playable"); - } else if (status === "unplayable") { - pill.classList.add("red"); - pill.textContent = t( - "gameStatus.unplayable", - "Unplayable", - ); - } else if (status === "needs_fixes") { - pill.classList.add("yellow"); - pill.textContent = t( - "gameStatus.needsFixes", - "Needs fixes", - ); - } - pillsContainer.appendChild(pill); - } - - // reset button state - const btn = - steamdbContainer.querySelector(".luatools-button"); - if (btn) { - btn.style.opacity = ""; - btn.style.pointerEvents = ""; - btn.style.cursor = ""; - const span = btn.querySelector("span"); - if (span && span.textContent === "Unplayable") { - span.textContent = lt("Add via LuaTools"); - } - } - - if (showDenuvoPill) { - const pill = document.createElement("span"); - pill.className = "luatools-pill orange"; - pill.textContent = t("gameStatus.denuvo", "Denuvo"); - pillsContainer.appendChild(pill); - } - }); - }); - } - } - } - } catch (e) { - /* ignore */ - } - } else { - if (!logState.missingOnce) { - backendLog("LuaTools: steamdbContainer not found on this page"); - logState.missingOnce = true; - } - } - } - - // Try to add the button immediately if DOM is ready - function onFrontendReady() { - // Fetch settings + translations FIRST, then insert the button once in the correct language - try { - fetchSettingsConfig(true) - .then(function (cfg) { - try { - ensureLuaToolsStyles(); - } catch (_) { } - - // Show disclaimer after translations are loaded so it displays in the correct language - try { - if (window.location.hostname === "store.steampowered.com") { - if ( - localStorage.getItem( - "luatools millennium disclaimer accepted", - ) !== "1" - ) { - showMillenniumDisclaimerModal(); - } - } - } catch (_) { } - - // Now translations are ready — insert the button in the correct language - addLuaToolsButton(); - }) - .catch(function (_) { - // Settings failed, still insert button (English fallback) - addLuaToolsButton(); - }); - } catch (_) { - addLuaToolsButton(); - } - - // Show gamepad hint if connected (only in Big Picture mode) - setTimeout(function () { - if ( - window.GamepadNav && - window.GamepadNav.isConnected && - window.GamepadNav.isConnected() - ) { - backendLog("[LuaTools] Gamepad detected - Navigation enabled"); - - // Only show visual hint in Big Picture mode - if (window.__LUATOOLS_IS_BIG_PICTURE__) { - const hint = document.createElement("div"); - hint.id = "luatools-gamepad-hint"; - hint.innerHTML = "🎮 " + lt("bigpicture.mouseTip"); - hint.style.cssText = - "\ - position: fixed;\ - bottom: 20px;\ - right: 20px;\ - background: rgba(11, 20, 30, 0.9);\ - color: #66c0f4;\ - padding: 12px 16px;\ - border-radius: 8px;\ - font-size: 14px;\ - z-index: 99998;\ - border: 1px solid rgba(102, 192, 244, 0.3);\ - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);\ - animation: fadeInOut 3s ease-in-out;\ - "; - - // Add CSS animation if not already present - if (!document.querySelector("#luatools-gamepad-hint-styles")) { - const style = document.createElement("style"); - style.id = "luatools-gamepad-hint-styles"; - style.textContent = - "\ - @keyframes fadeInOut {\ - 0% { opacity: 0; transform: translateY(10px); }\ - 10% { opacity: 1; transform: translateY(0); }\ - 90% { opacity: 1; transform: translateY(0); }\ - 100% { opacity: 0; transform: translateY(10px); }\ - }\ - "; - document.head.appendChild(style); - } - - document.body.appendChild(hint); - - // Auto-remove after animation - setTimeout(function () { - if (hint && hint.parentElement) { - hint.remove(); - } - }, 3000); - } - } - }, 500); - - // Ask backend if there is a queued startup message from InitApis - try { - if ( - typeof Millennium !== "undefined" && - typeof Millennium.callServerMethod === "function" - ) { - Millennium.callServerMethod("luatools", "GetInitApisMessage", { - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - if (payload && payload.message) { - const msg = String(payload.message); - // Check if this is an update message (contains "update" or "restart") - const isUpdateMsg = - msg.toLowerCase().includes("update") || - msg.toLowerCase().includes("restart"); - - if (isUpdateMsg) { - // For update messages, use confirm dialog with OK (restart) and Cancel options - askRestartConfirmation(); - } else { - // For non-update messages, use regular alert - ShowLuaToolsAlert("LuaTools", msg); - } - } - } catch (_) { } - }); - // Also show loaded apps list if present (only once per session, store page only) - try { - if (window.location.hostname === "store.steampowered.com") { - if (!sessionStorage.getItem("LuaToolsLoadedAppsGate")) { - sessionStorage.setItem("LuaToolsLoadedAppsGate", "1"); - Millennium.callServerMethod("luatools", "ReadLoadedApps", { - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = - typeof res === "string" ? JSON.parse(res) : res; - const apps = - payload && payload.success && Array.isArray(payload.apps) - ? payload.apps - : []; - if (apps.length > 0) { - showLoadedAppsPopup(apps); - } - } catch (_) { } - }); - } - } - } catch (_) { } - } - } catch (_) { } - } - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", onFrontendReady); - } else { - onFrontendReady(); - } - - // Delegate click handling in case the DOM is re-rendered and listeners are lost - // Use bubble phase instead of capture phase to avoid interfering with gamepad navigation - document.addEventListener( - "click", - function (evt) { - // Quick exit if target doesn't have closest method or isn't an element - if (!evt.target || !evt.target.closest) return; - - const anchor = evt.target.closest(".luatools-button"); - if (anchor) { - evt.preventDefault(); - evt.stopPropagation(); // Stop propagation to avoid conflicts - backendLog("LuaTools delegated click"); - try { - const match = - window.location.href.match( - /https:\/\/store\.steampowered\.com\/app\/(\d+)/, - ) || - window.location.href.match( - /https:\/\/steamcommunity\.com\/app\/(\d+)/, - ); - const appid = match ? parseInt(match[1], 10) : NaN; - if ( - !isNaN(appid) && - typeof Millennium !== "undefined" && - typeof Millennium.callServerMethod === "function" - ) { - if (runState.inProgress && runState.appid === appid) { - backendLog( - "LuaTools: operation already in progress for this appid", - ); - return; - } - - // Helper that continues with the multi-API check flow - const continueWithAdd = function () { - // Open the loading popup first to show "Searching..." - showTestPopup(); - const overlay = document.querySelector(".luatools-overlay"); - const status = overlay - ? overlay.querySelector(".luatools-status") - : null; - const apiList = overlay - ? overlay.querySelector(".luatools-api-list") - : null; - - if (status) - status.textContent = lt("Searching across sources..."); - - Millennium.callServerMethod("luatools", "CheckApisForApp", { - appid, - contentScriptQuery: "", - }) - .then(function (res) { - try { - const payload = - typeof res === "string" ? JSON.parse(res) : res; - if (!payload || !payload.success) { - throw new Error(payload.error || "Check failed"); - } - - const results = payload.results || []; - const available = results.filter((r) => r.available); - - if (available.length === 0) { - const msg = lt("Game not found on any available API."); - if (status) status.textContent = msg; - const hideBtn = overlay - ? overlay.querySelector(".luatools-hide-btn") - : null; - if (hideBtn) - hideBtn.innerHTML = "" + lt("Close") + ""; - return; - } - - let isFastDownload = true; // default - try { - if ( - window.__LuaToolsSettings && - window.__LuaToolsSettings.values && - window.__LuaToolsSettings.values.general - ) { - if ( - typeof window.__LuaToolsSettings.values.general - .fastDownload !== "undefined" - ) { - isFastDownload = - window.__LuaToolsSettings.values.general - .fastDownload; - } - } - } catch (e) { } - - if (available.length === 1 || isFastDownload) { - // Only one source or fast download enabled, proceed automatically with the first available - const source = available[0]; - backendLog( - "LuaTools: Auto-selecting " + - (available.length === 1 - ? "only source" - : "source via fast download") + - ": " + - source.name, - ); - startDirectDownload(appid, available, 0); - } else { - // Multiple sources, let user select - showSourceSelectionModal(appid, available); - } - } catch (err) { - backendLog("LuaTools: CheckApisForApp error: " + err); - if (status) - status.textContent = lt("Error: {error}").replace( - "{error}", - err.message, - ); - } - }) - .catch(function (err) { - backendLog("LuaTools: CheckApisForApp promise error: " + err); - }); - }; - - const startDirectDownload = function (appid, availableSources, index = 0) { - const source = availableSources[index]; - const url = source.url; - const apiName = source.name; - - const performDownload = function () { - runState.inProgress = true; - runState.appid = appid; - - // If the selection modal was open, it should be replaced by showTestPopup or updated - const overlay = document.querySelector(".luatools-overlay"); - if (overlay) { - // Reset for progress - const status = overlay.querySelector(".luatools-status"); - if (status) { - if (index > 0) { - status.textContent = lt("Failed on {previous}. Trying {current}...").replace("{previous}", availableSources[index-1].name).replace("{current}", apiName); - } else { - status.textContent = lt("Initializing download..."); - } - } - const progressWrap = overlay.querySelector( - ".luatools-progress-wrap", - ); - if (progressWrap) progressWrap.style.display = "block"; - const progressInfo = overlay.querySelector( - ".luatools-progress-info", - ); - if (progressInfo) progressInfo.style.display = "block"; - const cancelBtn = overlay.querySelector( - ".luatools-cancel-btn", - ); - if (cancelBtn) cancelBtn.style.display = "flex"; - } else { - showTestPopup(); - } - - Millennium.callServerMethod( - "luatools", - "StartAddViaLuaToolsFromUrl", - { - appid, - url, - apiName, - contentScriptQuery: "", - }, - ); - - const onFailedCallback = function(errMsg) { - if (index + 1 < availableSources.length) { - backendLog("LuaTools: Fast download failed on " + apiName + " (" + errMsg + "). Trying next API: " + availableSources[index+1].name); - setTimeout(function() { - startDirectDownload(appid, availableSources, index + 1); - }, 1500); - } - }; - - startPolling(appid, onFailedCallback); - }; - - if (apiName && apiName.toLowerCase().includes("morrenus")) { - let hubcapKey = ""; - try { - if ( - window.__LuaToolsSettings && - window.__LuaToolsSettings.values && - window.__LuaToolsSettings.values.advanced - ) { - hubcapKey = - window.__LuaToolsSettings.values.advanced - .morrenusApiKey || ""; - } - if (!hubcapKey) { - for (const group in window.__LuaToolsSettings.values) { - if ( - window.__LuaToolsSettings.values[group] && - window.__LuaToolsSettings.values[group].morrenusApiKey - ) { - hubcapKey = - window.__LuaToolsSettings.values[group] - .morrenusApiKey; - break; - } - } - } - } catch (e) { } - - if (hubcapKey && /^smm_[0-9a-f]{96}$/.test(hubcapKey)) { - // Wait, check the limits - showTestPopup(); // Ensures basic loading modal is up - const overlay = document.querySelector(".luatools-overlay"); - if (overlay) { - const status = overlay.querySelector(".luatools-status"); - if (status) - status.textContent = lt("Verifying API limits..."); - const cancelBtn = overlay.querySelector( - ".luatools-cancel-btn", - ); - if (cancelBtn) cancelBtn.style.display = "none"; - } - - Millennium.callServerMethod("luatools", "GetMorrenusStats", { - api_key: hubcapKey, - force_refresh: true, - contentScriptQuery: "", - }) - .then((r) => (typeof r === "string" ? JSON.parse(r) : r)) - .then((res) => { - if ( - res && - res.detail === "API key not found or expired" - ) { - // 401 - invalid or expired key - showLuaToolsPlayableWarning( - lt( - "Your Morrenus API key is invalid or expired. Please check your key in the settings or regenerate it on the Morrenus website.", - ), - function () { - showSettingsManagerPopup(false, null); - }, - null, - ); - runState.inProgress = false; - } else if ( - res && - typeof res.detail === "string" && - res.detail.startsWith("Daily limit reached") - ) { - // 429 - daily limit exhausted - showLuaToolsPlayableWarning( - lt( - "You have exceeded your daily download limit. Please wait until tomorrow for more uses, or upgrade your plan on the Morrenus website.", - ), - function () { - showSettingsManagerPopup(false, null); - }, - null, - ); - runState.inProgress = false; - } else if ( - res && - typeof res.daily_usage !== "undefined" && - typeof res.daily_limit !== "undefined" && - res.daily_usage >= res.daily_limit - ) { - // usage fields show limit reached (fallback) - showLuaToolsPlayableWarning( - lt( - "You have exceeded your daily download limit. Please wait until tomorrow for more uses, or upgrade your plan on the Morrenus website.", - ), - function () { - showSettingsManagerPopup(false, null); - }, - null, - ); - runState.inProgress = false; - } else { - performDownload(); - } - }) - .catch((e) => { - backendLog( - "LuaTools: Error checking Morrenus API limit: " + e, - ); - // Network error or other, try to proceed and let the backend error it if needed - performDownload(); - }); - return; // yield execution to async fetch - } - } - - // Normal flow if not Morrenus or no key present - performDownload(); - }; - - function showSourceSelectionModal(appid, available) { - const overlay = document.querySelector(".luatools-overlay"); - if (!overlay) return; - - const colors = getThemeColors(); - const title = overlay.querySelector(".luatools-title"); - const status = overlay.querySelector(".luatools-status"); - const apiList = overlay.querySelector(".luatools-api-list"); - - if (title) title.textContent = lt("Select Download Source"); - if (status) status.style.display = "none"; // Remove "Multiple sources found" text - - if (apiList) { - apiList.innerHTML = ""; - apiList.style.cssText = - "display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:16px;"; - - available.forEach((source) => { - const btn = document.createElement("a"); - btn.href = "#"; - btn.className = "luatools-btn focusable"; - btn.style.cssText = `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;flex:1;min-width:80px;padding:12px 8px;background:rgba(${colors.rgbString},0.06);border:1px solid ${colors.borderRgba};border-radius:12px;text-decoration:none;transition:all 0.2s ease;text-align:center;`; - - const srcIcon = document.createElement("i"); - srcIcon.className = "fa-solid fa-server"; - srcIcon.style.cssText = `font-size:18px;color:${colors.accent};`; - - const name = document.createElement("div"); - name.style.cssText = `font-size:11px; font-weight:500; color:${colors.text};line-height:1.2;`; - name.textContent = source.name; - - btn.appendChild(srcIcon); - btn.appendChild(name); - - btn.onmouseover = function () { - this.style.background = `rgba(${colors.rgbString},0.25)`; - this.style.borderColor = colors.accent; - this.style.transform = "translateY(-1px)"; - }; - btn.onmouseout = function () { - this.style.background = `rgba(${colors.rgbString},0.1)`; - this.style.borderColor = colors.borderRgba; - this.style.transform = "translateY(0)"; - }; - - btn.onclick = function (e) { - e.preventDefault(); - apiList.style.display = "block"; // Reset layout for progress - apiList.style.flexDirection = ""; - apiList.innerHTML = ""; // Clear selection buttons - if (status) status.style.display = ""; // Restore status text - startDirectDownload(appid, [source], 0); - }; - - apiList.appendChild(btn); - }); - } - - // Update Cancel button: show it, hide the Hide/Close button, and make it close the modal - const cancelBtn = overlay.querySelector(".luatools-cancel-btn"); - const hideBtn = overlay.querySelector(".luatools-hide-btn"); - - if (cancelBtn) { - cancelBtn.style.display = "flex"; - cancelBtn.innerHTML = `${lt("Cancel")}`; - cancelBtn.onclick = function (e) { - e.preventDefault(); - overlay.remove(); // Close modal immediately - }; - } - - if (hideBtn) { - hideBtn.style.display = "none"; // Remove "Hide" button as per request - } - - // Re-scan for gamepad - if (window.GamepadNav) window.GamepadNav.scanElements(); - } - - // Check if this is a dlc - const isdlc = !!document.querySelector(".game_area_dlc_bubble"); - const parentdiv = document.querySelector( - '.glance_details a[href*="/app/"]', - ); - - if (isdlc && parentdiv) { - const id = parseInt( - parentdiv.href.match(/app\/(\d+)\//)?.[1] ?? "", - ); - const name = parentdiv.innerText ?? "name not found"; - - showDlcWarning(appid, id, name); - } else { - // Not a dlc (or failed) ? Then continue normally - return fetchGamesDatabase().then(function (db) { - try { - const gameData = db?.[String(appid)] ?? null; - if (gameData?.playable === 0) { - // warning modal - showLuaToolsPlayableWarning( - "This game may not work, support for it wont be given in our discord", - function () { - continueWithAdd(); - }, - function () { }, - ); - } else { - continueWithAdd(); - } - } catch (_) { - continueWithAdd(); - } - }); - } - } - } catch (_) { } - } - }, - false, - ); // Changed from true to false (bubble phase instead of capture phase) - - // Poll backend for progress and update progress bar and text - function startPolling(appid, onFailedCallback) { - let done = false; - let lastCheckedApi = null; - let successfulApi = null; // Track which API successfully found the file - const timer = setInterval(() => { - if (done) { - clearInterval(timer); - return; - } - try { - Millennium.callServerMethod("luatools", "GetAddViaLuaToolsStatus", { - appid, - contentScriptQuery: "", - }).then(function (res) { - try { - const payload = typeof res === "string" ? JSON.parse(res) : res; - const st = payload && payload.state ? payload.state : {}; - - // Try to find overlay (may or may not be visible) - const overlay = document.querySelector(".luatools-overlay"); - const title = overlay - ? overlay.querySelector(".luatools-title") - : null; - const status = overlay - ? overlay.querySelector(".luatools-status") - : null; - const wrap = overlay - ? overlay.querySelector(".luatools-progress-wrap") - : null; - const progressInfo = overlay - ? overlay.querySelector(".luatools-progress-info") - : null; - const percent = overlay - ? overlay.querySelector(".luatools-percent") - : null; - const downloadSize = overlay - ? overlay.querySelector(".luatools-download-size") - : null; - const bar = overlay - ? overlay.querySelector(".luatools-progress-bar") - : null; - - // Update individual API status in the list - if (overlay) { - const colors = getThemeColors(); - const apiItems = overlay.querySelectorAll(".luatools-api-item"); - - // Track successful API when download/processing starts - if ( - (st.status === "downloading" || - st.status === "processing" || - st.status === "installing" || - st.status === "done") && - st.currentApi && - !successfulApi - ) { - successfulApi = st.currentApi; - - // Mark all APIs: not found before successful, skipped after - let foundSuccessful = false; - apiItems.forEach((item) => { - const apiName = item.getAttribute("data-api-name"); - const apiStatus = item.querySelector(".luatools-api-status"); - if (!apiStatus) return; - - if (apiName === successfulApi) { - foundSuccessful = true; - item.style.background = `rgba(${colors.rgbString},0.2)`; - item.style.borderColor = colors.accent; - apiStatus.innerHTML = `${lt("Found")}`; - } else if (!foundSuccessful) { - // This API comes before the successful one, check if it has an error first - if (st.apiErrors && st.apiErrors[apiName]) { - const apiError = st.apiErrors[apiName]; - item.style.background = `rgba(255, 0, 0, 0.15)`; - item.style.borderColor = "#ff5c5c"; - if (apiError.type === "timeout") { - apiStatus.innerHTML = `${lt("Error, Timed Out")}`; - } else if (apiError.type === "error") { - const code = apiError.code ? String(apiError.code) : ""; - apiStatus.innerHTML = `${lt("Error, Code: {code}").replace("{code}", code)}`; - } - } else { - // Mark as not found - item.style.background = `rgba(0,0,0,0.2)`; - item.style.borderColor = colors.borderRgba; - apiStatus.innerHTML = `${lt("Not found")}`; - } - } else { - // This API comes after the successful one, mark as skipped - item.style.background = `rgba(0,0,0,0.15)`; - item.style.borderColor = colors.borderRgba; - apiStatus.innerHTML = `${lt("Skipped")}`; - } - }); - } - - // Mark previous API as not found if we moved to a new one (only during checking phase) - if ( - st.status === "checking" && - st.currentApi && - st.currentApi !== lastCheckedApi && - lastCheckedApi - ) { - apiItems.forEach((item) => { - const apiName = item.getAttribute("data-api-name"); - const apiStatus = item.querySelector(".luatools-api-status"); - if (!apiStatus) return; - - if (apiName === lastCheckedApi) { - item.style.background = `rgba(0,0,0,0.2)`; - item.style.borderColor = colors.borderRgba; - apiStatus.innerHTML = `${lt("Not found")}`; - } - }); - } - - // Update current API status during checking - if (st.status === "checking" && st.currentApi) { - apiItems.forEach((item) => { - const apiName = item.getAttribute("data-api-name"); - const apiStatus = item.querySelector(".luatools-api-status"); - if (!apiStatus) return; - - if (apiName === st.currentApi) { - item.style.background = `rgba(${colors.rgbString},0.15)`; - item.style.borderColor = colors.accent; - apiStatus.innerHTML = `${lt("Checking…")}`; - } - }); - - lastCheckedApi = st.currentApi; - } - - // Show error statuses for APIs that errored (when not checking them anymore) - if (st.apiErrors && typeof st.apiErrors === "object") { - apiItems.forEach((item) => { - const apiName = item.getAttribute("data-api-name"); - const apiStatus = item.querySelector(".luatools-api-status"); - if (!apiStatus || !apiName) return; - - const apiError = st.apiErrors[apiName]; - if (!apiError) return; - - // Only show error if this API is not currently being checked - if (st.currentApi === apiName && st.status === "checking") - return; - - // Don't overwrite "Found" status - const statusText = apiStatus.textContent || ""; - if ( - statusText.includes("Found") || - statusText.includes("Encontrado") - ) - return; - - item.style.background = `rgba(255, 0, 0, 0.15)`; - item.style.borderColor = "#ff5c5c"; - - if (apiError.type === "timeout") { - apiStatus.innerHTML = `${lt("Error, Timed Out")}`; - } else if (apiError.type === "error") { - const code = apiError.code ? String(apiError.code) : ""; - apiStatus.innerHTML = `${lt("Error, Code: {code}").replace("{code}", code)}`; - } - }); - } - } - - // Update UI if overlay is present - if (st.status === "checking" && st.currentApi && title) { - title.textContent = lt("LuaTools · {api}").replace( - "{api}", - st.currentApi, - ); - } else if ( - (st.status === "downloading" || - st.status === "processing" || - st.status === "installing") && - title - ) { - title.textContent = t("common.appName", "LuaTools"); - } - - if (status) { - const spinner = - ''; - const dlIcon = - ''; - const gearIcon = - ''; - - if (st.status === "checking") - status.innerHTML = - spinner + "" + lt("Checking availability…") + ""; - if (st.status === "downloading") - status.innerHTML = - dlIcon + "" + lt("Downloading…") + ""; - if (st.status === "processing") - status.innerHTML = - gearIcon + "" + lt("Processing package…") + ""; - if (st.status === "installing") - status.innerHTML = - gearIcon + "" + lt("Installing…") + ""; - if (st.status === "checking content") - status.innerHTML = - spinner + "" + lt("Checking content…") + ""; - if (st.status === "failed") - status.innerHTML = - '' + - lt("Failed") + - ""; - } - if ( - ["downloading", "processing", "installing"].includes(st.status) - ) { - // reveal progress UI (if overlay visible) - if (wrap && wrap.style.display === "none") - wrap.style.display = "block"; - if (progressInfo && progressInfo.style.display === "none") { - progressInfo.style.display = "flex"; - progressInfo.style.justifyContent = "space-between"; - } - - const total = st.totalBytes || 0; - const read = st.bytesRead || 0; - let pct = - total > 0 ? Math.floor((read / total) * 100) : read ? 1 : 0; - if (pct > 100) pct = 100; - if (pct < 0) pct = 0; - - // Update bar and percentage - if (bar) bar.style.width = pct + "%"; - if (percent) percent.textContent = pct + "%"; - - // Format file sizes (only if we have size data) - if (downloadSize) { - if (total > 0) { - const formatBytes = (bytes) => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return ( - Math.round((bytes / Math.pow(k, i)) * 100) / 100 + - " " + - sizes[i] - ); - }; - downloadSize.textContent = - formatBytes(read) + " / " + formatBytes(total); - } else { - downloadSize.textContent = ""; - } - } - // Show Cancel button during download - const cancelBtn = overlay - ? overlay.querySelector(".luatools-cancel-btn") - : null; - if (cancelBtn && st.status === "downloading") - cancelBtn.style.display = ""; - } - - if (["checking content", "done"].includes(st.status)) { - // Update popup if visible - if (title) title.textContent = t("common.appName", "LuaTools"); - if (bar) bar.style.width = "100%"; - if (percent) percent.textContent = "100%"; - - // hide progress visuals after a short beat - if (wrap || progressInfo) { - setTimeout(function () { - if (wrap) wrap.style.display = "none"; - if (progressInfo) progressInfo.style.display = "none"; - }, 300); - } - - // Hide Cancel button - const cancelBtn = overlay - ? overlay.querySelector(".luatools-cancel-btn") - : null; - if (cancelBtn) cancelBtn.style.display = "none"; - } - - if (st.status === "done") { - // Update popup if visible - if (overlay) { - const doneColors = getThemeColors(); - // Hide API list for clean look - const apiList = overlay.querySelector(".luatools-api-list"); - if (apiList) apiList.style.display = "none"; - // Hide progress - if (wrap) wrap.style.display = "none"; - if (progressInfo) progressInfo.style.display = "none"; - // Hide cancel - const cancelBtn = overlay.querySelector(".luatools-cancel-btn"); - if (cancelBtn) cancelBtn.style.display = "none"; - - // Update title with success icon - if (title) { - title.innerHTML = ""; - title.style.cssText = `display:flex;align-items:center;justify-content:center;gap:10px;font-size:20px;color:${doneColors.text};margin-bottom:12px;font-weight:600;`; - const checkIcon = document.createElement("i"); - checkIcon.className = "fa-solid fa-circle-check"; - checkIcon.style.cssText = `color:${doneColors.accent};font-size:24px;`; - const checkText = document.createElement("span"); - checkText.textContent = lt("Game Added!"); - title.appendChild(checkIcon); - title.appendChild(checkText); - } - - // Build status content - if (status) { - const result = st.contentCheckResult; - status.style.textAlign = "center"; - - if (!result) { - status.innerText = lt( - "The game has been added successfully.", - ); - } else { - const status_content = [ - lt("Content details =>"), - `\u00A0\u00A0• ${lt("Workshop: ")}${lt(result.workshop)}`, - ]; - if ( - result.dlc.missing.length || - result.dlc.included.length - ) { - status_content.push(`\u00A0\u00A0• ${lt("Dlc: ")}`); - if (result.dlc.included.length > 0) { - status_content.push( - `\u00A0\u00A0\u00A0\u00A0◦ ${lt("Included")}: ${result.dlc.included.length}`, - ); - } - if (result.dlc.missing.length > 0) { - const missingLinks = result.dlc.missing - .map( - (id) => - `${id}`, - ) - .join(", "); - status_content.push( - `\u00A0\u00A0\u00A0\u00A0◦ ${lt("Missing")}: ${result.dlc.missing.length} (${missingLinks})`, - ); - } - } - status.style.whiteSpace = "pre-line"; - status.innerHTML = status_content.join("\n"); - status - .querySelectorAll(".lt-dlc-link") - .forEach(function (link) { - link.addEventListener("click", function (e) { - e.preventDefault(); - try { - Millennium.callServerMethod( - "luatools", - "OpenExternalUrl", - { - url: - "https://steamdb.info/app/" + - link.dataset.dlcId + - "/", - contentScriptQuery: "", - }, - ); - } catch (_) { } - }); - }); - } - } - - // Update Hide button to styled Close - const hideBtn = overlay.querySelector(".luatools-hide-btn"); - if (hideBtn) { - hideBtn.className = "luatools-btn primary luatools-hide-btn"; - hideBtn.style.cssText = - "min-width:140px;display:flex;align-items:center;justify-content:center;text-align:center;"; - hideBtn.innerHTML = - '' + - lt("Close") + - ""; - } - } - done = true; - clearInterval(timer); - runState.inProgress = false; - runState.appid = null; - // Remove button since game is added (works even if popup is hidden) - const btnEl = document.querySelector(".luatools-button"); - if (btnEl && btnEl.parentElement) { - btnEl.parentElement.removeChild(btnEl); - } - } - if (st.status === "failed") { - // Mark all APIs as not found when failed (unless they have error status) - if (overlay && !successfulApi) { - const colors = getThemeColors(); - const apiItems = overlay.querySelectorAll(".luatools-api-item"); - apiItems.forEach((item) => { - const apiName = item.getAttribute("data-api-name"); - const apiStatus = item.querySelector(".luatools-api-status"); - if (!apiStatus) return; - - // Skip if this API already has an error status - if (st.apiErrors && st.apiErrors[apiName]) { - const apiError = st.apiErrors[apiName]; - item.style.background = `rgba(255, 0, 0, 0.15)`; - item.style.borderColor = "#ff5c5c"; - if (apiError.type === "timeout") { - apiStatus.innerHTML = `${lt("Error, Timed Out")}`; - } else if (apiError.type === "error") { - const code = apiError.code ? String(apiError.code) : ""; - apiStatus.innerHTML = `${lt("Error, Code: {code}").replace("{code}", code)}`; - } - return; - } - - // Check if this API is still in "Waiting..." or "Checking..." state - const statusText = apiStatus.textContent || ""; - if ( - statusText.includes("Waiting") || - statusText.includes("Esperando") || - statusText.includes("Checking") || - statusText.includes("Verificando") - ) { - item.style.background = `rgba(0,0,0,0.2)`; - item.style.borderColor = colors.borderRgba; - apiStatus.innerHTML = `${lt("Not found")}`; - } - }); - } - - // show error in the popup if visible - if (status) - status.textContent = lt("Failed: {error}").replace( - "{error}", - st.error || lt("Unknown error"), - ); - // Hide Cancel button and update Hide to Close - const cancelBtn = overlay - ? overlay.querySelector(".luatools-cancel-btn") - : null; - if (cancelBtn) cancelBtn.style.display = "none"; - const hideBtn = overlay - ? overlay.querySelector(".luatools-hide-btn") - : null; - if (hideBtn) - hideBtn.innerHTML = "" + lt("Close") + ""; - if (wrap) wrap.style.display = "none"; - if (progressInfo) progressInfo.style.display = "none"; - done = true; - clearInterval(timer); - runState.inProgress = false; - runState.appid = null; - - if (onFailedCallback) { - onFailedCallback(st.error || "Unknown error"); - } - } - } catch (_) { } - }); - } catch (_) { - clearInterval(timer); - } - }, 300); - } - - // Also try after a delay to catch dynamically loaded content - setTimeout(addLuaToolsButton, 1000); - setTimeout(addLuaToolsButton, 3000); - - // Listen for URL changes (Steam uses pushState for navigation) - let lastUrl = window.location.href; - - function checkUrlChange() { - const currentUrl = window.location.href; - if (currentUrl !== lastUrl) { - lastUrl = currentUrl; - // URL changed - reset flags and update buttons - window.__LuaToolsButtonInserted = false; - window.__LuaToolsRestartInserted = false; - window.__LuaToolsIconInserted = false; - window.__LuaToolsHeaderInserted = false; - - window.__LuaToolsPresenceCheckInFlight = false; - window.__LuaToolsPresenceCheckAppId = undefined; - // Update translations and re-add buttons - ensureTranslationsLoaded(false).then(function () { - updateButtonTranslations(); - addLuaToolsButton(); - }); - } - } - // Check URL changes periodically and on popstate - // Reduced frequency to avoid blocking gamepad input - setInterval(checkUrlChange, 2000); // Changed from 500ms to 2000ms (2 seconds) - window.addEventListener("popstate", checkUrlChange); - // Override pushState/replaceState to detect navigation - const originalPushState = history.pushState; - const originalReplaceState = history.replaceState; - history.pushState = function () { - originalPushState.apply(history, arguments); - setTimeout(checkUrlChange, 100); - }; - history.replaceState = function () { - originalReplaceState.apply(history, arguments); - setTimeout(checkUrlChange, 100); - }; - - // Pre-fetch settings quietly to ensure background values (like fastDownload) are populated immediately, - // and apply themes immediately once settings load. - function bootSettings() { - if (typeof Millennium === "undefined" || typeof Millennium.callServerMethod !== "function") { - setTimeout(bootSettings, 200); - return; - } - Promise.all([ - loadThemes(), - fetchSettingsConfig() - ]).then(function() { - if (typeof ensureLuaToolsStyles === "function") ensureLuaToolsStyles(); - }).catch(function(e) { - try { backendLog("LuaTools: Boot fetchSettingsConfig failed: " + String(e)); } catch(_) {} - }); - } - bootSettings(); - - // Use MutationObserver to catch dynamically added content - // Heavily optimized and throttled version to avoid blocking gamepad - if (typeof MutationObserver !== "undefined") { - let mutationTimeout; - let lastMutationProcessTime = 0; - const MUTATION_THROTTLE = 1000; // Only process once per second - - const observer = new MutationObserver(function (mutations) { - // Additional throttle on top of debounce - const now = Date.now(); - if (now - lastMutationProcessTime < MUTATION_THROTTLE) { - return; // Skip if processed recently - } - - // Debounce mutations to avoid blocking the UI - clearTimeout(mutationTimeout); - mutationTimeout = setTimeout(function () { - lastMutationProcessTime = Date.now(); - - let shouldUpdate = false; - // Quick check: only process first 10 mutations to avoid long loops - const mutationsToCheck = Math.min(mutations.length, 10); - - for (let i = 0; i < mutationsToCheck; i++) { - const mutation = mutations[i]; - if (mutation.type === "childList" && mutation.addedNodes.length > 0) { - // Only check first 3 added nodes to avoid blocking - const nodesToCheck = Math.min(mutation.addedNodes.length, 3); - - for (let j = 0; j < nodesToCheck; j++) { - const node = mutation.addedNodes[j]; - if (node.nodeType === 1) { - // Element node - // Quick class check without querySelector (faster) - if ( - node.classList && - (node.classList.contains("steamdb-buttons") || - node.classList.contains("apphub_OtherSiteInfo") || - node.id === "queueBtnFollow") - ) { - shouldUpdate = true; - break; - } - } - } - } - if (shouldUpdate) break; - } - - if (shouldUpdate) { - updateButtonTranslations(); - addLuaToolsButton(); - } - }, 300); // Increased debounce to 300ms - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); - } - - function showLoadedAppsPopup(apps) { - // Avoid duplicates - if (document.querySelector(".luatools-loadedapps-overlay")) return; - ensureFontAwesome(); - ensureLuaToolsStyles(); - const overlay = document.createElement("div"); - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease-out;"; - overlay.className = "luatools-loadedapps-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease-out;"; - overlay.className = "luatools-loadedapps-overlay"; - overlay.style.cssText = - "position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;"; - const modal = document.createElement("div"); - const loadedAppsModalColors = getThemeColors(); - modal.style.cssText = `background:${loadedAppsModalColors.modalBg};color:${loadedAppsModalColors.text};border:2px solid ${loadedAppsModalColors.border};border-radius:8px;width:560px;padding:28px 32px;box-shadow:0 20px 60px rgba(0,0,0,.8), 0 0 0 1px ${loadedAppsModalColors.shadowRgba};animation:slideUp 0.1s ease-out;`; - const title = document.createElement("div"); - const loadedAppsTitleColors = getThemeColors(); - title.style.cssText = `font-size:24px;color:${loadedAppsTitleColors.text};margin-bottom:20px;font-weight:700;text-shadow:0 2px 8px ${loadedAppsTitleColors.shadow};background:${loadedAppsTitleColors.gradientLight};-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-align:center;`; - title.textContent = lt("LuaTools · Added Games"); - const body = document.createElement("div"); - const loadedAppsBodyColors = getThemeColors(); - body.style.cssText = `font-size:14px;line-height:1.8;margin-bottom:16px;max-height:320px;overflow:auto;padding:16px;border:1px solid ${loadedAppsBodyColors.border};border-radius:12px;background:${loadedAppsBodyColors.bgContainer};`; - if (apps && apps.length) { - const list = document.createElement("div"); - apps.forEach(function (item) { - const a = document.createElement("a"); - a.href = "steam://install/" + String(item.appid); - a.textContent = String(item.name || item.appid); - const linkColors = getThemeColors(); - a.style.cssText = `display:block;color:${linkColors.textSecondary};text-decoration:none;padding:10px 16px;margin-bottom:8px;background:rgba(${linkColors.rgbString},0.08);border:1px solid rgba(${linkColors.rgbString},0.2);border-radius:4px;transition:all 0.3s ease;`; - a.onmouseover = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.2)`; - this.style.borderColor = c.accent; - this.style.transform = "translateX(4px)"; - this.style.color = c.text; - }; - a.onmouseout = function () { - const c = getThemeColors(); - this.style.background = `rgba(${c.rgbString},0.08)`; - this.style.borderColor = `rgba(${c.rgbString},0.2)`; - this.style.transform = "translateX(0)"; - this.style.color = c.textSecondary; - }; - a.onclick = function (e) { - e.preventDefault(); - try { - window.location.href = a.href; - } catch (_) { } - }; - a.oncontextmenu = function (e) { - e.preventDefault(); - const url = "https://steamdb.info/app/" + String(item.appid) + "/"; - try { - Millennium.callServerMethod("luatools", "OpenExternalUrl", { - url, - contentScriptQuery: "", - }); - } catch (_) { } - }; - list.appendChild(a); - }); - body.appendChild(list); - } else { - body.style.textAlign = "center"; - body.textContent = lt("No games found."); - } - const btnRow = document.createElement("div"); - btnRow.style.cssText = - "margin-top:16px;display:flex;gap:8px;justify-content:space-between;align-items:center;"; - const instructionText = document.createElement("div"); - instructionText.style.cssText = "font-size:12px;color:#8f98a0;"; - instructionText.textContent = lt( - "Left click to install, Right click for SteamDB", - ); - const dismissBtn = document.createElement("a"); - dismissBtn.className = "luatools-btn"; - dismissBtn.innerHTML = "" + lt("Dismiss") + ""; - dismissBtn.href = "#"; - dismissBtn.onclick = function (e) { - e.preventDefault(); - try { - Millennium.callServerMethod("luatools", "DismissLoadedApps", { - contentScriptQuery: "", - }); - } catch (_) { } - try { - sessionStorage.setItem("LuaToolsLoadedAppsShown", "1"); - } catch (_) { } - overlay.remove(); - }; - btnRow.appendChild(instructionText); - btnRow.appendChild(dismissBtn); - modal.appendChild(title); - modal.appendChild(body); - modal.appendChild(btnRow); - overlay.appendChild(modal); - overlay.addEventListener("click", function (e) { - if (e.target === overlay) overlay.remove(); - }); - document.body.appendChild(overlay); - - // Re-scan elements for gamepad navigation - setTimeout(function () { - if (window.GamepadNav) { - window.GamepadNav.scanElements(); - } - }, 150); - } - - // ============================================ - // GAMEPAD NAVIGATION INTEGRATION - // ============================================ - // Note: The gamepad back handler is configured in the gamepad system at the top of this file - // It already handles all overlay types automatically using OVERLAY_SELECTOR_STRING -})(); +// LuaTools button injection (standalone plugin) + +// ============================================ +// GAMEPAD NAVIGATION SYSTEM - Inline Version +// ============================================ +(function () { + "use strict"; + + // Inject gamepad navigation CSS + const gamepadCSS = document.createElement("style"); + gamepadCSS.id = "gamepad-navigation-styles"; + gamepadCSS.textContent = ` + .active-focus { + outline: 3px solid #66c0f4 !important; + outline-offset: 2px !important; + box-shadow: 0 0 0 4px rgba(102, 192, 244, 0.3), + 0 0 12px rgba(102, 192, 244, 0.5) !important; + position: relative !important; + z-index: 9999 !important; + transition: outline 0.15s ease, box-shadow 0.15s ease !important; + } + + @keyframes gamepad-focus-pulse { + 0%, 100% { + box-shadow: 0 0 0 4px rgba(102, 192, 244, 0.3), + 0 0 12px rgba(102, 192, 244, 0.5); + } + 50% { + box-shadow: 0 0 0 4px rgba(102, 192, 244, 0.5), + 0 0 16px rgba(102, 192, 244, 0.7); + } + } + + .active-focus { + animation: gamepad-focus-pulse 1.5s ease-in-out infinite; + } + + button.active-focus, + a.active-focus { + background-color: rgba(102, 192, 244, 0.15) !important; + transform: scale(1.02); + } + + .BasicUI .active-focus, + .touch .active-focus { + outline-width: 4px !important; + outline-offset: 3px !important; + } + + input.active-focus, + select.active-focus, + textarea.active-focus { + border-color: #66c0f4 !important; + background-color: rgba(102, 192, 244, 0.1) !important; + } + + .active-focus:focus { + outline: 3px solid #66c0f4 !important; + } + + button, + a, + input, + select, + textarea, + .focusable { + transition: transform 0.15s ease, background-color 0.15s ease !important; + } + + .luatools-button.active-focus, + .luatools-restart-button.active-focus { + transform: scale(1.05) !important; + background: linear-gradient(135deg, rgba(102, 192, 244, 0.3), rgba(102, 192, 244, 0.2)) !important; + } + + .btnv6_blue_hoverfade.active-focus { + background: linear-gradient(to right, #47bfff 5%, #1a9fff 95%) !important; + } + + .active-focus { + scroll-margin: 20px; + } + `; + document.head.appendChild(gamepadCSS); + + // Gamepad Navigation System + // ALL LuaTools overlays that should block Steam navigation + const OVERLAY_SELECTORS = [ + ".luatools-overlay", + ".luatools-settings-overlay", + ".luatools-fixes-results-overlay", + ".luatools-loading-fixes-overlay", + ".luatools-unfix-overlay", + ".luatools-settings-manager-overlay", + ".luatools-alert-overlay", + ".luatools-confirm-overlay", + ".luatools-loadedapps-overlay", + ]; + const OVERLAY_SELECTOR_STRING = OVERLAY_SELECTORS.join(", "); + + const CONFIG = { + deadzone: 0.4, // Increased from 0.3 to prevent unwanted drift + debounceTime: 200, + pollRate: 16, + stickThreshold: 0.7, // Increased threshold for stick navigation + buttonMap: { + A: 0, + B: 1, + X: 2, + Y: 3, + LB: 4, + RB: 5, + LT: 6, + RT: 7, + SELECT: 8, + START: 9, + L3: 10, + R3: 11, + DPAD_UP: 12, + DPAD_DOWN: 13, + DPAD_LEFT: 14, + DPAD_RIGHT: 15, + }, + axesMap: { + LEFT_STICK_X: 0, + LEFT_STICK_Y: 1, + RIGHT_STICK_X: 2, + RIGHT_STICK_Y: 3, + }, + }; + + const state = { + gamepadConnected: false, + gamepadIndex: null, + focusableElements: [], + currentFocusIndex: 0, + lastNavigationTime: 0, + lastAxisValues: { + x: 0, + y: 0, + }, + buttonStates: {}, + animationFrameId: null, + }; + + // duplicated from main code thing for reliability + function isBigPictureMode() { + if (typeof window.__LUATOOLS_IS_BIG_PICTURE__ !== "undefined") { + return window.__LUATOOLS_IS_BIG_PICTURE__; + } + const htmlClasses = document.documentElement.className; + const userAgent = navigator.userAgent; + let score = 0; + if (htmlClasses.includes("BasicUI")) score += 3; + if (htmlClasses.includes("DesktopUI")) score -= 3; + if (userAgent.includes("Valve Steam Gamepad")) score += 2; + if (userAgent.includes("Valve Steam Client")) score -= 2; + if (htmlClasses.includes("touch")) score += 1; + return score > 0; + } + + // B button handler removed - users should use the modal buttons directly + // This prevents conflicts with Steam's back navigation + let onBackHandler = function () { + console.log( + "[Gamepad] B button pressed - ignoring (use modal buttons instead)", + ); + // Do nothing - let users navigate with D-pad/stick and press A on Cancel/Back buttons + }; + + function onGamepadConnected(event) { + console.log("[Gamepad] Gamepad conectado en Millennium:", event.gamepad.id); + state.gamepadConnected = true; + state.gamepadIndex = event.gamepad.index; + if (!state.animationFrameId) { + pollGamepad(); + } + // Don't scan immediately - only scan when an overlay is opened + // scanFocusableElements() will be called by the overlay's setTimeout + } + + function onGamepadDisconnected(event) { + console.log("[Gamepad] Gamepad disconnected:", event.gamepad.id); + if (state.gamepadIndex === event.gamepad.index) { + state.gamepadConnected = false; + state.gamepadIndex = null; + if (state.animationFrameId) { + cancelAnimationFrame(state.animationFrameId); + state.animationFrameId = null; + } + } + } + + function scanFocusableElements() { + if (!isBigPictureMode()) return; + + // Only scan if there's a LuaTools overlay active + const activeOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); + + if (!activeOverlay) { + console.log("[Gamepad] No LuaTools overlay active, skipping scan"); + state.focusableElements = []; + state.currentFocusIndex = 0; + return; + } + + // Only scan elements INSIDE the active overlay + const selectors = [ + "button:not([disabled])", + "a[href]:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex="0"]', + '[tabindex]:not([tabindex="-1"])', + ".focusable:not([disabled])", + ].join(", "); + + // Use querySelectorAll on the overlay, not the whole document + const elements = Array.from(activeOverlay.querySelectorAll(selectors)); + state.focusableElements = elements.filter(function (el) { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" + ); + }); + + console.log( + "[Gamepad] Scanned " + + state.focusableElements.length + + " focusable elements inside overlay", + ); + + if (state.focusableElements.length > 0) { + focusElement(0); + } + } + + function focusElement(index) { + const prevElement = state.focusableElements[state.currentFocusIndex]; + if (prevElement) { + prevElement.blur(); + prevElement.classList.remove("active-focus"); + } + + if (index < 0) index = 0; + if (index >= state.focusableElements.length) + index = state.focusableElements.length - 1; + + state.currentFocusIndex = index; + + const element = state.focusableElements[index]; + if (element) { + element.focus(); + element.classList.add("active-focus"); + element.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + console.log("[Gamepad] Focused element " + index + ":", element); + } + } + + function navigate(direction) { + const now = Date.now(); + if (now - state.lastNavigationTime < CONFIG.debounceTime) { + return; + } + state.lastNavigationTime = now; + + if (state.focusableElements.length === 0) { + scanFocusableElements(); + return; + } + + let newIndex = state.currentFocusIndex; + + switch (direction) { + case "up": + newIndex--; + break; + case "down": + newIndex++; + break; + case "left": + newIndex = findElementInDirection("left"); + break; + case "right": + newIndex = findElementInDirection("right"); + break; + } + + if (newIndex < 0) newIndex = state.focusableElements.length - 1; + if (newIndex >= state.focusableElements.length) newIndex = 0; + + focusElement(newIndex); + } + + function findElementInDirection(direction) { + const currentElement = state.focusableElements[state.currentFocusIndex]; + if (!currentElement) return state.currentFocusIndex; + + const currentRect = currentElement.getBoundingClientRect(); + let closestIndex = state.currentFocusIndex; + let closestDistance = Infinity; + + state.focusableElements.forEach(function (el, index) { + if (index === state.currentFocusIndex) return; + + const rect = el.getBoundingClientRect(); + let isInDirection = false; + let distance = 0; + + if (direction === "left") { + isInDirection = rect.right <= currentRect.left; + distance = currentRect.left - rect.right; + } else if (direction === "right") { + isInDirection = rect.left >= currentRect.right; + distance = rect.left - currentRect.right; + } + + if (isInDirection && distance < closestDistance) { + closestDistance = distance; + closestIndex = index; + } + }); + + return closestIndex; + } + + function handleButtonPress(buttonIndex) { + const element = state.focusableElements[state.currentFocusIndex]; + + switch (buttonIndex) { + case CONFIG.buttonMap.A: + if (element) { + console.log("[Gamepad] A button: clicking element", element); + element.click(); + setTimeout(scanFocusableElements, 100); + } + break; + + case CONFIG.buttonMap.B: + // B button disabled - users should use modal buttons + console.log("[Gamepad] B button pressed - ignoring"); + break; + + case CONFIG.buttonMap.DPAD_UP: + navigate("up"); + break; + + case CONFIG.buttonMap.DPAD_DOWN: + navigate("down"); + break; + + case CONFIG.buttonMap.DPAD_LEFT: + navigate("left"); + break; + + case CONFIG.buttonMap.DPAD_RIGHT: + navigate("right"); + break; + } + } + + function pollGamepad() { + if (!state.gamepadConnected) { + state.animationFrameId = null; + return; + } + + // Check if there's an active LuaTools overlay + const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); + + // If no overlay is active, skip input processing but keep polling + if (!hasActiveOverlay) { + state.animationFrameId = requestAnimationFrame(pollGamepad); + return; + } + + const gamepads = navigator.getGamepads(); + const gamepad = gamepads[state.gamepadIndex]; + + if (!gamepad) { + state.animationFrameId = requestAnimationFrame(pollGamepad); + return; + } + + // Buttons + gamepad.buttons.forEach(function (button, index) { + const wasPressed = state.buttonStates[index] || false; + const isPressed = button.pressed; + + if (isPressed && !wasPressed) { + handleButtonPress(index); + } + + state.buttonStates[index] = isPressed; + }); + + // Left stick + const axisX = gamepad.axes[CONFIG.axesMap.LEFT_STICK_X] || 0; + const axisY = gamepad.axes[CONFIG.axesMap.LEFT_STICK_Y] || 0; + + const x = Math.abs(axisX) > CONFIG.deadzone ? axisX : 0; + const y = Math.abs(axisY) > CONFIG.deadzone ? axisY : 0; + + const now = Date.now(); + const threshold = CONFIG.stickThreshold; // Use higher threshold (0.7) + if (now - state.lastNavigationTime >= CONFIG.debounceTime) { + if (y < -threshold && state.lastAxisValues.y >= -threshold) { + navigate("up"); + } else if (y > threshold && state.lastAxisValues.y <= threshold) { + navigate("down"); + } else if (x < -threshold && state.lastAxisValues.x >= -threshold) { + navigate("left"); + } else if (x > threshold && state.lastAxisValues.x <= threshold) { + navigate("right"); + } + } + + state.lastAxisValues.x = x; + state.lastAxisValues.y = y; + + state.animationFrameId = requestAnimationFrame(pollGamepad); + } + + // Disabled: MutationObserver was causing unwanted auto-scanning + // Only manual scanElements() calls from overlay setTimeout will trigger scans + /* + const observer = new MutationObserver(function(mutations) { + clearTimeout(observer.rescanTimeout); + observer.rescanTimeout = setTimeout(function() { + if (state.gamepadConnected) { + scanFocusableElements(); + } + }, 300); + }); + */ + + // Block Steam's gamepad navigation when overlay is active + function blockSteamNavigation(event) { + const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); + + if (hasActiveOverlay && state.gamepadConnected) { + // Block arrow keys, Enter, Escape, Backspace and other navigation keys + // Note: Steam may translate gamepad B button to Escape or Backspace + const navKeys = [ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Enter", + "Escape", + "Backspace", + " ", + "Tab", + ]; + if (navKeys.includes(event.key)) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + console.log("[Gamepad] Blocked Steam navigation key:", event.key); + return false; + } + } + } + + // Block clicks on Steam UI when overlay is active + function blockSteamClicks(event) { + const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); + + if (hasActiveOverlay && state.gamepadConnected) { + // Only allow clicks inside the overlay + const clickedInsideOverlay = event.target.closest( + OVERLAY_SELECTOR_STRING, + ); + + if (!clickedInsideOverlay) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + console.log("[Gamepad] Blocked click outside overlay"); + return false; + } + } + } + + // Block browser history navigation when overlay is active + function blockHistoryNavigation(event) { + const hasActiveOverlay = document.querySelector(OVERLAY_SELECTOR_STRING); + if (hasActiveOverlay && state.gamepadConnected) { + console.log("[Gamepad] Blocked history navigation (popstate)"); + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + // Push the current state back to prevent navigation + window.history.pushState(null, "", window.location.href); + return false; + } + } + + function init() { + if (!isBigPictureMode()) { + console.log("[Gamepad] Not in Big Picture Mode, skipping initialization"); + return; + } + + console.log("[Gamepad] Initializing Gamepad Navigation System..."); + + window.addEventListener("gamepadconnected", onGamepadConnected); + window.addEventListener("gamepaddisconnected", onGamepadDisconnected); + + // Block Steam's keyboard navigation when overlay is active + document.addEventListener("keydown", blockSteamNavigation, true); + document.addEventListener("keyup", blockSteamNavigation, true); + + // Block clicks outside overlay when gamepad is active + document.addEventListener("click", blockSteamClicks, true); + document.addEventListener("mousedown", blockSteamClicks, true); + + // Block browser history navigation (back button) + window.addEventListener("popstate", blockHistoryNavigation, true); + + const gamepads = navigator.getGamepads(); + for (let i = 0; i < gamepads.length; i++) { + if (gamepads[i]) { + onGamepadConnected({ + gamepad: gamepads[i], + }); + break; + } + } + + // Disabled: MutationObserver auto-scanning + /* + observer.observe(document.body, { + childList: true, + subtree: true + }); + */ + + // Don't scan on init - only scan when overlays are opened + // scanFocusableElements(); + + console.log("[Gamepad] Initialization complete"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + window.GamepadNav = { + scanElements: scanFocusableElements, + setBackHandler: function (fn) { + if (typeof fn === "function") { + onBackHandler = fn; + } + }, + focusElement: focusElement, + getCurrentIndex: function () { + return state.currentFocusIndex; + }, + getElements: function () { + return state.focusableElements; + }, + isConnected: function () { + return state.gamepadConnected; + }, + }; +})(); + +// ============================================ +// LUATOOLS MAIN CODE +// ============================================ +(function () { + "use strict"; + + // Big Picture Mode Detector - Multi-method system for maximum reliability + function isBigPictureMode() { + const htmlClasses = document.documentElement.className; + const userAgent = navigator.userAgent; + + // METHOD 1: HTML Classes + // Big Picture: 'BasicUI' + 'touch' + // Normal Mode: 'DesktopUI' (without 'touch') + const hasBigPictureClass = htmlClasses.includes("BasicUI"); + const hasDesktopClass = htmlClasses.includes("DesktopUI"); + const hasTouchClass = htmlClasses.includes("touch"); + + // METHOD 2: User Agent + // Big Picture: 'Valve Steam Gamepad' + // Normal Mode: 'Valve Steam Client' + const isGamepadUA = userAgent.includes("Valve Steam Gamepad"); + const isClientUA = userAgent.includes("Valve Steam Client"); + + // Scoring system: each indicator adds points + let bigPictureScore = 0; + + // BasicUI/DesktopUI class (weight: 3 points - highly reliable) + if (hasBigPictureClass) bigPictureScore += 3; + if (hasDesktopClass) bigPictureScore -= 3; + + // User Agent (weight: 2 points - reliable) + if (isGamepadUA) bigPictureScore += 2; + if (isClientUA) bigPictureScore -= 2; + + // Touch class (weight: 1 point - additional indicator) + if (hasTouchClass) bigPictureScore += 1; + + // Positive score = Big Picture, negative/zero = Normal + const isBigPicture = bigPictureScore > 0; + + return isBigPicture; + } + + // Detect and save mode at startup + window.__LUATOOLS_IS_BIG_PICTURE__ = isBigPictureMode(); + + // Forward logs to Millennium backend so they appear in the dev console + function backendLog(message) { + try { + if ( + typeof Millennium !== "undefined" && + typeof Millennium.callServerMethod === "function" + ) { + Millennium.callServerMethod("luatools", "Logger.log", { + message: String(message), + }); + } + } catch (err) { + if (typeof console !== "undefined" && console.warn) { + console.warn("[LuaTools] backendLog failed", err); + } + } + } + + backendLog("LuaTools script loaded"); + backendLog( + "Mode Detection: " + + (window.__LUATOOLS_IS_BIG_PICTURE__ ? "BIG PICTURE MODE" : "NORMAL MODE"), + ); + // anti-spam state + const logState = { + missingOnce: false, + existsOnce: false, + }; + // click/run debounce state + const runState = { + inProgress: false, + appid: null, + }; + + // Games Database - backend handles caching + function fetchGamesDatabase() { + if ( + typeof Millennium === "undefined" || + typeof Millennium.callServerMethod !== "function" + ) { + return Promise.resolve({}); + } + return Millennium.callServerMethod("luatools", "GetGamesDatabase", { + contentScriptQuery: "", + }) + .then(function (res) { + var payload = (res && (res.result || res.value)) || res; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch (e) {} + } + return payload || {}; + }) + .catch(function (err) { + console.warn("[LuaTools] Failed to fetch games database", err); + return {}; + }); + } + + // Fixes - backend handles caching + function fetchFixes(appid) { + if ( + typeof Millennium === "undefined" || + typeof Millennium.callServerMethod !== "function" + ) { + return Promise.resolve(null); + } + return Millennium.callServerMethod("luatools", "CheckForFixes", { + appid: appid, + contentScriptQuery: "", + }) + .then(function (res) { + const payload = typeof res === "string" ? JSON.parse(res) : res; + return payload && payload.success ? payload : null; + }) + .catch(function (err) { + console.warn("[LuaTools] Failed to fetch fixes", err); + return null; + }); + } + + // Cache for game names fetched from Steam API + const steamGameNameCache = {}; + + /** + * get game name separately without cached full appid + * @param {number|string} appid + * @returns {Promise} + */ + function fetchSteamGameName(appid) { + if (!appid) return Promise.resolve(null); + if (steamGameNameCache[appid]) + return Promise.resolve(steamGameNameCache[appid]); + + return fetch( + "https://store.steampowered.com/api/appdetails?appids=" + + appid + + "&filters=basic", + ) + .then(function (res) { + return res.json(); + }) + .then(function (data) { + if ( + data && + data[appid] && + data[appid].success && + data[appid].data && + data[appid].data.name + ) { + const name = data[appid].data.name; + steamGameNameCache[appid] = name; + return name; + } + return null; + }) + .catch(function (err) { + backendLog( + "LuaTools: fetchSteamGameName error for " + appid + ": " + err, + ); + return null; + }); + } + + const TRANSLATION_PLACEHOLDER = "translation missing"; + + function applyTranslationBundle(bundle) { + if (!bundle || typeof bundle !== "object") return; + const stored = window.__LuaToolsI18n || {}; + if (bundle.language) { + stored.language = String(bundle.language); + } else if (!stored.language) { + stored.language = "en"; + } + if (bundle.strings && typeof bundle.strings === "object") { + stored.strings = bundle.strings; + } else if (!stored.strings) { + stored.strings = {}; + } + if (Array.isArray(bundle.locales)) { + stored.locales = bundle.locales; + } else if (!Array.isArray(stored.locales)) { + stored.locales = []; + } + stored.ready = true; + stored.lastFetched = Date.now(); + window.__LuaToolsI18n = stored; + } + + // Theme definitions (pulled from themes.json; inline only used as fallback) + const DEFAULT_THEMES = { + original: { + name: "Original", + bgPrimary: "#1b2838", + bgSecondary: "#2a475e", + bgTertiary: "rgba(44, 79, 112, 0.86)", + bgHover: "rgba(68, 112, 153, 0.86)", + bgContainer: "rgba(40, 74, 102, 0.6)", + bgContainerGradient: "rgba(40, 74, 102, 0.85), #0b141e", + accent: "#66c0f4", + accentLight: "#a4d7f5", + accentDark: "#4a9ece", + border: "rgba(102,192,244,0.3)", + borderHover: "rgba(102,192,244,0.8)", + text: "#fff", + textSecondary: "#c7d5e0", + gradient: "linear-gradient(135deg, #66c0f4 0%, #a4d7f5 100%)", + gradientLight: "linear-gradient(135deg, #a4d7f5 0%, #7dd4ff 100%)", + shadow: "rgba(102,192,244,0.4)", + shadowHover: "rgba(102,192,244,0.6)", + }, + }; + + // Runtime THEMES map - start with fallback, then hydrate from themes.json/backend. + let THEMES = DEFAULT_THEMES; + let themesLoaded = false; + + function normalizeThemesPayload(input) { + try { + let payload = input; + if (typeof payload === "string") payload = JSON.parse(payload); + if (payload && typeof payload === "object") { + if (Array.isArray(payload.themes)) return payload.themes; + if (Array.isArray(payload.result)) return payload.result; + if (payload.result && Array.isArray(payload.result.themes)) + return payload.result.themes; + if (Array.isArray(payload.value)) return payload.value; + } + if (Array.isArray(payload)) return payload; + } catch (_) { + /* ignore */ + } + return []; + } + + function _applyBackendThemes(themesArray) { + try { + const themes = normalizeThemesPayload(themesArray); + if (!Array.isArray(themes) || themes.length === 0) return; + const map = {}; + themes.forEach(function (t) { + if (!t || (!t.value && !t.key)) return; + const key = t.value || t.key; + map[key] = Object.assign({}, t, { + value: key, + name: t.name || key, + }); + }); + if (Object.keys(map).length === 0) return; + // Merge into existing THEMES if themes have been loaded, otherwise start from DEFAULT_THEMES + THEMES = Object.assign({}, themesLoaded ? THEMES : DEFAULT_THEMES, map); + themesLoaded = true; + try { + ensureLuaToolsStyles(); + } catch (_) {} + } catch (e) { + console.warn("Failed to apply backend themes", e); + } + } + + function loadThemesFromFile() { + try { + return fetch("themes/themes.json", { + cache: "no-store", + }) + .then(function (res) { + if (!res || !res.ok) return null; + return res.json(); + }) + .then(function (json) { + if (!json) return null; + _applyBackendThemes(json); + return json; + }) + .catch(function () { + return null; + }); + } catch (_) { + return Promise.resolve(null); + } + } + + function loadThemesFromBackend() { + if ( + typeof Millennium === "undefined" || + typeof Millennium.callServerMethod !== "function" + ) { + return Promise.resolve(null); + } + return Millennium.callServerMethod("luatools", "GetThemes", { + contentScriptQuery: "", + }) + .then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success && payload.themes) { + _applyBackendThemes(payload.themes); + return payload.themes; + } + } catch (_) {} + return null; + }) + .catch(function () { + return null; + }); + } + + function loadThemes() { + return Promise.all([loadThemesFromFile(), loadThemesFromBackend()]).catch( + function () { + /* ignore */ + }, + ); + } + + // Trigger load (non-blocking). Keeps DEFAULT_THEMES as a safe fallback. + const themeLoadPromise = loadThemes(); + + function getCurrentThemeKey() { + try { + const settings = window.__LuaToolsSettings || {}; + const themeKey = (settings.values || {}).general || {}; + return themeKey.theme || "original"; + } catch (e) { + return "original"; + } + } + + function getCurrentTheme() { + try { + const themeName = getCurrentThemeKey(); + const theme = THEMES[themeName] || THEMES.original; + if (!THEMES[themeName]) { + try { + backendLog( + "LuaTools: Theme " + + themeName + + " not found in THEMES, using original. Available: " + + Object.keys(THEMES).join(", "), + ); + } catch (_) {} + } + return theme; + } catch (e) { + return THEMES.original; + } + } + + function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16), + ] + : [102, 192, 244]; + } + + function getThemeColors() { + const theme = getCurrentTheme(); + const rgb = hexToRgb(theme.accent); + return { + modalBg: `linear-gradient(135deg, ${theme.bgPrimary} 0%, ${theme.bgSecondary} 100%)`, + border: theme.accent, + borderRgba: theme.border, + text: theme.text, + textSecondary: theme.textSecondary, + accent: theme.accent, + accentLight: theme.accentLight, + gradient: theme.gradient, + gradientLight: theme.gradientLight, + shadow: theme.shadow, + shadowHover: theme.shadowHover, + shadowRgba: theme.shadow.replace("0.4", "0.3"), + bgContainer: theme.bgContainer, + bgTertiary: theme.bgTertiary, + bgHover: theme.bgHover, + rgbString: rgb.join(","), + }; + } + + function generateThemeStyles(theme) { + return ` + /* Force overlay backdrops to follow the active theme (overrides inline styles) */ + .luatools-settings-overlay, + .luatools-overlay, + .luatools-fixes-results-overlay, + .luatools-loading-fixes-overlay, + .luatools-unfix-overlay, + .luatools-settings-manager-overlay, + .luatools-loadedapps-overlay { + background: rgba(${theme.rgbString}, 0.12) !important; + backdrop-filter: blur(8px) !important; + } + + /* Prefer overlay-scoped select rules to override theme CSS files */ + .luatools-settings-overlay select, + .luatools-settings-manager-overlay select, + .luatools-overlay select, + .luatools-fixes-results-overlay select, + .luatools-loadedapps-overlay select { + background-color: ${theme.bgTertiary} !important; + color: ${theme.text} !important; + border: 1px solid ${theme.border} !important; + border-radius: 3px !important; + padding: 6px 8px !important; + font-size: 14px !important; + } + .luatools-settings-overlay select option, + .luatools-settings-manager-overlay select option, + .luatools-overlay select option, + .luatools-fixes-results-overlay select option, + .luatools-loadedapps-overlay select option { + background-color: ${theme.bgPrimary} !important; + color: ${theme.text} !important; + } + .luatools-settings-overlay select option:checked, + .luatools-settings-manager-overlay select option:checked, + .luatools-overlay select option:checked, + .luatools-fixes-results-overlay select option:checked, + .luatools-loadedapps-overlay select option:checked { + background: ${theme.accent} !important; + color: ${theme.text} !important; + } + .luatools-settings-overlay select:hover, + .luatools-settings-manager-overlay select:hover, + .luatools-overlay select:hover, + .luatools-fixes-results-overlay select:hover, + .luatools-loadedapps-overlay select:hover { + border-color: ${theme.borderHover} !important; + } + .luatools-settings-overlay select:focus, + .luatools-settings-manager-overlay select:focus, + .luatools-overlay select:focus, + .luatools-fixes-results-overlay select:focus, + .luatools-loadedapps-overlay select:focus { + outline: none !important; + border-color: ${theme.accent} !important; + box-shadow: 0 0 0 2px ${theme.shadow} !important; + } + .luatools-btn { + padding: 12px 24px; + background: ${theme.bgSecondary}; + border: 2px solid ${theme.border.replace("0.3", "0.5")}; + border-radius: 12px; + color: ${theme.text}; + font-size: 15px; + font-weight: 600; + text-decoration: none; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + cursor: pointer; + box-shadow: 0 2px 8px ${theme.shadow}; + letter-spacing: 0.3px; + } + .luatools-btn:hover:not([data-disabled="1"]) { + background: ${theme.bgHover}; + transform: translateY(-2px); + box-shadow: 0 6px 20px ${theme.shadowHover}; + border-color: ${theme.borderHover}; + } + .luatools-btn.primary { + background: ${theme.gradient}; + border-color: ${theme.borderHover.replace("0.8", "0.8")}; + color: ${theme.text}; + font-weight: 700; + box-shadow: 0 4px 15px ${theme.shadow}, inset 0 1px 0 rgba(255,255,255,0.3); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } + .luatools-btn.primary:hover:not([data-disabled="1"]) { + background: ${theme.gradientLight}; + transform: translateY(-3px) scale(1.03); + box-shadow: 0 8px 25px rgba(26, 159, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.4); + } + + /* Modern Toggle Switch */ + .luatools-toggle-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } + .luatools-toggle-label-wrap { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + margin-right: 20px; + } + .luatools-toggle { + position: relative; + display: inline-block; + width: 50px; + height: 26px; + flex-shrink: 0; + } + .luatools-toggle input { + opacity: 0; + width: 0; + height: 0; + } + .luatools-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.1); + transition: .4s; + border-radius: 34px; + border: 1px solid rgba(255, 255, 255, 0.2); + } + .luatools-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: #ffffff; + transition: .4s; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + input:checked + .luatools-slider { + background-color: #1a9fff; + border-color: #1a9fff; + } + input:checked + .luatools-slider:before { + transform: translateX(24px); + } + .luatools-slider:hover { + border-color: #1a9fff; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes slideUp { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } + } + + /* Store header button - LuaTools themed icon button */ + button.luatools-header-button { + display: inline-flex; + align-items: center; + justify-content: center; + align-self: center; + width: 36px; + height: 36px; + padding: 0; + border: 2px solid ${theme.border.replace("0.3", "0.5")}; + border-radius: 4px; + background: ${theme.bgSecondary}; + color: ${theme.text}; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 2px 8px ${theme.shadow}; + margin-left: 12px; + } + button.luatools-header-button:hover { + background: ${theme.bgHover}; + transform: translateY(-1px); + box-shadow: 0 4px 12px ${theme.shadowHover}; + border-color: ${theme.borderHover}; + } + button.luatools-header-button:focus-visible { + outline: 2px solid ${theme.accent}; + outline-offset: 2px; + } + button.luatools-header-button img, + button.luatools-header-button svg { + height: 16px; + width: 16px; + } + `; + } + + function ensureThemeStylesheet(themeKey) { + const id = "luatools-theme-css"; + const href = "themes/" + themeKey + ".css"; + const link = document.getElementById(id); + if (link) { + const currentTheme = link.getAttribute("data-theme"); + if (currentTheme === themeKey) return; + link.href = href; + link.setAttribute("data-theme", themeKey); + return; + } + try { + const el = document.createElement("link"); + el.id = id; + el.rel = "stylesheet"; + el.href = href; + el.setAttribute("data-theme", themeKey); + document.head.appendChild(el); + } catch (err) { + backendLog("LuaTools: Theme CSS injection failed: " + err); + } + } + + function ensureLuaToolsStyles() { + const styleEl = document.getElementById("luatools-styles"); + const themeKey = getCurrentThemeKey(); + const theme = getCurrentTheme(); + const styles = generateThemeStyles(theme); + + try { + ensureThemeStylesheet(themeKey); + } catch (_) {} + + if (styleEl) { + styleEl.textContent = styles; + } else { + try { + const style = document.createElement("style"); + style.id = "luatools-styles"; + style.textContent = styles; + document.head.appendChild(style); + } catch (err) { + backendLog("LuaTools: Styles injection failed: " + err); + } + } + } + + function ensureFontAwesome() { + if (document.getElementById("luatools-fontawesome")) return; + try { + const link = document.createElement("link"); + link.id = "luatools-fontawesome"; + link.rel = "stylesheet"; + link.href = + "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"; + link.integrity = + "sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="; + link.crossOrigin = "anonymous"; + link.referrerPolicy = "no-referrer"; + document.head.appendChild(link); + } catch (err) { + backendLog("LuaTools: Font Awesome injection failed: " + err); + } + } + + function showCustomApiModal() { + try { + const old = document.querySelector(".luatools-custom-api-overlay"); + if (old) old.remove(); + } catch (_) {} + + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement("div"); + overlay.className = "luatools-custom-api-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:500px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const title = document.createElement("div"); + title.style.cssText = `font-size:22px;font-weight:600;margin-bottom:8px;color:${colors.text};`; + title.textContent = lt("Add Custom API"); + + const desc = document.createElement("div"); + desc.style.cssText = `font-size:14px;color:${colors.textSecondary};margin-bottom:20px;line-height:1.5;`; + desc.innerHTML = lt( + "Enter the custom API details below. You MUST include <appid> in the URL where the Game ID goes, and optionally <apikey> if an API key is required.", + ); + + const body = document.createElement("div"); + body.style.cssText = + "display:flex;flex-direction:column;gap:16px;margin-bottom:24px;"; + + function createInputGroup(labelText, placeholder, type = "text") { + const wrap = document.createElement("div"); + wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;"; + const lbl = document.createElement("label"); + lbl.style.cssText = `font-size:13px;font-weight:600;color:${colors.text};`; + lbl.textContent = labelText; + const input = document.createElement("input"); + input.type = type; + input.placeholder = placeholder; + input.style.cssText = `width:100%;padding:10px 12px;background:rgba(0,0,0,0.2);border:1px solid ${colors.borderRgba};border-radius:8px;color:${colors.text};font-size:14px;outline:none;transition:border-color 0.2s;box-sizing:border-box;`; + input.onfocus = () => (input.style.borderColor = colors.accent); + input.onblur = () => (input.style.borderColor = colors.borderRgba); + wrap.appendChild(lbl); + wrap.appendChild(input); + return { wrap, input }; + } + + const nameField = createInputGroup("API Name", "My Custom API"); + const urlField = createInputGroup( + "API URL", + "https://example.com/download?id=&key=", + ); + + body.appendChild(nameField.wrap); + body.appendChild(urlField.wrap); + + const toggleWrap = document.createElement("div"); + toggleWrap.style.cssText = + "display:flex;align-items:center;gap:10px;margin-top:8px;cursor:pointer;"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.style.cssText = `width:16px;height:16px;accent-color:${colors.accent};cursor:pointer;`; + + const toggleLabel = document.createElement("span"); + toggleLabel.style.cssText = `font-size:14px;color:${colors.text};`; + toggleLabel.textContent = lt("Require API Key"); + + toggleWrap.appendChild(checkbox); + toggleWrap.appendChild(toggleLabel); + + const apiKeyField = createInputGroup("API Key", "Enter your API key here"); + apiKeyField.wrap.style.display = "none"; + + toggleWrap.onclick = function (e) { + if (e.target !== checkbox) checkbox.checked = !checkbox.checked; + apiKeyField.wrap.style.display = checkbox.checked ? "flex" : "none"; + }; + + body.appendChild(toggleWrap); + body.appendChild(apiKeyField.wrap); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = + "display:flex;justify-content:flex-end;gap:12px;margin-top:24px;"; + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = lt("Cancel"); + cancelBtn.style.cssText = `padding:8px 16px;background:transparent;border:1px solid ${colors.borderRgba};border-radius:8px;color:${colors.text};font-size:14px;font-weight:500;cursor:pointer;transition:all 0.2s ease;`; + cancelBtn.onmouseover = () => + (cancelBtn.style.background = `rgba(255,255,255,0.1)`); + cancelBtn.onmouseout = () => (cancelBtn.style.background = "transparent"); + cancelBtn.onclick = () => overlay.remove(); + + const saveBtn = document.createElement("button"); + saveBtn.textContent = lt("Save API"); + saveBtn.style.cssText = `padding:8px 24px;background:${colors.accent};border:none;border-radius:8px;color:#fff;font-size:14px;font-weight:600;cursor:pointer;transition:transform 0.1s, filter 0.2s;`; + saveBtn.onmouseover = () => (saveBtn.style.filter = "brightness(1.1)"); + saveBtn.onmouseout = () => (saveBtn.style.filter = "none"); + saveBtn.onmousedown = () => (saveBtn.style.transform = "scale(0.96)"); + saveBtn.onmouseup = () => (saveBtn.style.transform = "scale(1)"); + + saveBtn.onclick = function () { + const name = nameField.input.value.trim(); + const url = urlField.input.value.trim(); + const needsKey = checkbox.checked; + const apiKey = apiKeyField.input.value.trim(); + + if (!name || !url) { + ShowLuaToolsAlert("Error", lt("Name and URL are required.")); + return; + } + + try { + const dummyUrl = url + .replace("", "123") + .replace("", "abc"); + const parsedUrl = new URL(dummyUrl); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + ShowLuaToolsAlert( + "Error", + lt("URL must start with http:// or https://"), + ); + return; + } + } catch (e) { + ShowLuaToolsAlert("Error", lt("Please enter a valid URL.")); + return; + } + + if (!url.includes("")) { + ShowLuaToolsAlert("Error", lt("URL must contain placeholder.")); + return; + } + if (needsKey && !url.includes("")) { + ShowLuaToolsAlert( + "Error", + lt("URL must contain when Require API Key is checked."), + ); + return; + } + if (needsKey && !apiKey) { + ShowLuaToolsAlert("Error", lt("Please enter an API Key.")); + return; + } + + saveBtn.textContent = lt("Saving..."); + saveBtn.disabled = true; + saveBtn.style.opacity = "0.7"; + + Millennium.callServerMethod("luatools", "AddCustomApi", { + name: name, + url: url, + api_key: needsKey ? apiKey : "", + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success) { + overlay.remove(); + ShowLuaToolsAlert("Success", lt("Custom API added successfully!")); + } else { + saveBtn.textContent = lt("Save API"); + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + ShowLuaToolsAlert("Error", payload.error || "Failed to save API."); + } + } catch (e) { + saveBtn.textContent = lt("Save API"); + saveBtn.disabled = false; + saveBtn.style.opacity = "1"; + ShowLuaToolsAlert("Error", e.toString()); + } + }); + }; + + btnRow.appendChild(cancelBtn); + btnRow.appendChild(saveBtn); + + modal.appendChild(title); + modal.appendChild(desc); + modal.appendChild(body); + modal.appendChild(btnRow); + overlay.appendChild(modal); + document.body.appendChild(overlay); + } + + function showSettingsPopup() { + if ( + document.querySelector(".luatools-settings-overlay") || + settingsMenuPending + ) + return; + settingsMenuPending = true; + ensureTranslationsLoaded(false) + .catch(function () { + return null; + }) + .finally(function () { + settingsMenuPending = false; + if (document.querySelector(".luatools-settings-overlay")) return; + + try { + const d = document.querySelector(".luatools-overlay"); + if (d) d.remove(); + } catch (_) {} + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement("div"); + overlay.className = "luatools-settings-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `position:relative;background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:460px;padding:20px 24px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const header = document.createElement("div"); + header.style.cssText = `display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid ${colors.borderRgba};`; + + const title = document.createElement("div"); + title.style.cssText = `display:flex;align-items:center;gap:10px;font-size:22px;color:${colors.text};font-weight:600;`; + const titleIcon = document.createElement("img"); + titleIcon.style.cssText = "width:24px;height:24px;border-radius:4px;"; + titleIcon.alt = "LuaTools"; + try { + Millennium.callServerMethod("luatools", "GetIconDataUrl", { + contentScriptQuery: "", + }).then(function (res) { + try { + const p = typeof res === "string" ? JSON.parse(res) : res; + titleIcon.src = + p && p.success && p.dataUrl + ? p.dataUrl + : "LuaTools/luatools-icon.png"; + } catch (_) { + titleIcon.src = "LuaTools/luatools-icon.png"; + } + }); + } catch (_) { + titleIcon.src = "LuaTools/luatools-icon.png"; + } + titleIcon.onerror = function () { + this.style.display = "none"; + }; + const titleText = document.createElement("span"); + titleText.textContent = t("menu.title", "LuaTools · Menu"); + title.appendChild(titleIcon); + title.appendChild(titleText); + + const iconButtons = document.createElement("div"); + iconButtons.style.cssText = "display:flex;gap:12px;"; + + function createIconButton(id, iconClass, titleKey, titleFallback) { + const btn = document.createElement("a"); + btn.id = id; + btn.href = "#"; + const btnColors = getThemeColors(); + btn.style.cssText = `display:flex;align-items:center;justify-content:center;width:40px;height:40px;background:rgba(${btnColors.rgbString},0.1);border:1px solid ${btnColors.borderRgba};border-radius:10px;color:${btnColors.accent};font-size:18px;text-decoration:none;transition:all 0.3s ease;cursor:pointer;`; + btn.innerHTML = ''; + btn.title = t(titleKey, titleFallback); + btn.onmouseover = function () { + this.style.background = `rgba(${btnColors.rgbString},0.25)`; + this.style.transform = "translateY(-2px) scale(1.05)"; + this.style.boxShadow = `0 8px 16px ${btnColors.shadowRgba}`; + this.style.borderColor = btnColors.accent; + }; + btn.onmouseout = function () { + this.style.background = `rgba(${btnColors.rgbString},0.1)`; + this.style.transform = "translateY(0) scale(1)"; + this.style.boxShadow = "none"; + this.style.borderColor = btnColors.borderRgba; + }; + iconButtons.appendChild(btn); + return btn; + } + + const body = document.createElement("div"); + body.style.cssText = + "font-size:14px;line-height:1.6;margin-bottom:12px;"; + + // Add mouse mode tip for Big Picture + if (window.__LUATOOLS_IS_BIG_PICTURE__) { + const tip = document.createElement("div"); + tip.style.cssText = + "background:rgba(102,192,244,0.15);border-left:3px solid #66c0f4;padding:12px 16px;border-radius:6px;font-size:13px;color:#c7d5e0;margin-bottom:16px;line-height:1.5;"; + tip.innerHTML = + '' + + t( + "bigpicture.mouseTip", + "To use mouse mode in Steam: Guide Button + Right Joystick, click with RB", + ); + body.appendChild(tip); + } + + const container = document.createElement("div"); + container.style.cssText = + "margin-top:16px;display:flex;flex-direction:column;gap:12px;align-items:stretch;"; + + function createCardButton(id, key, fallback, iconClass) { + const btn = document.createElement("a"); + btn.id = id; + btn.href = "#"; + const btnColors = getThemeColors(); + btn.style.cssText = `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:1;background:rgba(${btnColors.rgbString},0.06);border:1px solid ${btnColors.borderRgba};border-radius:12px;color:${btnColors.text};font-size:11px;font-weight:500;text-decoration:none;transition:all 0.2s ease;cursor:pointer;text-align:center;padding:14px 6px;min-width:0;`; + const iconHtml = iconClass + ? '' + : ""; + const textSpan = + '' + + t(key, fallback) + + ""; + btn.innerHTML = iconHtml + textSpan; + btn.onmouseover = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.15)`; + this.style.transform = "translateY(-2px)"; + this.style.boxShadow = `0 8px 20px ${c.shadow.replace("0.4", "0.15")}`; + this.style.borderColor = c.accent; + }; + btn.onmouseout = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.06)`; + this.style.transform = "translateY(0)"; + this.style.boxShadow = "none"; + this.style.borderColor = c.borderRgba; + }; + return btn; + } + + const discordBtn = createIconButton( + "lt-settings-discord", + "fa-brands fa-discord", + "menu.discord", + "Discord", + ); + const settingsManagerBtn = createIconButton( + "lt-settings-open-manager", + "fa-gear", + "menu.settings", + "Settings", + ); + const customApiBtn = createIconButton( + "lt-settings-custom-api", + "fa-solid fa-code-branch", + "menu.customApi", + "Custom API", + ); + const closeBtn = createIconButton( + "lt-settings-close", + "fa-xmark", + "settings.close", + "Close", + ); + + // Check if we are on a game page + const isGamePage = window.location.href.includes("/app/"); + + if (customApiBtn) { + customApiBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + showCustomApiModal(); + }); + } + + const removeBtn = document.createElement("a"); + removeBtn.id = "lt-settings-remove-lua"; + removeBtn.href = "#"; + const removeBtnColors = getThemeColors(); + removeBtn.style.cssText = `display:none;align-items:center;justify-content:center;gap:8px;padding:10px 16px;background:rgba(${removeBtnColors.rgbString},0.06);border:1px solid ${removeBtnColors.borderRgba};border-radius:10px;color:${removeBtnColors.textSecondary};font-size:13px;font-weight:500;text-decoration:none;transition:all 0.2s ease;cursor:pointer;text-align:center;`; + removeBtn.innerHTML = + '' + + t("menu.removeLuaTools", "Remove via LuaTools") + + ""; + removeBtn.onmouseover = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.15)`; + this.style.borderColor = c.accent; + }; + removeBtn.onmouseout = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.06)`; + this.style.borderColor = c.borderRgba; + }; + container.appendChild(removeBtn); + + // Card button grid + const cardGrid = document.createElement("div"); + cardGrid.style.cssText = + "display:flex;gap:10px;justify-content:center;"; + + const fixesMenuBtn = createCardButton( + "lt-settings-fixes-menu", + "menu.fixesMenu", + "Fixes Menu", + "fa-wrench", + ); + if (isGamePage) cardGrid.appendChild(fixesMenuBtn); + + const checkBtn = createCardButton( + "lt-settings-check", + "menu.checkForUpdates", + "Check Updates", + "fa-cloud-arrow-down", + ); + cardGrid.appendChild(checkBtn); + + const fetchApisBtn = createCardButton( + "lt-settings-fetch-apis", + "menu.fetchFreeApis", + "Fetch APIs", + "fa-server", + ); + cardGrid.appendChild(fetchApisBtn); + + container.appendChild(cardGrid); + + body.appendChild(container); + + header.appendChild(title); + header.appendChild(iconButtons); + modal.appendChild(header); + modal.appendChild(body); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + + if (checkBtn) { + checkBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + try { + Millennium.callServerMethod("luatools", "CheckForUpdatesNow", { + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = + typeof res === "string" ? JSON.parse(res) : res; + const msg = + payload && payload.message + ? String(payload.message) + : lt("No updates available."); + ShowLuaToolsAlert("LuaTools", msg); + } catch (_) {} + }); + } catch (_) {} + }); + } + + if (discordBtn) { + discordBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + const url = "https://discord.gg/luatools"; + try { + Millennium.callServerMethod("luatools", "OpenExternalUrl", { + url, + contentScriptQuery: "", + }); + } catch (_) {} + }); + } + + if (fetchApisBtn) { + fetchApisBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + try { + Millennium.callServerMethod("luatools", "FetchFreeApisNow", { + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = + typeof res === "string" ? JSON.parse(res) : res; + const ok = payload && payload.success; + const count = payload && payload.count; + const successText = lt("Loaded free APIs: {count}").replace( + "{count}", + count != null ? count : "?", + ); + const failText = + payload && payload.error + ? String(payload.error) + : lt("Failed to load free APIs."); + const text = ok ? successText : failText; + ShowLuaToolsAlert("LuaTools", text); + } catch (_) {} + }); + } catch (_) {} + }); + } + + if (closeBtn) { + closeBtn.addEventListener("click", function (e) { + e.preventDefault(); + overlay.remove(); + }); + } + + if (settingsManagerBtn) { + // This is the icon button now + settingsManagerBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + showSettingsManagerPopup(false, showSettingsPopup); + }); + } + + if (fixesMenuBtn) { + fixesMenuBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + const match = + window.location.href.match( + /https:\/\/store\.steampowered\.com\/app\/(\d+)/, + ) || + window.location.href.match( + /https:\/\/steamcommunity\.com\/app\/(\d+)/, + ); + const appid = match + ? parseInt(match[1], 10) + : window.__LuaToolsCurrentAppId || NaN; + if (isNaN(appid)) { + try { + overlay.remove(); + } catch (_) {} + const errText = t( + "menu.error.noAppId", + "Could not determine game AppID", + ); + ShowLuaToolsAlert("LuaTools", errText); + return; + } + + Millennium.callServerMethod("luatools", "GetGameInstallPath", { + appid, + contentScriptQuery: "", + }) + .then(function (pathRes) { + try { + let isGameInstalled = false; + const pathPayload = + typeof pathRes === "string" + ? JSON.parse(pathRes) + : pathRes; + if ( + pathPayload && + pathPayload.success && + pathPayload.installPath + ) { + isGameInstalled = true; + window.__LuaToolsGameInstallPath = + pathPayload.installPath; + } + window.__LuaToolsGameIsInstalled = isGameInstalled; + try { + overlay.remove(); + } catch (_) {} + showFixesLoadingPopupAndCheck(appid); + } catch (err) { + backendLog("LuaTools: GetGameInstallPath error: " + err); + try { + overlay.remove(); + } catch (_) {} + } + }) + .catch(function () { + try { + overlay.remove(); + } catch (_) {} + const errorText = t( + "menu.error.getPath", + "Error getting game path", + ); + ShowLuaToolsAlert("LuaTools", errorText); + }); + } catch (err) { + backendLog("LuaTools: Fixes Menu button error: " + err); + } + }); + } + + try { + const match = + window.location.href.match( + /https:\/\/store\.steampowered\.com\/app\/(\d+)/, + ) || + window.location.href.match( + /https:\/\/steamcommunity\.com\/app\/(\d+)/, + ); + const appid = match + ? parseInt(match[1], 10) + : window.__LuaToolsCurrentAppId || NaN; + if ( + !isNaN(appid) && + typeof Millennium !== "undefined" && + typeof Millennium.callServerMethod === "function" + ) { + Millennium.callServerMethod("luatools", "HasLuaToolsForApp", { + appid, + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + const exists = !!( + payload && + payload.success && + payload.exists === true + ); + if (exists) { + const doDelete = function () { + try { + Millennium.callServerMethod( + "luatools", + "DeleteLuaToolsForApp", + { + appid, + contentScriptQuery: "", + }, + ) + .then(function () { + try { + window.__LuaToolsButtonInserted = false; + window.__LuaToolsPresenceCheckInFlight = false; + window.__LuaToolsPresenceCheckAppId = undefined; + addLuaToolsButton(); + const successText = t( + "menu.remove.success", + "LuaTools removed for this app.", + ); + ShowLuaToolsAlert("LuaTools", successText); + } catch (err) { + backendLog( + "LuaTools: post-delete cleanup failed: " + err, + ); + } + }) + .catch(function (err) { + const failureText = t( + "menu.remove.failure", + "Failed to remove LuaTools.", + ); + const errMsg = + err && err.message ? err.message : failureText; + ShowLuaToolsAlert("LuaTools", errMsg); + }); + } catch (err) { + backendLog("LuaTools: doDelete failed: " + err); + } + }; + + removeBtn.style.display = "flex"; + removeBtn.onclick = function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + const confirmMessage = t( + "menu.remove.confirm", + "Remove via LuaTools for this game?", + ); + showLuaToolsConfirm( + "LuaTools", + confirmMessage, + function () { + doDelete(); + }, + function () { + try { + showSettingsPopup(); + } catch (_) {} + }, + ); + }; + } else { + removeBtn.style.display = "none"; + } + } catch (_) {} + }); + } + } catch (_) {} + }); + } + + function ensureTranslationsLoaded(forceRefresh, preferredLanguage) { + try { + if ( + !forceRefresh && + window.__LuaToolsI18n && + window.__LuaToolsI18n.ready + ) { + return Promise.resolve(window.__LuaToolsI18n); + } + if ( + typeof Millennium === "undefined" || + typeof Millennium.callServerMethod !== "function" + ) { + window.__LuaToolsI18n = window.__LuaToolsI18n || { + language: "en", + locales: [], + strings: {}, + ready: false, + }; + return Promise.resolve(window.__LuaToolsI18n); + } + const settingsVals = + ((window.__LuaToolsSettings || {}).values || {}).general || {}; + const useSteamLang = + typeof settingsVals.useSteamLanguage === "boolean" + ? settingsVals.useSteamLanguage + : true; + let targetLanguage = + typeof preferredLanguage === "string" && preferredLanguage + ? preferredLanguage + : ""; + if (!targetLanguage) { + let steamLang = document.documentElement.lang || "en"; + if (steamLang.toLowerCase() === "pt-br") steamLang = "pt-BR"; + if (steamLang.toLowerCase() === "zh-cn") steamLang = "zh-CN"; + if (steamLang.toLowerCase() === "zh-tw") steamLang = "zh-TW"; + if (steamLang.toLowerCase() === "es-419") steamLang = "es"; + targetLanguage = useSteamLang + ? steamLang + : (window.__LuaToolsI18n && window.__LuaToolsI18n.language) || "en"; + } + return Millennium.callServerMethod("luatools", "GetTranslations", { + language: targetLanguage, + contentScriptQuery: "", + }) + .then(function (res) { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (!payload || payload.success !== true || !payload.strings) { + throw new Error("Invalid translation payload"); + } + applyTranslationBundle(payload); + // Update button text after translations are loaded + updateButtonTranslations(); + return window.__LuaToolsI18n; + }) + .catch(function (err) { + backendLog("LuaTools: translation load failed: " + err); + window.__LuaToolsI18n = window.__LuaToolsI18n || { + language: "en", + locales: [], + strings: {}, + ready: false, + }; + return window.__LuaToolsI18n; + }); + } catch (err) { + backendLog("LuaTools: ensureTranslationsLoaded error: " + err); + window.__LuaToolsI18n = window.__LuaToolsI18n || { + language: "en", + locales: [], + strings: {}, + ready: false, + }; + return Promise.resolve(window.__LuaToolsI18n); + } + } + + function translateText(key, fallback) { + if (!key) { + return typeof fallback !== "undefined" ? fallback : ""; + } + try { + const store = window.__LuaToolsI18n; + if ( + store && + store.strings && + Object.prototype.hasOwnProperty.call(store.strings, key) + ) { + const value = store.strings[key]; + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed && trimmed.toLowerCase() !== TRANSLATION_PLACEHOLDER) { + return value; + } + } + } + } catch (_) {} + return typeof fallback !== "undefined" ? fallback : key; + } + + function t(key, fallback) { + return translateText(key, fallback); + } + + function lt(text) { + return t(text, text); + } + + // Translations are loaded by fetchSettingsConfig() in onFrontendReady — no separate preload needed. + + function askRestartConfirmation() { + showLuaToolsConfirm( + "LuaTools", + lt("Restart Steam now?"), + function () { + try { + Millennium.callServerMethod("luatools", "RestartSteam", { + contentScriptQuery: "", + }); + // SteamClient.User.StartRestart(true) Unreliable, closes but doesn't restart (on my pc) + } catch (_) {} + }, + function () { + /* Cancel - do nothing */ + }, + ); + } + + let settingsMenuPending = false; + + // Helper: show a Steam-style popup with a 10s loading bar (custom UI) + function showTestPopup() { + // Avoid duplicates + if (document.querySelector(".luatools-overlay")) return; + // Close settings popup if open so modals don't overlap + try { + const s = document.querySelector(".luatools-settings-overlay"); + if (s) s.remove(); + } catch (_) {} + + ensureLuaToolsStyles(); + ensureFontAwesome(); + const overlay = document.createElement("div"); + overlay.className = "luatools-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:520px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const title = document.createElement("div"); + const titleColors = getThemeColors(); + title.style.cssText = `display:flex;align-items:center;gap:10px;font-size:20px;color:${titleColors.text};margin-bottom:16px;font-weight:600;`; + title.className = "luatools-title"; + const dlTitleIcon = document.createElement("i"); + dlTitleIcon.className = "fa-solid fa-cloud-arrow-down"; + dlTitleIcon.style.cssText = `color:${titleColors.accent};font-size:20px;`; + title.appendChild(dlTitleIcon); + const dlTitleText = document.createElement("span"); + dlTitleText.textContent = lt("Select Download Source"); + title.appendChild(dlTitleText); + + // API list container + const apiListContainer = document.createElement("div"); + apiListContainer.className = "luatools-api-list"; + apiListContainer.style.cssText = "margin-bottom:16px;"; + + // Placeholder while loading APIs + const loadingItem = document.createElement("div"); + loadingItem.style.cssText = `text-align:center;padding:10px;color:${colors.textSecondary};font-size:13px;`; + loadingItem.textContent = lt("Loading APIs..."); + apiListContainer.appendChild(loadingItem); + + // Load APIs dynamically from backend + if ( + typeof Millennium !== "undefined" && + typeof Millennium.callServerMethod === "function" + ) { + Millennium.callServerMethod("luatools", "GetApiList", { + contentScriptQuery: "", + }) + .then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if ( + payload && + payload.success && + payload.apis && + Array.isArray(payload.apis) + ) { + // Clear loading message + apiListContainer.innerHTML = ""; + + // Create API items + payload.apis.forEach((api, index) => { + const apiItem = document.createElement("div"); + apiItem.className = `luatools-api-item luatools-api-${index}`; + apiItem.setAttribute("data-api-name", api.name); + apiItem.style.cssText = `display:flex;align-items:center;justify-content:space-between;padding:10px 14px;margin-bottom:8px;background:rgba(${colors.rgbString},0.1);border:1px solid ${colors.borderRgba};border-radius:6px;transition:all 0.2s;`; + + const apiName = document.createElement("div"); + apiName.className = "luatools-api-name"; + apiName.style.cssText = `font-size:14px;color:${colors.textSecondary};font-weight:500;`; + apiName.textContent = api.name; + + const apiStatus = document.createElement("div"); + apiStatus.className = "luatools-api-status"; + apiStatus.style.cssText = `font-size:14px;color:${colors.textSecondary};display:flex;align-items:center;gap:6px;`; + apiStatus.innerHTML = + "" + + lt("Waiting…") + + "" + + ''; + + apiItem.appendChild(apiName); + apiItem.appendChild(apiStatus); + apiListContainer.appendChild(apiItem); + }); + } + } catch (err) { + backendLog("Failed to parse API list: " + err); + } + }) + .catch(function (err) { + backendLog("Failed to load API list: " + err); + }); + } + + const body = document.createElement("div"); + body.style.cssText = `display:flex;align-items:center;justify-content:center;gap:8px;font-size:14px;line-height:1.4;margin-bottom:12px;color:${colors.textSecondary};`; + body.className = "luatools-status"; + body.innerHTML = + '' + + lt("Checking availability…") + + ""; + + const progressWrap = document.createElement("div"); + progressWrap.style.cssText = `background:rgba(0,0,0,0.3);height:20px;border-radius:4px;overflow:hidden;position:relative;display:none;border:1px solid ${colors.border};margin-top:12px;`; + progressWrap.className = "luatools-progress-wrap"; + const progressBar = document.createElement("div"); + progressBar.style.cssText = `height:100%;width:0%;background:${colors.gradient};transition:width 0.3s ease;box-shadow:0 0 10px ${colors.shadow};`; + progressBar.className = "luatools-progress-bar"; + progressWrap.appendChild(progressBar); + + const progressInfo = document.createElement("div"); + progressInfo.style.cssText = `display:none;margin-top:8px;font-size:12px;color:${colors.textSecondary};`; + progressInfo.className = "luatools-progress-info"; + + const percent = document.createElement("span"); + percent.className = "luatools-percent"; + percent.textContent = "0%"; + + const downloadSize = document.createElement("span"); + downloadSize.className = "luatools-download-size"; + downloadSize.style.cssText = "margin-left:12px;"; + downloadSize.textContent = ""; + + progressInfo.appendChild(percent); + progressInfo.appendChild(downloadSize); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = + "margin-top:20px;display:flex;gap:8px;justify-content:center;"; + const cancelBtn = document.createElement("a"); + cancelBtn.className = "luatools-btn luatools-cancel-btn"; + cancelBtn.style.cssText = + "display:none;align-items:center;justify-content:center;text-align:center;"; + cancelBtn.innerHTML = `${lt("Cancel")}`; + cancelBtn.href = "#"; + cancelBtn.onclick = function (e) { + e.preventDefault(); + cancelOperation(); + }; + const hideBtn = document.createElement("a"); + hideBtn.className = "luatools-btn luatools-hide-btn"; + hideBtn.style.cssText = + "display:flex;align-items:center;justify-content:center;text-align:center;"; + hideBtn.innerHTML = `${lt("Hide")}`; + hideBtn.href = "#"; + hideBtn.onclick = function (e) { + e.preventDefault(); + cleanup(); + }; + btnRow.appendChild(cancelBtn); + btnRow.appendChild(hideBtn); + + modal.appendChild(title); + modal.appendChild(apiListContainer); + modal.appendChild(body); + modal.appendChild(progressWrap); + modal.appendChild(progressInfo); + modal.appendChild(btnRow); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + + function cleanup() { + overlay.remove(); + } + + function cancelOperation() { + // Call backend to cancel the operation + try { + const match = + window.location.href.match( + /https:\/\/store\.steampowered\.com\/app\/(\d+)/, + ) || + window.location.href.match( + /https:\/\/steamcommunity\.com\/app\/(\d+)/, + ); + const appid = match + ? parseInt(match[1], 10) + : window.__LuaToolsCurrentAppId || NaN; + if ( + !isNaN(appid) && + typeof Millennium !== "undefined" && + typeof Millennium.callServerMethod === "function" + ) { + Millennium.callServerMethod("luatools", "CancelAddViaLuaTools", { + appid, + contentScriptQuery: "", + }); + } + } catch (_) {} + // Update UI to show cancelled + const status = overlay.querySelector(".luatools-status"); + if (status) status.textContent = lt("Cancelled"); + const cancelBtn = overlay.querySelector(".luatools-cancel-btn"); + if (cancelBtn) cancelBtn.style.display = "none"; + const hideBtn = overlay.querySelector(".luatools-hide-btn"); + if (hideBtn) hideBtn.innerHTML = `${lt("Close")}`; + // Hide progress UI + const wrap = overlay.querySelector(".luatools-progress-wrap"); + const progressInfo = overlay.querySelector(".luatools-progress-info"); + if (wrap) wrap.style.display = "none"; + if (progressInfo) progressInfo.style.display = "none"; + // Reset run state + runState.inProgress = false; + runState.appid = null; + } + } + + // Fixes Results popup + function showFixesResultsPopup(data, isGameInstalled) { + if (document.querySelector(".luatools-fixes-results-overlay")) return; + // Close other popups + try { + const d = document.querySelector(".luatools-overlay"); + if (d) d.remove(); + } catch (_) {} + try { + const s = document.querySelector(".luatools-settings-overlay"); + if (s) s.remove(); + } catch (_) {} + try { + const f = document.querySelector(".luatools-fixes-results-overlay"); + if (f) f.remove(); + } catch (_) {} + try { + const l = document.querySelector(".luatools-loading-fixes-overlay"); + if (l) l.remove(); + } catch (_) {} + + ensureLuaToolsStyles(); + ensureFontAwesome(); + const overlay = document.createElement("div"); + overlay.className = "luatools-fixes-results-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `position:relative;background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:640px;max-height:80vh;display:flex;flex-direction:column;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const header = document.createElement("div"); + header.style.cssText = `flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid ${colors.borderRgba};`; + + const title = document.createElement("div"); + title.style.cssText = `display:flex;align-items:center;gap:10px;font-size:22px;color:${colors.text};font-weight:600;`; + const titleIcon = document.createElement("i"); + titleIcon.className = "fa-solid fa-wrench"; + titleIcon.style.cssText = `color:${colors.accent};font-size:20px;`; + const titleText = document.createElement("span"); + titleText.textContent = lt("LuaTools · Fixes Menu"); + title.appendChild(titleIcon); + title.appendChild(titleText); + + const iconButtons = document.createElement("div"); + iconButtons.style.cssText = "display:flex;gap:12px;"; + + function createIconButton(id, iconClass, titleKey, titleFallback) { + const btn = document.createElement("a"); + btn.id = id; + btn.href = "#"; + const btnColors = getThemeColors(); + btn.style.cssText = `display:flex;align-items:center;justify-content:center;width:40px;height:40px;background:rgba(${btnColors.rgbString},0.1);border:1px solid ${btnColors.borderRgba};border-radius:10px;color:${btnColors.accent};font-size:18px;text-decoration:none;transition:all 0.3s ease;cursor:pointer;`; + btn.innerHTML = ''; + btn.title = t(titleKey, titleFallback); + btn.onmouseover = function () { + this.style.background = `rgba(${btnColors.rgbString},0.25)`; + this.style.transform = "translateY(-2px) scale(1.05)"; + this.style.boxShadow = `0 8px 16px ${btnColors.shadowRgba}`; + this.style.borderColor = btnColors.accent; + }; + btn.onmouseout = function () { + this.style.background = `rgba(${btnColors.rgbString},0.1)`; + this.style.transform = "translateY(0) scale(1)"; + this.style.boxShadow = "none"; + this.style.borderColor = btnColors.borderRgba; + }; + iconButtons.appendChild(btn); + return btn; + } + + const discordBtn = createIconButton( + "lt-fixes-discord", + "fa-brands fa-discord", + "menu.discord", + "Discord", + ); + const settingsBtn = createIconButton( + "lt-fixes-settings", + "fa-gear", + "menu.settings", + "Settings", + ); + const closeIconBtn = createIconButton( + "lt-fixes-close", + "fa-xmark", + "settings.close", + "Close", + ); + + const body = document.createElement("div"); + const bodyColors = getThemeColors(); + body.style.cssText = `flex:1 1 auto;overflow-y:auto;padding:20px;border:1px solid ${bodyColors.border};border-radius:12px;background:${bodyColors.bgContainer};`; + + try { + const bannerImg = document.querySelector(".game_header_image_full"); + if (bannerImg && bannerImg.src) { + body.style.background = `linear-gradient(to bottom, rgba(15, 15, 15, 0.85), #0f0f0f 70%), url('${bannerImg.src}') no-repeat top center`; + body.style.backgroundSize = "cover"; + } + } catch (_) {} + + // Add mouse mode tip for Big Picture + if (window.__LUATOOLS_IS_BIG_PICTURE__) { + const tip = document.createElement("div"); + tip.style.cssText = + "background:rgba(102,192,244,0.15);border-left:3px solid #66c0f4;padding:12px 16px;border-radius:6px;font-size:13px;color:#c7d5e0;margin-bottom:16px;line-height:1.5;"; + tip.innerHTML = + '' + + t( + "bigpicture.mouseTip", + "To use mouse mode in Steam: Guide Button + Right Joystick, click with RB", + ); + body.appendChild(tip); + } + + const gameHeader = document.createElement("div"); + gameHeader.style.cssText = + "display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:16px;"; + + const gameIcon = document.createElement("img"); + gameIcon.style.cssText = + "width:32px;height:32px;border-radius:4px;object-fit:cover;display:none;"; + try { + const iconImg = document.querySelector(".apphub_AppIcon img"); + if (iconImg && iconImg.src) { + gameIcon.src = iconImg.src; + gameIcon.style.display = "block"; + } + } catch (_) {} + + const gameName = document.createElement("div"); + gameName.style.cssText = + "font-size:22px;color:#fff;font-weight:600;text-align:center;"; + gameName.textContent = data.gameName || lt("Unknown Game"); + + if ( + !data.gameName || + data.gameName === "Unknown Game" || + data.gameName === lt("Unknown Game") || + data.gameName.startsWith("Unknown Game") + ) { + fetchSteamGameName(data.appid).then(function (name) { + if (name) { + data.gameName = name; + gameName.textContent = name; + } + }); + } + + const contentContainer = document.createElement("div"); + contentContainer.style.position = "relative"; + contentContainer.style.zIndex = "1"; + + const columnsContainer = document.createElement("div"); + columnsContainer.style.cssText = + "display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:16px;"; + + function createFixButton(label, text, icon, isSuccess, onClick) { + const btn = document.createElement("a"); + btn.href = "#"; + const btnColors = getThemeColors(); + btn.style.cssText = `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:1 1 calc(50% - 10px);min-width:140px;box-sizing:border-box;padding:14px 6px;background:rgba(${btnColors.rgbString},0.06);border:1px solid ${btnColors.borderRgba};border-radius:12px;color:${btnColors.text};text-decoration:none;transition:all 0.2s ease;cursor:pointer;text-align:center;`; + + const iconHtml = + ''; + const labelHtml = + '' + + label + + ""; + const textHtml = + '' + + text + + ""; + btn.innerHTML = iconHtml + labelHtml + textHtml; + + // If the active theme is light, make certain fix action texts/icons white for readability. + try { + const currentThemeKey = + (((window.__LuaToolsSettings || {}).values || {}).general || {}) + .theme || "original"; + // Use localized labels so this works in other languages + const applyLabel = lt("Apply"); + const onlineUnsteamLabel = lt("Online Fix (Unsteam)"); + const noOnlineLabel = lt("No online-fix"); + const unfixLabel = lt("Un-Fix (verify game)"); + const noGenericLabel = lt("No generic fix"); + const whiteTexts = new Set([ + applyLabel, + onlineUnsteamLabel, + noOnlineLabel, + unfixLabel, + noGenericLabel, + ]); + if (currentThemeKey === "light" && whiteTexts.has(String(text))) { + btn + .querySelectorAll("span, i") + .forEach((el) => (el.style.color = "#ffffff")); + } + } catch (_) {} + + if (isSuccess) { + btn.style.background = + "linear-gradient(135deg, rgba(92,156,62,0.4) 0%, rgba(92,156,62,0.2) 100%)"; + btn.style.borderColor = "rgba(92,156,62,0.6)"; + btn.onmouseover = function () { + this.style.background = + "linear-gradient(135deg, rgba(92,156,62,0.6) 0%, rgba(92,156,62,0.3) 100%)"; + this.style.transform = "translateY(-2px)"; + this.style.boxShadow = "0 8px 20px rgba(92,156,62,0.3)"; + this.style.borderColor = "#79c754"; + }; + btn.onmouseout = function () { + this.style.background = + "linear-gradient(135deg, rgba(92,156,62,0.4) 0%, rgba(92,156,62,0.2) 100%)"; + this.style.transform = "translateY(0)"; + this.style.boxShadow = "none"; + this.style.borderColor = "rgba(92,156,62,0.6)"; + }; + } else if (isSuccess === false) { + btn.style.opacity = "0.5"; + btn.style.cursor = "not-allowed"; + } else { + const mutableColors = getThemeColors(); + btn.onmouseover = function () { + const c = getThemeColors(); + this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.3) 0%, rgba(${c.rgbString},0.15) 100%)`; + this.style.transform = "translateY(-2px)"; + this.style.boxShadow = `0 8px 20px rgba(${c.rgbString},0.25)`; + this.style.borderColor = c.accent; + }; + btn.onmouseout = function () { + const c = getThemeColors(); + this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.15) 0%, rgba(${c.rgbString},0.05) 100%)`; + this.style.transform = "translateY(0)"; + this.style.boxShadow = "none"; + this.style.borderColor = c.border; + }; + } + + btn.onclick = onClick; + return btn; + } + + // left thing in fixes modal + const genericStatus = data.genericFix.status; + const genericSection = createFixButton( + lt("Generic Fix"), + genericStatus === 200 ? lt("Apply") : lt("No generic fix"), + genericStatus === 200 ? "fa-check" : "fa-circle-xmark", + genericStatus === 200 ? true : false, + function (e) { + e.preventDefault(); + if (genericStatus === 200 && isGameInstalled) { + const genericUrl = + "https://files.luatools.work/GameBypasses/" + data.appid + ".zip"; + applyFix( + data.appid, + genericUrl, + lt("Generic Fix"), + data.gameName, + overlay, + ); + } + }, + ); + columnsContainer.appendChild(genericSection); + + if (!isGameInstalled) { + genericSection.style.opacity = "0.5"; + genericSection.style.cursor = "not-allowed"; + } + + const onlineStatus = data.onlineFix.status; + const onlineSection = createFixButton( + lt("Online Fix"), + onlineStatus === 200 ? lt("Apply") : lt("No online-fix"), + onlineStatus === 200 ? "fa-check" : "fa-circle-xmark", + onlineStatus === 200 ? true : false, + function (e) { + e.preventDefault(); + if (onlineStatus === 200 && isGameInstalled) { + const onlineUrl = + data.onlineFix.url || + "https://files.luatools.work/OnlineFix1/" + data.appid + ".zip"; + applyFix( + data.appid, + onlineUrl, + lt("Online Fix"), + data.gameName, + overlay, + ); + } + }, + ); + columnsContainer.appendChild(onlineSection); + + if (!isGameInstalled) { + onlineSection.style.opacity = "0.5"; + onlineSection.style.cursor = "not-allowed"; + } + const aioSection = createFixButton( + lt("All-In-One Fixes"), + lt("Online Fix (Unsteam)"), + "fa-globe", + null, // default blue button + function (e) { + e.preventDefault(); + if (isGameInstalled) { + const downloadUrl = + "https://github.com/madoiscool/lt_api_links/releases/download/unsteam/Win64.zip"; + applyFix( + data.appid, + downloadUrl, + lt("Online Fix (Unsteam)"), + data.gameName, + overlay, + ); + } + }, + ); + columnsContainer.appendChild(aioSection); + if (!isGameInstalled) { + aioSection.style.opacity = "0.5"; + aioSection.style.cursor = "not-allowed"; + } + + const unfixSection = createFixButton( + lt("Manage Game"), + lt("Un-Fix (verify game)"), + "fa-trash", + null, // ^^ + function (e) { + e.preventDefault(); + if (isGameInstalled) { + try { + overlay.remove(); + } catch (_) {} + showLuaToolsConfirm( + "LuaTools", + lt( + "Are you sure you want to un-fix? This will remove fix files and verify game files.", + ), + function () { + startUnfix(data.appid); + }, + function () { + showFixesResultsPopup(data, isGameInstalled); + }, + ); + } + }, + ); + columnsContainer.appendChild(unfixSection); + if (!isGameInstalled) { + unfixSection.style.opacity = "0.5"; + unfixSection.style.cursor = "not-allowed"; + } + + // Credit message + const creditMsg = document.createElement("div"); + const creditColors = getThemeColors(); + creditMsg.style.cssText = `margin-top:16px;text-align:center;font-size:13px;color:${creditColors.textSecondary};`; + const creditTemplate = lt("Only possible thanks to {name} 💜"); + creditMsg.innerHTML = creditTemplate.replace( + "{name}", + `ShayneVi`, + ); + + // Wire up ShayneVi link + setTimeout(function () { + const shayenviLink = overlay.querySelector("#lt-shayenvi-link"); + if (shayenviLink) { + shayenviLink.addEventListener("click", function (e) { + e.preventDefault(); + try { + Millennium.callServerMethod("luatools", "OpenExternalUrl", { + url: "https://github.com/ShayneVi/", + contentScriptQuery: "", + }); + } catch (_) {} + }); + } + }, 0); + + // body moment + gameHeader.appendChild(gameIcon); + gameHeader.appendChild(gameName); + contentContainer.appendChild(gameHeader); + + contentContainer.appendChild(columnsContainer); + + if (!isGameInstalled) { + const notInstalledWarning = document.createElement("div"); + notInstalledWarning.style.cssText = + "margin-top: 16px; padding: 12px; background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); border-radius: 6px; color: #ffc107; font-size: 13px; text-align: center;"; + notInstalledWarning.innerHTML = + '' + + t("menu.error.notInstalled", "Game is not installed"); + contentContainer.appendChild(notInstalledWarning); + } + + contentContainer.appendChild(creditMsg); + body.appendChild(contentContainer); + + // header moment + header.appendChild(title); + header.appendChild(iconButtons); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = + "flex:0 0 auto;margin-top:16px;display:flex;gap:8px;justify-content:space-between;align-items:center;"; + + const rightButtons = document.createElement("div"); + rightButtons.style.cssText = "display:flex;gap:8px;"; + const gameFolderBtn = document.createElement("a"); + gameFolderBtn.className = "luatools-btn"; + gameFolderBtn.innerHTML = `${lt("Game folder")}`; + gameFolderBtn.href = "#"; + gameFolderBtn.onclick = function (e) { + e.preventDefault(); + if (window.__LuaToolsGameInstallPath) { + try { + Millennium.callServerMethod("luatools", "OpenGameFolder", { + path: window.__LuaToolsGameInstallPath, + contentScriptQuery: "", + }); + } catch (err) { + backendLog("LuaTools: Failed to open game folder: " + err); + } + } + }; + rightButtons.appendChild(gameFolderBtn); + + const backBtn = document.createElement("a"); + backBtn.className = "luatools-btn"; + backBtn.innerHTML = ''; + backBtn.href = "#"; + backBtn.onclick = function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + showSettingsPopup(); + }; + btnRow.appendChild(backBtn); + btnRow.appendChild(rightButtons); + + // final modal + modal.appendChild(header); + modal.appendChild(body); + modal.appendChild(btnRow); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + + closeIconBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + }; + discordBtn.onclick = function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + const url = "https://discord.gg/luatools"; + try { + Millennium.callServerMethod("luatools", "OpenExternalUrl", { + url, + contentScriptQuery: "", + }); + } catch (_) {} + }; + settingsBtn.onclick = function (e) { + e.preventDefault(); + try { + overlay.remove(); + } catch (_) {} + showSettingsManagerPopup(false, function () { + showFixesResultsPopup(data, isGameInstalled); + }); + }; + + function startUnfix(appid) { + try { + Millennium.callServerMethod("luatools", "UnFixGame", { + appid: appid, + installPath: window.__LuaToolsGameInstallPath, + contentScriptQuery: "", + }) + .then(function (res) { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success) { + showUnfixProgress(appid); + } else { + const errorKey = + payload && payload.error ? String(payload.error) : ""; + const errorMsg = + errorKey && + (errorKey.startsWith("menu.error.") || + errorKey.startsWith("common.")) + ? t(errorKey) + : errorKey || lt("Failed to start un-fix"); + ShowLuaToolsAlert("LuaTools", errorMsg); + } + }) + .catch(function () { + const msg = lt("Error starting un-fix"); + ShowLuaToolsAlert("LuaTools", msg); + }); + } catch (err) { + backendLog("LuaTools: Un-Fix start error: " + err); + } + } + } + + function showFixesLoadingPopupAndCheck(appid) { + if (document.querySelector(".luatools-loading-fixes-overlay")) return; + try { + const d = document.querySelector(".luatools-overlay"); + if (d) d.remove(); + } catch (_) {} + try { + const s = document.querySelector(".luatools-settings-overlay"); + if (s) s.remove(); + } catch (_) {} + try { + const f = document.querySelector(".luatools-fixes-overlay"); + if (f) f.remove(); + } catch (_) {} + + ensureLuaToolsStyles(); + ensureFontAwesome(); + const overlay = document.createElement("div"); + overlay.className = "luatools-loading-fixes-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:480px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const title = document.createElement("div"); + const titleColorsLoading = getThemeColors(); + title.style.cssText = `font-size:22px;color:${titleColorsLoading.text};margin-bottom:16px;font-weight:600;`; + title.textContent = lt("Loading fixes..."); + + const body = document.createElement("div"); + const bodyColorsLoading = getThemeColors(); + body.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:16px;color:${bodyColorsLoading.textSecondary};`; + body.textContent = lt("Checking availability…"); + + const progressWrap = document.createElement("div"); + const progressColorsLoading = getThemeColors(); + progressWrap.style.cssText = `background:rgba(0,0,0,0.3);height:12px;border-radius:4px;overflow:hidden;position:relative;border:1px solid ${progressColorsLoading.border};`; + const progressBar = document.createElement("div"); + progressBar.style.cssText = `height:100%;width:0%;background:${progressColorsLoading.gradient};transition:width 0.2s linear;box-shadow:0 0 10px ${progressColorsLoading.shadow};`; + progressWrap.appendChild(progressBar); + + modal.appendChild(title); + modal.appendChild(body); + modal.appendChild(progressWrap); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + + let progress = 0; + const progressInterval = setInterval(function () { + if (progress < 95) { + progress += Math.random() * 5; + progressBar.style.width = Math.min(progress, 95) + "%"; + } + }, 200); + + fetchFixes(appid) + .then(function (payload) { + if (payload && payload.success) { + const isGameInstalled = window.__LuaToolsGameIsInstalled === true; + showFixesResultsPopup(payload, isGameInstalled); + } else { + const errText = + payload && payload.error + ? String(payload.error) + : lt("Failed to check for fixes."); + ShowLuaToolsAlert("LuaTools", errText); + } + }) + .catch(function () { + const msg = lt("Error checking for fixes"); + ShowLuaToolsAlert("LuaTools", msg); + }) + .finally(function () { + clearInterval(progressInterval); + progressBar.style.width = "100%"; + setTimeout(function () { + try { + const l = document.querySelector(".luatools-loading-fixes-overlay"); + if (l) l.remove(); + } catch (_) {} + }, 300); + }); + } + + // Apply Fix function + function applyFix(appid, downloadUrl, fixType, gameName, resultsOverlay) { + try { + // Close results overlay + if (resultsOverlay) { + resultsOverlay.remove(); + } + + // Check if we have the game install path + if (!window.__LuaToolsGameInstallPath) { + const msg = lt("Game install path not found"); + ShowLuaToolsAlert("LuaTools", msg); + return; + } + + backendLog("LuaTools: Applying fix " + fixType + " for appid " + appid); + + // Start the download and extraction process + Millennium.callServerMethod("luatools", "ApplyGameFix", { + appid: appid, + downloadUrl: downloadUrl, + installPath: window.__LuaToolsGameInstallPath, + fixType: fixType, + gameName: gameName || "", + contentScriptQuery: "", + }) + .then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success) { + // Show download progress popup similar to Add via LuaTools + showFixDownloadProgress(appid, fixType); + } else { + const errorKey = + payload && payload.error ? String(payload.error) : ""; + const errorMsg = + errorKey && + (errorKey.startsWith("menu.error.") || + errorKey.startsWith("common.")) + ? t(errorKey) + : errorKey || lt("Failed to start fix download"); + ShowLuaToolsAlert("LuaTools", errorMsg); + } + } catch (err) { + backendLog("LuaTools: ApplyGameFix response error: " + err); + const msg = lt("Error applying fix"); + ShowLuaToolsAlert("LuaTools", msg); + } + }) + .catch(function (err) { + backendLog("LuaTools: ApplyGameFix error: " + err); + const msg = lt("Error applying fix"); + ShowLuaToolsAlert("LuaTools", msg); + }); + } catch (err) { + backendLog("LuaTools: applyFix error: " + err); + } + } + + // Show fix download progress popup + function showFixDownloadProgress(appid, fixType) { + // Reuse the download popup UI from Add via LuaTools + if (document.querySelector(".luatools-overlay")) return; + + ensureLuaToolsStyles(); + ensureFontAwesome(); + const overlay = document.createElement("div"); + overlay.className = "luatools-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:480px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const title = document.createElement("div"); + const applyFixTitleColors = getThemeColors(); + title.style.cssText = `font-size:22px;color:${applyFixTitleColors.text};margin-bottom:16px;font-weight:600;`; + title.textContent = lt("Applying {fix}").replace("{fix}", fixType); + + const body = document.createElement("div"); + const applyFixBodyColors = getThemeColors(); + body.style.cssText = `font-size:15px;line-height:1.6;margin-bottom:20px;color:${applyFixBodyColors.textSecondary};`; + body.innerHTML = + '
' + lt("Downloading...") + "
"; + + const btnRow = document.createElement("div"); + btnRow.className = "lt-fix-btn-row"; + btnRow.style.cssText = + "margin-top:16px;display:flex;gap:12px;justify-content:center;"; + + const hideBtn = document.createElement("a"); + hideBtn.href = "#"; + hideBtn.className = "luatools-btn"; + hideBtn.style.flex = "1"; + hideBtn.innerHTML = `${lt("Hide")}`; + hideBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + }; + btnRow.appendChild(hideBtn); + + const cancelBtn = document.createElement("a"); + cancelBtn.href = "#"; + cancelBtn.className = "luatools-btn primary"; + cancelBtn.style.flex = "1"; + cancelBtn.innerHTML = `${lt("Cancel")}`; + cancelBtn.onclick = function (e) { + e.preventDefault(); + if (cancelBtn.dataset.pending === "1") return; + cancelBtn.dataset.pending = "1"; + const span = cancelBtn.querySelector("span"); + if (span) span.textContent = lt("Cancelling..."); + const msgEl = document.getElementById("lt-fix-progress-msg"); + if (msgEl) msgEl.textContent = lt("Cancelling..."); + Millennium.callServerMethod("luatools", "CancelApplyFix", { + appid: appid, + contentScriptQuery: "", + }) + .then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (!payload || payload.success !== true) { + throw new Error( + (payload && payload.error) || lt("Cancellation failed"), + ); + } + } catch (err) { + cancelBtn.dataset.pending = "0"; + if (span) span.textContent = lt("Cancel"); + const msgEl2 = document.getElementById("lt-fix-progress-msg"); + if (msgEl2 && msgEl2.dataset.last) + msgEl2.textContent = msgEl2.dataset.last; + backendLog("LuaTools: CancelApplyFix response error: " + err); + const msg = lt("Failed to cancel fix download"); + ShowLuaToolsAlert("LuaTools", msg); + } + }) + .catch(function (err) { + cancelBtn.dataset.pending = "0"; + const span2 = cancelBtn.querySelector("span"); + if (span2) span2.textContent = lt("Cancel"); + const msgEl2 = document.getElementById("lt-fix-progress-msg"); + if (msgEl2 && msgEl2.dataset.last) + msgEl2.textContent = msgEl2.dataset.last; + backendLog("LuaTools: CancelApplyFix error: " + err); + const msg = lt("Failed to cancel fix download"); + ShowLuaToolsAlert("LuaTools", msg); + }); + }; + btnRow.appendChild(cancelBtn); + + modal.appendChild(title); + modal.appendChild(body); + modal.appendChild(btnRow); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + + // Start polling for progress + pollFixProgress(appid, fixType); + } + + function replaceFixButtonsWithClose(overlayEl) { + if (!overlayEl) return; + const btnRow = overlayEl.querySelector(".lt-fix-btn-row"); + if (!btnRow) return; + btnRow.innerHTML = ""; + btnRow.style.cssText = + "margin-top:16px;display:flex;justify-content:flex-end;"; + const closeBtn = document.createElement("a"); + closeBtn.href = "#"; + closeBtn.className = "luatools-btn primary"; + closeBtn.style.minWidth = "140px"; + closeBtn.innerHTML = `${lt("Close")}`; + closeBtn.onclick = function (e) { + e.preventDefault(); + overlayEl.remove(); + }; + btnRow.appendChild(closeBtn); + } + + // Poll fix download and extraction progress + function pollFixProgress(appid, fixType) { + const poll = function () { + try { + const overlayEl = document.querySelector(".luatools-overlay"); + if (!overlayEl) return; // Stop if overlay was closed + + Millennium.callServerMethod("luatools", "GetApplyFixStatus", { + appid: appid, + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success && payload.state) { + const state = payload.state; + const msgEl = document.getElementById("lt-fix-progress-msg"); + + if (state.status === "downloading") { + const pct = + state.totalBytes > 0 + ? Math.floor((state.bytesRead / state.totalBytes) * 100) + : 0; + if (msgEl) { + msgEl.textContent = lt("Downloading: {percent}%").replace( + "{percent}", + pct, + ); + msgEl.dataset.last = msgEl.textContent; + } + setTimeout(poll, 500); + } else if (state.status === "extracting") { + if (msgEl) { + msgEl.textContent = lt("Extracting to game folder..."); + msgEl.dataset.last = msgEl.textContent; + } + setTimeout(poll, 500); + } else if (state.status === "cancelled") { + if (msgEl) + msgEl.textContent = lt("Cancelled: {reason}").replace( + "{reason}", + state.error || lt("Cancelled by user"), + ); + replaceFixButtonsWithClose(overlayEl); + return; + } else if (state.status === "done") { + if (msgEl) + msgEl.textContent = lt("{fix} applied successfully!").replace( + "{fix}", + fixType, + ); + replaceFixButtonsWithClose(overlayEl); + return; // Stop polling + } else if (state.status === "failed") { + if (msgEl) + msgEl.textContent = lt("Failed: {error}").replace( + "{error}", + state.error || lt("Unknown error"), + ); + replaceFixButtonsWithClose(overlayEl); + return; // Stop polling + } else { + // Continue polling for unknown states + setTimeout(poll, 500); + } + } + } catch (err) { + backendLog("LuaTools: GetApplyFixStatus error: " + err); + } + }); + } catch (err) { + backendLog("LuaTools: pollFixProgress error: " + err); + } + }; + setTimeout(poll, 500); + } + + // Show un-fix progress popup + function showUnfixProgress(appid) { + // Remove any existing popup + try { + const old = document.querySelector(".luatools-unfix-overlay"); + if (old) old.remove(); + } catch (_) {} + + ensureLuaToolsStyles(); + ensureFontAwesome(); + const overlay = document.createElement("div"); + overlay.className = "luatools-unfix-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:480px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const title = document.createElement("div"); + const unfixTitleColors = getThemeColors(); + title.style.cssText = `font-size:22px;color:${unfixTitleColors.text};margin-bottom:16px;font-weight:600;`; + title.textContent = lt("Un-Fixing game"); + + const body = document.createElement("div"); + body.style.cssText = + "font-size:15px;line-height:1.6;margin-bottom:20px;color:#c7d5e0;"; + body.innerHTML = + '
' + + lt("Removing fix files...") + + "
"; + + const btnRow = document.createElement("div"); + btnRow.style.cssText = + "margin-top:16px;display:flex;justify-content:center;"; + const hideBtn = document.createElement("a"); + hideBtn.href = "#"; + hideBtn.className = "luatools-btn"; + hideBtn.style.minWidth = "140px"; + hideBtn.innerHTML = `${lt("Hide")}`; + hideBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + }; + btnRow.appendChild(hideBtn); + + modal.appendChild(title); + modal.appendChild(body); + modal.appendChild(btnRow); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + + // Start polling for progress + pollUnfixProgress(appid); + } + + // Poll un-fix progress + function pollUnfixProgress(appid) { + const poll = function () { + try { + const overlayEl = document.querySelector(".luatools-unfix-overlay"); + if (!overlayEl) return; // Stop if overlay was closed + + Millennium.callServerMethod("luatools", "GetUnfixStatus", { + appid: appid, + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success && payload.state) { + const state = payload.state; + const msgEl = document.getElementById("lt-unfix-progress-msg"); + + if (state.status === "removing") { + if (msgEl) + msgEl.textContent = + state.progress || lt("Removing fix files..."); + // Continue polling + setTimeout(poll, 500); + } else if (state.status === "done") { + const filesRemoved = state.filesRemoved || 0; + if (msgEl) + msgEl.textContent = lt( + "Removed {count} files. Running Steam verification...", + ).replace("{count}", filesRemoved); + // Change Hide button to Close button + try { + const btnRow = overlayEl.querySelector( + 'div[style*="justify-content:center"]', + ); + if (btnRow) { + btnRow.innerHTML = ""; + const closeBtn = document.createElement("a"); + closeBtn.href = "#"; + closeBtn.className = "luatools-btn primary"; + closeBtn.style.minWidth = "140px"; + closeBtn.innerHTML = `${lt("Close")}`; + closeBtn.onclick = function (e) { + e.preventDefault(); + overlayEl.remove(); + }; + btnRow.appendChild(closeBtn); + } + } catch (_) {} + + // Trigger Steam verification after a short delay + setTimeout(function () { + try { + const verifyUrl = "steam://validate/" + appid; + window.location.href = verifyUrl; + backendLog("LuaTools: Running verify for appid " + appid); + } catch (_) {} + }, 1000); + + return; // Stop polling + } else if (state.status === "failed") { + if (msgEl) + msgEl.textContent = lt("Failed: {error}").replace( + "{error}", + state.error || lt("Unknown error"), + ); + // Change Hide button to Close button + try { + const btnRow = overlayEl.querySelector( + 'div[style*="justify-content:center"]', + ); + if (btnRow) { + btnRow.innerHTML = ""; + const closeBtn = document.createElement("a"); + closeBtn.href = "#"; + closeBtn.className = "luatools-btn primary"; + closeBtn.style.minWidth = "140px"; + closeBtn.innerHTML = `${lt("Close")}`; + closeBtn.onclick = function (e) { + e.preventDefault(); + overlayEl.remove(); + }; + btnRow.appendChild(closeBtn); + } + } catch (_) {} + return; // Stop polling + } else { + // Continue polling for unknown states + setTimeout(poll, 500); + } + } + } catch (err) { + backendLog("LuaTools: GetUnfixStatus error: " + err); + } + }); + } catch (err) { + backendLog("LuaTools: pollUnfixProgress error: " + err); + } + }; + setTimeout(poll, 500); + } + + function fetchSettingsConfig(forceRefresh) { + try { + if ( + !forceRefresh && + window.__LuaToolsSettings && + Array.isArray(window.__LuaToolsSettings.schema) + ) { + return Promise.resolve(window.__LuaToolsSettings); + } + } catch (_) {} + + if ( + typeof Millennium === "undefined" || + typeof Millennium.callServerMethod !== "function" + ) { + return Promise.reject(new Error(lt("LuaTools backend unavailable"))); + } + + return Millennium.callServerMethod("luatools", "GetSettingsConfig", { + contentScriptQuery: "", + }).then(function (res) { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (!payload || payload.success !== true) { + const errorMsg = + payload && payload.error + ? String(payload.error) + : t("settings.error", "Failed to load settings."); + throw new Error(errorMsg); + } + const config = { + schemaVersion: payload.schemaVersion || 0, + schema: Array.isArray(payload.schema) ? payload.schema : [], + values: + payload && payload.values && typeof payload.values === "object" + ? payload.values + : {}, + language: payload && payload.language ? String(payload.language) : "en", + locales: Array.isArray(payload && payload.locales) + ? payload.locales + : [], + translations: + payload && + payload.translations && + typeof payload.translations === "object" + ? payload.translations + : {}, + lastFetched: Date.now(), + }; + applyTranslationBundle({ + language: config.language, + locales: config.locales, + strings: config.translations, + }); + window.__LuaToolsSettings = config; + return config; + }); + } + + function initialiseSettingsDraft(config) { + const values = JSON.parse(JSON.stringify((config && config.values) || {})); + if (!config || !Array.isArray(config.schema)) { + return values; + } + for (let i = 0; i < config.schema.length; i++) { + const group = config.schema[i]; + if (!group || !group.key) continue; + if ( + typeof values[group.key] !== "object" || + values[group.key] === null || + Array.isArray(values[group.key]) + ) { + values[group.key] = {}; + } + const options = Array.isArray(group.options) ? group.options : []; + for (let j = 0; j < options.length; j++) { + const option = options[j]; + if (!option || !option.key) continue; + if (typeof values[group.key][option.key] === "undefined") { + values[group.key][option.key] = option.default; + } + } + } + return values; + } + + function showSettingsManagerPopup(forceRefresh, onBack) { + if (document.querySelector(".luatools-settings-manager-overlay")) return; + + try { + const mainOverlay = document.querySelector(".luatools-settings-overlay"); + if (mainOverlay) mainOverlay.remove(); + } catch (_) {} + + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement("div"); + overlay.className = "luatools-settings-manager-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100000;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const settingsModalColors = getThemeColors(); + modal.style.cssText = `position:relative;background:${settingsModalColors.modalBg};color:${settingsModalColors.text};border:1px solid ${settingsModalColors.border};border-radius:16px;width:750px;max-height:88vh;padding:0;display:flex;flex-direction:column;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${settingsModalColors.shadowRgba};animation:slideUp 0.12s ease-out;overflow:hidden;`; + + const header = document.createElement("div"); + const settingsHeaderColors = getThemeColors(); + header.style.cssText = `display:flex;justify-content:space-between;align-items:center;padding:20px 24px 16px;border-bottom:1px solid ${settingsHeaderColors.border.replace("0.3", "0.15")};`; + + const title = document.createElement("div"); + const settingsTitleColors = getThemeColors(); + title.style.cssText = `font-size:22px;color:${settingsTitleColors.text};font-weight:600;`; + title.textContent = t("settings.title", "LuaTools · Settings"); + + const iconButtons = document.createElement("div"); + iconButtons.style.cssText = "display:flex;gap:12px;"; + + const discordIconBtn = document.createElement("a"); + discordIconBtn.href = "#"; + const discordBtnColors = getThemeColors(); + discordIconBtn.style.cssText = `display:flex;align-items:center;justify-content:center;width:36px;height:36px;background:rgba(${discordBtnColors.rgbString},0.08);border:1px solid ${discordBtnColors.border};border-radius:8px;color:${discordBtnColors.accent};font-size:16px;text-decoration:none;transition:all 0.2s ease;cursor:pointer;`; + discordIconBtn.innerHTML = ''; + discordIconBtn.title = t("menu.discord", "Discord"); + discordIconBtn.onmouseover = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.18)`; + this.style.transform = "translateY(-1px)"; + this.style.boxShadow = `0 4px 12px ${c.shadow}`; + this.style.borderColor = c.accent; + }; + discordIconBtn.onmouseout = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.08)`; + this.style.transform = "translateY(0)"; + this.style.boxShadow = "none"; + this.style.borderColor = c.border; + }; + iconButtons.appendChild(discordIconBtn); + + const closeIconBtn = document.createElement("a"); + closeIconBtn.href = "#"; + const closeBtnColors = getThemeColors(); + closeIconBtn.style.cssText = `display:flex;align-items:center;justify-content:center;width:36px;height:36px;background:rgba(${closeBtnColors.rgbString},0.08);border:1px solid ${closeBtnColors.border};border-radius:8px;color:${closeBtnColors.accent};font-size:16px;text-decoration:none;transition:all 0.2s ease;cursor:pointer;`; + closeIconBtn.innerHTML = ''; + closeIconBtn.title = t("settings.close", "Close"); + closeIconBtn.onmouseover = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.18)`; + this.style.transform = "translateY(-1px)"; + this.style.boxShadow = `0 4px 12px ${c.shadow}`; + this.style.borderColor = c.accent; + }; + closeIconBtn.onmouseout = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.08)`; + this.style.transform = "translateY(0)"; + this.style.boxShadow = "none"; + this.style.borderColor = c.border; + }; + iconButtons.appendChild(closeIconBtn); + + // Search bar container + const searchContainer = document.createElement("div"); + const searchColors = getThemeColors(); + searchContainer.style.cssText = + "padding:16px 24px;border-bottom:1px solid rgba(255,255,255,0.06);"; + + const searchWrap = document.createElement("div"); + searchWrap.style.cssText = `display:flex;align-items:center;gap:10px;padding:10px 14px;background:${searchColors.bgTertiary};border:1px solid ${searchColors.border};border-radius:10px;transition:all 0.2s ease;`; + + const searchIcon = document.createElement("i"); + searchIcon.className = "fa-solid fa-magnifying-glass"; + searchIcon.style.cssText = `color:${searchColors.textSecondary};font-size:14px;flex-shrink:0;`; + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.id = "luatools-settings-search"; + searchInput.placeholder = t( + "settings.search.placeholder", + "Search settings, games, fixes...", + ); + searchInput.style.cssText = `flex:1;background:transparent;border:none;outline:none;color:${searchColors.text};font-size:14px;`; + searchInput.setAttribute("autocomplete", "off"); + + const searchClear = document.createElement("a"); + searchClear.href = "#"; + searchClear.style.cssText = `display:none;color:${searchColors.textSecondary};font-size:14px;text-decoration:none;padding:4px;flex-shrink:0;`; + searchClear.innerHTML = ''; + searchClear.title = t("settings.search.clear", "Clear search"); + + searchWrap.onfocus = function () { + searchWrap.style.borderColor = searchColors.accent; + }; + searchInput.onfocus = function () { + const c = getThemeColors(); + searchWrap.style.borderColor = c.accent; + searchWrap.style.boxShadow = `0 0 0 2px rgba(${c.rgbString},0.2)`; + }; + searchInput.onblur = function () { + const c = getThemeColors(); + searchWrap.style.borderColor = c.border; + searchWrap.style.boxShadow = "none"; + }; + + searchWrap.appendChild(searchIcon); + searchWrap.appendChild(searchInput); + searchWrap.appendChild(searchClear); + searchContainer.appendChild(searchWrap); + + const contentWrap = document.createElement("div"); + contentWrap.id = "luatools-content-wrap"; + const contentColors = getThemeColors(); + contentWrap.style.cssText = `flex:1 1 auto;overflow-y:auto;overflow-x:hidden;padding:24px;margin:0;background:transparent;`; + + // Add mouse mode tip for Big Picture + if (window.__LUATOOLS_IS_BIG_PICTURE__) { + const tip = document.createElement("div"); + const tipColors = getThemeColors(); + tip.style.cssText = `background:rgba(${tipColors.rgbString},0.08);border:1px solid ${tipColors.border};padding:12px 16px;border-radius:8px;font-size:13px;color:${tipColors.textSecondary};margin-bottom:20px;line-height:1.5;display:flex;align-items:center;gap:10px;`; + tip.innerHTML = + '' + + t( + "bigpicture.mouseTip", + "To use mouse mode in Steam: Guide Button + Right Joystick, click with RB", + ); + contentWrap.appendChild(tip); + } + + const btnRow = document.createElement("div"); + btnRow.style.cssText = + "padding:16px 24px 20px;display:flex;gap:10px;justify-content:space-between;align-items:center;border-top:1px solid rgba(255,255,255,0.06);"; + + const backBtn = createSettingsButton( + "back", + "", + false, + '', + ); + const rightButtons = document.createElement("div"); + rightButtons.style.cssText = "display:flex;gap:10px;"; + const refreshBtn = createSettingsButton( + "refresh", + "", + false, + '', + ); + const saveBtn = createSettingsButton( + "save", + "", + true, + '', + ); + + modal.appendChild(header); + modal.appendChild(searchContainer); + modal.appendChild(contentWrap); + modal.appendChild(btnRow); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + + const state = { + config: null, + draft: {}, + searchQuery: "", + }; + + // Search functionality + let searchDebounceTimer = null; + searchInput.addEventListener("input", function () { + const query = searchInput.value.trim().toLowerCase(); + searchClear.style.display = query ? "block" : "none"; + + // Debounce the search + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(function () { + state.searchQuery = query; + applySearchFilter(); + }, 150); + }); + + searchClear.addEventListener("click", function (e) { + e.preventDefault(); + searchInput.value = ""; + searchClear.style.display = "none"; + state.searchQuery = ""; + applySearchFilter(); + searchInput.focus(); + }); + + function applySearchFilter() { + const query = state.searchQuery; + + // Filter settings options + const optionEls = contentWrap.querySelectorAll("[data-setting-option]"); + optionEls.forEach(function (el) { + const searchText = (el.dataset.searchText || "").toLowerCase(); + if (!query || searchText.includes(query)) { + el.style.display = ""; + } else { + el.style.display = "none"; + } + }); + + // Filter settings groups (hide if all options hidden) + const groupEls = contentWrap.querySelectorAll("[data-setting-group]"); + groupEls.forEach(function (groupEl) { + const visibleOptions = groupEl.querySelectorAll( + '[data-setting-option]:not([style*="display: none"])', + ); + if (!query || visibleOptions.length > 0) { + groupEl.style.display = ""; + } else { + groupEl.style.display = "none"; + } + }); + + // Filter installed fixes + const fixItems = contentWrap.querySelectorAll("[data-fix-item]"); + let visibleFixes = 0; + fixItems.forEach(function (el) { + const searchText = (el.dataset.searchText || "").toLowerCase(); + if (!query || searchText.includes(query)) { + el.style.display = ""; + visibleFixes++; + } else { + el.style.display = "none"; + } + }); + + // Show/hide fixes empty state + const fixesSection = document.getElementById( + "luatools-installed-fixes-section", + ); + const fixesEmptySearch = fixesSection + ? fixesSection.querySelector(".search-empty-state") + : null; + if (fixesSection && query && fixItems.length > 0 && visibleFixes === 0) { + if (!fixesEmptySearch) { + const emptyEl = document.createElement("div"); + emptyEl.className = "search-empty-state"; + const emptyColors = getThemeColors(); + emptyEl.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};text-align:center;margin-top:10px;`; + emptyEl.textContent = t( + "settings.search.noResults", + "No matches found", + ); + const listContainer = fixesSection.querySelector( + "#luatools-fixes-list", + ); + if (listContainer) listContainer.appendChild(emptyEl); + } + } else if (fixesEmptySearch) { + fixesEmptySearch.remove(); + } + + // Filter installed lua scripts + const luaItems = contentWrap.querySelectorAll("[data-lua-item]"); + let visibleLua = 0; + luaItems.forEach(function (el) { + const searchText = (el.dataset.searchText || "").toLowerCase(); + if (!query || searchText.includes(query)) { + el.style.display = ""; + visibleLua++; + } else { + el.style.display = "none"; + } + }); + + // Show/hide lua empty state + const luaSection = document.getElementById( + "luatools-installed-lua-section", + ); + const luaEmptySearch = luaSection + ? luaSection.querySelector(".search-empty-state") + : null; + if (luaSection && query && luaItems.length > 0 && visibleLua === 0) { + if (!luaEmptySearch) { + const emptyEl = document.createElement("div"); + emptyEl.className = "search-empty-state"; + const emptyColors = getThemeColors(); + emptyEl.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};text-align:center;margin-top:10px;`; + emptyEl.textContent = t( + "settings.search.noResults", + "No matches found", + ); + const listContainer = luaSection.querySelector("#luatools-lua-list"); + if (listContainer) listContainer.appendChild(emptyEl); + } + } else if (luaEmptySearch) { + luaEmptySearch.remove(); + } + } + + let refreshDefaultLabel = ""; + let saveDefaultLabel = ""; + let closeDefaultLabel = ""; + let backDefaultLabel = ""; + + function createSettingsButton(id, text, isPrimary, iconHtml) { + const btn = document.createElement("a"); + btn.id = "lt-settings-" + id; + btn.href = "#"; + const btnColors = getThemeColors(); + const hasText = text && text.trim().length > 0; + if (iconHtml) { + btn.innerHTML = hasText + ? iconHtml + "" + text + "" + : iconHtml; + } else { + btn.innerHTML = "" + text + ""; + } + + const btnSize = hasText + ? "padding:9px 16px;" + : "width:38px;height:38px;padding:0;"; + btn.style.cssText = `display:inline-flex;align-items:center;justify-content:center;${btnSize}background:rgba(${btnColors.rgbString},0.1);border:1px solid ${btnColors.border};border-radius:8px;color:${btnColors.text};font-size:14px;text-decoration:none;transition:all 0.2s ease;cursor:pointer;`; + + if (isPrimary) { + btn.style.background = `linear-gradient(135deg, rgba(${btnColors.rgbString},0.25) 0%, rgba(${btnColors.rgbString},0.15) 100%)`; + btn.style.borderColor = btnColors.accent; + } + + btn.onmouseover = function () { + if (this.dataset.disabled === "1") { + this.style.opacity = "0.6"; + this.style.cursor = "not-allowed"; + return; + } + const c = getThemeColors(); + if (isPrimary) { + this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.35) 0%, rgba(${c.rgbString},0.2) 100%)`; + } else { + this.style.background = `rgba(${c.rgbString},0.18)`; + } + this.style.transform = "translateY(-1px)"; + this.style.boxShadow = `0 4px 12px ${c.shadow}`; + }; + + btn.onmouseout = function () { + if (this.dataset.disabled === "1") { + this.style.opacity = "0.5"; + this.style.transform = "none"; + this.style.boxShadow = "none"; + return; + } + const c = getThemeColors(); + if (isPrimary) { + this.style.background = `linear-gradient(135deg, rgba(${c.rgbString},0.25) 0%, rgba(${c.rgbString},0.15) 100%)`; + } else { + this.style.background = `rgba(${c.rgbString},0.1)`; + } + this.style.transform = "translateY(0)"; + this.style.boxShadow = "none"; + }; + + if (isPrimary) { + btn.dataset.disabled = "1"; + btn.style.opacity = "0.5"; + btn.style.cursor = "not-allowed"; + } + + return btn; + } + + header.appendChild(title); + header.appendChild(iconButtons); + + // Inject scrollbar styles for content area + const scrollbarStyle = document.createElement("style"); + scrollbarStyle.textContent = + "#luatools-content-wrap::-webkit-scrollbar { width: 8px; } " + + "#luatools-content-wrap::-webkit-scrollbar-track { background: transparent; } " + + "#luatools-content-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } " + + "#luatools-content-wrap::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.25); }"; + modal.appendChild(scrollbarStyle); + + function applyStaticTranslations() { + title.textContent = t("settings.title", "LuaTools · Settings"); + refreshBtn.title = t("settings.refresh", "Refresh"); + saveBtn.title = t("settings.save", "Save Settings"); + backBtn.title = t("Back", "Back"); + discordIconBtn.title = t("menu.discord", "Discord"); + closeIconBtn.title = t("settings.close", "Close"); + } + applyStaticTranslations(); + + function setStatus(text, color) { + let statusLine = contentWrap.querySelector(".luatools-settings-status"); + if (!statusLine) { + statusLine = document.createElement("div"); + statusLine.className = "luatools-settings-status"; + statusLine.style.cssText = + "font-size:13px;margin-bottom:16px;color:#c7d5e0;text-align:center;padding:6px 12px;background:rgba(255,255,255,0.03);border-radius:6px;"; + contentWrap.insertBefore(statusLine, contentWrap.firstChild); + } + if (!text || text.trim() === "") { + statusLine.style.display = "none"; + return; + } + statusLine.style.display = ""; + statusLine.textContent = text; + statusLine.style.color = color || "#c7d5e0"; + } + + function ensureDraftGroup(groupKey) { + if (!state.draft[groupKey] || typeof state.draft[groupKey] !== "object") { + state.draft[groupKey] = {}; + } + return state.draft[groupKey]; + } + + function collectChanges() { + if (!state.config || !Array.isArray(state.config.schema)) { + return {}; + } + const changes = {}; + for (let i = 0; i < state.config.schema.length; i++) { + const group = state.config.schema[i]; + if (!group || !group.key) continue; + const options = Array.isArray(group.options) ? group.options : []; + const draftGroup = state.draft[group.key] || {}; + const originalGroup = + (state.config.values && state.config.values[group.key]) || {}; + const groupChanges = {}; + for (let j = 0; j < options.length; j++) { + const option = options[j]; + if (!option || !option.key) continue; + const newValue = draftGroup.hasOwnProperty(option.key) + ? draftGroup[option.key] + : option.default; + const oldValue = originalGroup.hasOwnProperty(option.key) + ? originalGroup[option.key] + : option.default; + if (newValue !== oldValue) { + groupChanges[option.key] = newValue; + } + } + if (Object.keys(groupChanges).length > 0) { + changes[group.key] = groupChanges; + } + } + return changes; + } + + function updateSaveState() { + const hasChanges = Object.keys(collectChanges()).length > 0; + const isBusy = saveBtn.dataset.busy === "1"; + + let hubcapKey = ""; + let foundHubcapKey = false; + for (const group in state.draft) { + if ( + state.draft[group] && + state.draft[group].hasOwnProperty("morrenusApiKey") + ) { + hubcapKey = state.draft[group].morrenusApiKey; + foundHubcapKey = true; + break; + } + } + + let isValid = true; + if (foundHubcapKey && hubcapKey) { + isValid = /^smm_[0-9a-f]{96}$/.test(hubcapKey); + } + + if (hasChanges && !isBusy && isValid) { + saveBtn.dataset.disabled = "0"; + saveBtn.style.opacity = ""; + saveBtn.style.cursor = "pointer"; + } else { + saveBtn.dataset.disabled = "1"; + saveBtn.style.opacity = "0.6"; + saveBtn.style.cursor = "not-allowed"; + } + + if (foundHubcapKey && hubcapKey && !isValid) { + setStatus(lt("Invalid Morrenus API Key format"), "#ff5c5c"); + } + } + + function optionLabelKey(groupKey, optionKey) { + if (groupKey === "general") { + if (optionKey === "language") return "settings.language.label"; + if (optionKey === "useSteamLanguage") + return "settings.useSteamLanguage.label"; + if (optionKey === "donateKeys") return "settings.donateKeys.label"; + if (optionKey === "theme") return "settings.theme.label"; + if (optionKey === "fastDownload") return "settings.fastDownload.label"; + if (optionKey === "morrenusApiKey") + return "settings.morrenusApiKey.label"; + } + return null; + } + + function optionDescriptionKey(groupKey, optionKey) { + if (groupKey === "general") { + if (optionKey === "language") return "settings.language.description"; + if (optionKey === "useSteamLanguage") + return "settings.useSteamLanguage.description"; + if (optionKey === "donateKeys") + return "settings.donateKeys.description"; + if (optionKey === "theme") return "settings.theme.description"; + if (optionKey === "fastDownload") + return "settings.fastDownload.description"; + if (optionKey === "morrenusApiKey") + return "settings.morrenusApiKey.description"; + } + return null; + } + + function optionPlaceholderKey(groupKey, optionKey) { + if (groupKey === "general") { + if (optionKey === "morrenusApiKey") + return "settings.morrenusApiKey.placeholder"; + } + return null; + } + + function renderSettings() { + contentWrap.innerHTML = ""; + if ( + !state.config || + !Array.isArray(state.config.schema) || + state.config.schema.length === 0 + ) { + const emptyState = document.createElement("div"); + const emptyColors = getThemeColors(); + emptyState.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};`; + emptyState.textContent = t( + "settings.empty", + "No settings available yet.", + ); + contentWrap.appendChild(emptyState); + updateSaveState(); + return; + } + + for (let i = 0; i < state.config.schema.length; i++) { + const group = state.config.schema[i]; + if (!group || !group.key) continue; + + const groupEl = document.createElement("div"); + const groupCardColors = getThemeColors(); + groupEl.style.cssText = `background:rgba(${groupCardColors.rgbString},0.04);border:1px solid ${groupCardColors.border};border-radius:10px;padding:18px 20px;margin-bottom:16px;`; + groupEl.dataset.settingGroup = group.key; + + const groupTitle = document.createElement("div"); + const titleText = t("settings." + group.key, group.label || group.key); + if (group.key === "general") { + const generalTitleColors = getThemeColors(); + groupTitle.innerHTML = `${titleText}`; + groupTitle.style.cssText = `font-size:19px;color:${generalTitleColors.text};margin-bottom:14px;font-weight:600;display:flex;align-items:center;`; + } else { + const otherTitleColors = getThemeColors(); + groupTitle.style.cssText = `font-size:15px;font-weight:600;color:${otherTitleColors.accent};margin-bottom:6px;`; + } + groupEl.appendChild(groupTitle); + + if (group.description && group.key !== "general") { + const groupDesc = document.createElement("div"); + const descColors = getThemeColors(); + groupDesc.style.cssText = `margin-bottom:14px;font-size:12px;color:${descColors.textSecondary};line-height:1.5;`; + groupDesc.textContent = t( + "settings." + group.key + "Description", + group.description, + ); + groupEl.appendChild(groupDesc); + } + + const options = Array.isArray(group.options) ? group.options : []; + for (let j = 0; j < options.length; j++) { + const option = options[j]; + if (!option || !option.key) continue; + + ensureDraftGroup(group.key); + if (!state.draft[group.key].hasOwnProperty(option.key)) { + const sourceGroup = + (state.config.values && state.config.values[group.key]) || {}; + const initialValue = sourceGroup.hasOwnProperty(option.key) + ? sourceGroup[option.key] + : option.default; + state.draft[group.key][option.key] = initialValue; + } + + const optionEl = document.createElement("div"); + const optionColors = getThemeColors(); + const alignItems = + option.type === "select" || option.type === "text" + ? "center" + : "flex-start"; + optionEl.style.cssText = + j === 0 + ? `padding-top:0;display:flex;justify-content:space-between;align-items:${alignItems};gap:16px;` + : `margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.05);display:flex;justify-content:space-between;align-items:${alignItems};gap:16px;`; + optionEl.dataset.settingOption = option.key; + + const labelWrap = document.createElement("div"); + labelWrap.className = "luatools-toggle-label-wrap"; + labelWrap.style.flex = "1"; + + const optionLabel = document.createElement("div"); + const optLabelColors = getThemeColors(); + optionLabel.style.cssText = `font-size:14px;font-weight:500;color:${optLabelColors.text};`; + const labelKey = optionLabelKey(group.key, option.key); + const labelText = t( + labelKey || "settings." + group.key + "." + option.key + ".label", + option.label || option.key, + ); + optionLabel.textContent = labelText; + + // Build search text from label, description, and key + const descText = option.description || ""; + optionEl.dataset.searchText = ( + labelText + + " " + + descText + + " " + + option.key + + " " + + group.key + ).toLowerCase(); + labelWrap.appendChild(optionLabel); + + if (option.description) { + const optionDesc = document.createElement("div"); + const optDescColors = getThemeColors(); + optionDesc.style.cssText = `margin-top:3px;font-size:12px;color:${optDescColors.textSecondary};line-height:1.45;`; + const descKey = optionDescriptionKey(group.key, option.key); + let descTextVal = t( + descKey || + "settings." + group.key + "." + option.key + ".description", + option.description, + ); + + // Special handling for hubcap link + if ( + descTextVal.includes("hubcapmanifest.com") || + descTextVal.includes("{link}") + ) { + const url = "https://hubcapmanifest.com"; + const linkHtml = `hubcapmanifest.com`; + if (descTextVal.includes("{link}")) { + descTextVal = descTextVal.replace("{link}", linkHtml); + } else { + descTextVal = descTextVal.replace( + "hubcapmanifest.com", + linkHtml, + ); + } + optionDesc.innerHTML = descTextVal; + + // Add event listener after appending to document or wait? + // Better: use a selector later or add it now if possible. + setTimeout(() => { + const link = document.getElementById("lt-hubcap-link"); + if (link) { + link.onclick = (e) => { + e.preventDefault(); + Millennium.callServerMethod("luatools", "OpenExternalUrl", { + url, + contentScriptQuery: "", + }); + }; + } + }, 0); + } else { + optionDesc.textContent = descTextVal; + } + labelWrap.appendChild(optionDesc); + } + + if (option.type === "toggle") { + optionEl.classList.add("luatools-toggle-container"); + optionEl.appendChild(labelWrap); + + const toggleWrap = document.createElement("div"); + toggleWrap.style.cssText = + "display:flex;align-items:center;flex-shrink:0;"; + + const toggleLabel = document.createElement("label"); + toggleLabel.className = "luatools-toggle"; + + const toggleInput = document.createElement("input"); + toggleInput.type = "checkbox"; + toggleInput.checked = state.draft[group.key][option.key] === true; + + const slider = document.createElement("span"); + slider.className = "luatools-slider"; + + toggleInput.addEventListener("change", function () { + state.draft[group.key][option.key] = toggleInput.checked; + updateSaveState(); + if (option.key === "useSteamLanguage") refreshDependencies(); + setStatus(t("settings.unsaved", "Unsaved changes"), "#c7d5e0"); + }); + + toggleLabel.appendChild(toggleInput); + toggleLabel.appendChild(slider); + toggleWrap.appendChild(toggleLabel); + optionEl.appendChild(toggleWrap); + } else { + optionEl.appendChild(labelWrap); + const controlWrap = document.createElement("div"); + + // If it's a select or any text input, align right like toggles + const isRightAligned = + option.type === "select" || option.type === "text"; + if (isRightAligned) { + optionEl.classList.add("luatools-toggle-container"); + optionEl.style.width = "100%"; + controlWrap.style.setProperty("width", "180px", "important"); + controlWrap.style.setProperty("flex-shrink", "0", "important"); + } else { + controlWrap.style.cssText = "margin-top:8px;"; + } + + optionEl.appendChild(controlWrap); + + if (option.type === "select") { + const selectEl = document.createElement("select"); + const selectColors = getThemeColors(); + selectEl.style.cssText = `width:100%;padding:7px 32px 7px 10px !important;background:${selectColors.bgTertiary} !important;color:${selectColors.text} !important;border:1px solid ${selectColors.border} !important;border-radius:6px !important;font-size:13px !important;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='${encodeURIComponent(selectColors.textSecondary)}' stroke-width='1.5' fill='none'/%3E%3C/svg%3E") !important;background-repeat:no-repeat !important;background-position:right 10px center !important;transition:border-color 0.2s ease,box-shadow 0.2s ease;`; + selectEl.onfocus = function () { + const c = getThemeColors(); + this.style.borderColor = c.accent + " !important"; + this.style.boxShadow = `0 0 0 2px rgba(${c.rgbString},0.2)`; + }; + selectEl.onblur = function () { + const c = getThemeColors(); + this.style.borderColor = c.border + " !important"; + this.style.boxShadow = "none"; + }; + + const choices = Array.isArray(option.choices) + ? option.choices + : []; + for (let c = 0; c < choices.length; c++) { + const choice = choices[c]; + if (!choice) continue; + const choiceOption = document.createElement("option"); + choiceOption.value = String(choice.value); + choiceOption.textContent = choice.label || choice.value; + selectEl.appendChild(choiceOption); + } + + const currentValue = state.draft[group.key][option.key]; + if (typeof currentValue !== "undefined") { + selectEl.value = String(currentValue); + } + + selectEl.addEventListener("change", function () { + state.draft[group.key][option.key] = selectEl.value; + try { + backendLog( + "LuaTools: " + + option.key + + " select changed to " + + selectEl.value, + ); + } catch (_) {} + + // If theme changed, apply it immediately + if (group.key === "general" && option.key === "theme") { + try { + backendLog( + "LuaTools: Theme change detected, new value: " + + selectEl.value, + ); + } catch (_) {} + // Update the settings cache so getCurrentTheme() returns the new value + if ( + window.__LuaToolsSettings && + window.__LuaToolsSettings.values + ) { + if (!window.__LuaToolsSettings.values.general) { + window.__LuaToolsSettings.values.general = {}; + } + window.__LuaToolsSettings.values.general.theme = + selectEl.value; + try { + backendLog( + "LuaTools: Updated cache, theme is now: " + + window.__LuaToolsSettings.values.general.theme, + ); + } catch (_) {} + } + // Reload styles immediately + ensureLuaToolsStyles(); + + // Update all modal elements with new theme colors + setTimeout(function () { + const colors = getThemeColors(); + + // Update modal background and border + const modalEl = + overlay && + overlay.querySelector( + '[style*="background:linear-gradient"]', + ); + if (modalEl) { + modalEl.style.background = colors.modalBg; + modalEl.style.borderColor = colors.border; + } + + // Update header border + const headerEl = + overlay && + overlay.querySelector('[style*="border-bottom"]'); + if (headerEl) { + headerEl.style.borderBottomColor = colors.border.replace( + "0.3", + "0.2", + ); + } + + // Update all title and text colors + const titles = + overlay && + overlay.querySelectorAll('[style*="text-shadow"]'); + if (titles) { + titles.forEach(function (title) { + title.style.backgroundImage = colors.gradientLight; + }); + } + + // Update content wrapper border + const contentWrapEl = + overlay && + overlay.querySelector("#luatools-content-wrap"); + if (contentWrapEl) { + contentWrapEl.style.borderColor = colors.border; + contentWrapEl.style.background = colors.bgContainer; + } + + // Re-render the settings content + renderSettings(); + }, 50); + + // Auto-save theme changes after a brief delay + setTimeout(function () { + if ( + saveBtn && + saveBtn.dataset.disabled !== "1" && + saveBtn.dataset.busy !== "1" + ) { + saveBtn.click(); + } + }, 150); + } + + updateSaveState(); + setStatus(t("settings.unsaved", "Unsaved changes"), "#c7d5e0"); + }); + + controlWrap.appendChild(selectEl); + } else if (option.type === "text") { + const textInput = document.createElement("input"); + textInput.type = + option.key === "morrenusApiKey" ? "password" : "text"; + const textColors = getThemeColors(); + const placeholderKey = optionPlaceholderKey( + group.key, + option.key, + ); + const placeholder = t( + placeholderKey || "", + option.metadata && option.metadata.placeholder + ? String(option.metadata.placeholder) + : "", + ); + textInput.placeholder = placeholder; + textInput.style.cssText = `width:180px !important;padding:7px 12px !important;background:${textColors.bgTertiary} !important;color:${textColors.text} !important;border:1px solid ${textColors.border} !important;border-radius:6px !important;font-size:13px !important;box-sizing:border-box !important;transition:border-color 0.2s ease, box-shadow 0.2s ease;`; + + const currentValue = state.draft[group.key][option.key]; + if ( + typeof currentValue !== "undefined" && + currentValue !== null + ) { + textInput.value = String(currentValue); + } + + textInput.addEventListener("input", function () { + state.draft[group.key][option.key] = textInput.value; + updateSaveState(); + setStatus(t("settings.unsaved", "Unsaved changes"), "#c7d5e0"); + }); + + textInput.addEventListener("focus", function () { + textInput.style.borderColor = textColors.accent + " !important"; + textInput.style.boxShadow = `0 0 0 2px rgba(${textColors.rgbString},0.2)`; + textInput.style.outline = "none"; + }); + + textInput.addEventListener("blur", function () { + textInput.style.borderColor = textColors.border + " !important"; + textInput.style.boxShadow = "none"; + }); + + controlWrap.appendChild(textInput); + + if (option.key === "morrenusApiKey") { + const statsDiv = document.createElement("div"); + statsDiv.style.cssText = + "margin-top:8px;font-size:12px;color:" + + textColors.textSecondary + + ";width:180px;word-break:break-word;"; + controlWrap.appendChild(statsDiv); + + const updateStats = function (key) { + if (!key || key.trim() === "") { + statsDiv.innerHTML = ""; + return; + } + if (!/^smm_[0-9a-f]{96}$/.test(key)) { + statsDiv.innerHTML = + "" + + lt("Invalid key format") + + ""; + return; + } + statsDiv.innerHTML = + "" + + lt("Checking key..."); + Millennium.callServerMethod("luatools", "GetMorrenusStats", { + api_key: key, + contentScriptQuery: "", + }) + .then((r) => (typeof r === "string" ? JSON.parse(r) : r)) + .then((res) => { + if (res && res.username) { + let expiryText = ""; + if (res.api_key_expires_at) { + const expiry = new Date(res.api_key_expires_at); + const now = new Date(); + const days = Math.max( + 0, + Math.ceil((expiry - now) / (1000 * 60 * 60 * 24)), + ); + expiryText = days + " " + lt("days left"); + } + const usage = + typeof res.daily_usage !== "undefined" + ? res.daily_usage + : "?"; + const limit = + typeof res.daily_limit !== "undefined" + ? res.daily_limit + : "?"; + + const usageColor = + typeof res.daily_usage !== "undefined" && + typeof res.daily_limit !== "undefined" && + res.daily_usage >= res.daily_limit + ? "#ff5c5c" + : textColors.accent; + + statsDiv.innerHTML = ` +
+
${res.username}
+
+ ${lt("Usage")} + ${usage} / ${limit} +
+
+ ${lt("Expires")} + ${expiryText} +
+
+ `; + } else { + statsDiv.innerHTML = + "" + + lt("Invalid or rejected key") + + ""; + } + }) + .catch((e) => { + statsDiv.innerHTML = + "" + + lt("Failed to verify key") + + ""; + }); + }; + + updateStats(textInput.value); + + textInput.addEventListener("input", function () { + if (textInput.apiDebounce) + clearTimeout(textInput.apiDebounce); + textInput.apiDebounce = setTimeout(() => { + updateStats(this.value); + }, 800); + }); + } + } else { + const unsupported = document.createElement("div"); + unsupported.style.cssText = "font-size:12px;color:#ffb347;"; + unsupported.textContent = lt( + "common.error.unsupportedOption", + ).replace("{type}", option.type); + controlWrap.appendChild(unsupported); + } + } + groupEl.appendChild(optionEl); + } + + contentWrap.appendChild(groupEl); + } + + // Render Installed Fixes section + renderInstalledFixesSection(); + + // Render Installed Lua Scripts section + renderInstalledLuaSection(); + + updateSaveState(); + refreshDependencies(); + } + + function refreshDependencies() { + try { + const languageEl = overlay.querySelector( + '[data-setting-option="language"]', + ); + if (languageEl) { + const useSteam = + state.draft && + state.draft.general && + state.draft.general.useSteamLanguage; + if (useSteam !== false) { + languageEl.style.display = "none"; + } else { + languageEl.style.display = "flex"; + } + } + } catch (_) {} + } + + function renderInstalledFixesSection() { + const sectionEl = document.createElement("div"); + sectionEl.id = "luatools-installed-fixes-section"; + const sectionColors = getThemeColors(); + sectionEl.style.cssText = `margin-top:28px;padding:20px;background:rgba(${sectionColors.rgbString},0.04);border:1px solid ${sectionColors.border};border-radius:10px;`; + + const sectionTitle = document.createElement("div"); + const titleColors = getThemeColors(); + sectionTitle.style.cssText = `font-size:16px;color:${titleColors.text};margin-bottom:14px;font-weight:600;`; + sectionTitle.innerHTML = + '' + + t("settings.installedFixes.title", "Installed Fixes"); + sectionEl.appendChild(sectionTitle); + + const listContainer = document.createElement("div"); + listContainer.id = "luatools-fixes-list"; + listContainer.style.cssText = "min-height:50px;"; + sectionEl.appendChild(listContainer); + + contentWrap.appendChild(sectionEl); + + loadInstalledFixes(listContainer); + } + + function loadInstalledFixes(container) { + const loadingColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedFixes.loading", "Scanning for installed fixes...")}
`; + + Millennium.callServerMethod("luatools", "GetInstalledFixes", { + contentScriptQuery: "", + }) + .then(function (res) { + const response = typeof res === "string" ? JSON.parse(res) : res; + backendLog( + "LuaTools: GetInstalledFixes response: " + + JSON.stringify(response).substring(0, 200), + ); + if (!response || !response.success) { + backendLog( + "LuaTools: GetInstalledFixes failed - response: " + + JSON.stringify(response), + ); + const errColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedFixes.error", "Failed to load installed fixes.")}
`; + return; + } + + const fixes = Array.isArray(response.fixes) ? response.fixes : []; + if (fixes.length === 0) { + const emptyColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedFixes.empty", "No fixes installed yet.")}
`; + return; + } + + container.innerHTML = ""; + for (let i = 0; i < fixes.length; i++) { + const fix = fixes[i]; + const fixEl = createFixListItem(fix, container); + container.appendChild(fixEl); + } + + // Re-apply search filter after loading + if (state.searchQuery) { + setTimeout(applySearchFilter, 50); + } + }) + .catch(function (err) { + backendLog("LuaTools: GetInstalledFixes catch error: " + err); + const catchColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedFixes.error", "Failed to load installed fixes.")}
`; + }); + } + + function createFixListItem(fix, container) { + const itemEl = document.createElement("div"); + const itemColors = getThemeColors(); + const accentColor = itemColors.accent || "#1a9fff"; + itemEl.style.cssText = `padding:14px 16px;background:rgba(${itemColors.rgbString},0.04);border:1px solid ${itemColors.border};border-radius:8px;display:flex;justify-content:space-between;align-items:center;transition:all 0.15s ease;`; + + itemEl.onmouseover = function () { + const c = getThemeColors(); + this.style.borderColor = c.accent; + this.style.background = `rgba(${c.rgbString},0.08)`; + }; + itemEl.onmouseout = function () { + const c = getThemeColors(); + this.style.borderColor = c.border; + this.style.background = `rgba(${c.rgbString},0.04)`; + }; + + // Add search data attributes + itemEl.dataset.fixItem = fix.appid; + const gameNameText = fix.gameName || "Unknown Game"; + itemEl.dataset.searchText = ( + gameNameText + + " " + + fix.appid + + " " + + (fix.fixType || "") + + " fix" + ).toLowerCase(); + + const infoDiv = document.createElement("div"); + infoDiv.style.cssText = "flex:1;padding-right:15px;"; + + const gameName = document.createElement("div"); + const nameColors = getThemeColors(); + gameName.style.cssText = `font-size:15px;font-weight:600;color:${nameColors.text};margin-bottom:3px;`; + gameName.textContent = gameNameText; + infoDiv.appendChild(gameName); + + if (!fix.gameName || fix.gameName.startsWith("Unknown Game")) { + fetchSteamGameName(fix.appid).then(function (name) { + if (name) { + fix.gameName = name; + gameName.textContent = name; + itemEl.dataset.searchText = ( + name + + " " + + fix.appid + + " " + + (fix.fixType || "") + + " fix" + ).toLowerCase(); + } + }); + } + + const detailsDiv = document.createElement("div"); + const detailsColors = getThemeColors(); + detailsDiv.style.cssText = `font-size:12px;color:${detailsColors.textSecondary};display:flex;flex-wrap:wrap;gap:10px;`; + + if (fix.fixType) { + const typeSpan = document.createElement("div"); + const typeColors = getThemeColors(); + typeSpan.innerHTML = `${fix.fixType}`; + detailsDiv.appendChild(typeSpan); + } + + if (fix.date) { + const dateSpan = document.createElement("div"); + const dateColors = getThemeColors(); + dateSpan.innerHTML = `${fix.date}`; + detailsDiv.appendChild(dateSpan); + } + + if (fix.filesCount > 0) { + const filesSpan = document.createElement("div"); + const filesColors = getThemeColors(); + filesSpan.innerHTML = `${t("settings.installedFixes.files", "{count} files").replace("{count}", fix.filesCount)}`; + detailsDiv.appendChild(filesSpan); + } + + infoDiv.appendChild(detailsDiv); + itemEl.appendChild(infoDiv); + + const fixDeleteBtn = document.createElement("a"); + fixDeleteBtn.href = "#"; + fixDeleteBtn.style.cssText = + "display:flex;align-items:center;justify-content:center;width:38px;height:38px;background:rgba(255,80,80,0.1);border:1px solid rgba(255,80,80,0.3);border-radius:8px;color:#ff5050;font-size:15px;text-decoration:none;transition:all 0.15s ease;cursor:pointer;flex-shrink:0;"; + fixDeleteBtn.innerHTML = ''; + fixDeleteBtn.title = t("settings.installedFixes.delete", "Remove"); + fixDeleteBtn.onmouseover = function () { + this.style.background = "rgba(255,80,80,0.2)"; + this.style.borderColor = "rgba(255,80,80,0.5)"; + this.style.color = "#ff6b6b"; + }; + fixDeleteBtn.onmouseout = function () { + this.style.background = "rgba(255,80,80,0.1)"; + this.style.borderColor = "rgba(255,80,80,0.3)"; + this.style.color = "#ff5050"; + }; + + fixDeleteBtn.addEventListener("click", function (e) { + e.preventDefault(); + if (fixDeleteBtn.dataset.busy === "1") return; + + showLuaToolsConfirm( + fix.gameName || "LuaTools", + t( + "settings.installedFixes.deleteConfirm", + "Are you sure you want to remove this fix? This will delete fix files and run Steam verification.", + ), + function () { + // User confirmed + fixDeleteBtn.dataset.busy = "1"; + fixDeleteBtn.style.opacity = "0.6"; + fixDeleteBtn.innerHTML = + ''; + + Millennium.callServerMethod("luatools", "UnFixGame", { + appid: fix.appid, + installPath: fix.installPath || "", + fixDate: fix.date || "", + contentScriptQuery: "", + }) + .then(function (res) { + const response = + typeof res === "string" ? JSON.parse(res) : res; + if (!response || !response.success) { + alert( + t( + "settings.installedFixes.deleteError", + "Failed to remove fix.", + ), + ); + fixDeleteBtn.dataset.busy = "0"; + fixDeleteBtn.style.opacity = "1"; + fixDeleteBtn.innerHTML = + ' ' + + t("settings.installedFixes.delete", "Delete") + + ""; + return; + } + + // Poll for unfix status + pollUnfixStatus(fix.appid, itemEl, fixDeleteBtn, container); + }) + .catch(function (err) { + alert( + t( + "settings.installedFixes.deleteError", + "Failed to remove fix.", + ) + + " " + + (err && err.message ? err.message : ""), + ); + fixDeleteBtn.dataset.busy = "0"; + fixDeleteBtn.style.opacity = "1"; + fixDeleteBtn.innerHTML = ''; + }); + }, + function () { + // User cancelled - do nothing + }, + ); + }); + + itemEl.appendChild(fixDeleteBtn); + return itemEl; + } + + function pollUnfixStatus(appid, itemEl, deleteBtn, container) { + let pollCount = 0; + const maxPolls = 60; + + function checkStatus() { + if (pollCount >= maxPolls) { + alert( + t("settings.installedFixes.deleteError", "Failed to remove fix.") + + " (Timeout)", + ); + deleteBtn.dataset.busy = "0"; + deleteBtn.style.opacity = "1"; + deleteBtn.innerHTML = + ' ' + + t("settings.installedFixes.delete", "Delete") + + ""; + return; + } + + pollCount++; + + Millennium.callServerMethod("luatools", "GetUnfixStatus", { + appid: appid, + contentScriptQuery: "", + }) + .then(function (res) { + const response = typeof res === "string" ? JSON.parse(res) : res; + if (!response || !response.success) { + setTimeout(checkStatus, 500); + return; + } + + const state = response.state || {}; + const status = state.status; + + if (status === "done" && state.success) { + // Success - remove item from list with animation + itemEl.style.transition = "all 0.3s ease"; + itemEl.style.opacity = "0"; + itemEl.style.transform = "translateX(-20px)"; + setTimeout(function () { + itemEl.remove(); + // Check if list is now empty + if (container.children.length === 0) { + const emptyFixesColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedFixes.empty", "No fixes installed yet.")}
`; + } + }, 300); + + // Trigger Steam verification after a short delay + setTimeout(function () { + try { + const verifyUrl = "steam://validate/" + appid; + window.location.href = verifyUrl; + backendLog("LuaTools: Running verify for appid " + appid); + } catch (_) {} + }, 1000); + + return; + } else if ( + status === "failed" || + (status === "done" && !state.success) + ) { + alert( + t( + "settings.installedFixes.deleteError", + "Failed to remove fix.", + ) + + " " + + (state.error || ""), + ); + fixDeleteBtn.dataset.busy = "1"; + fixDeleteBtn.style.opacity = "0.6"; + fixDeleteBtn.innerHTML = + ' ' + + t("settings.installedFixes.delete", "Delete") + + ""; + return; + } else { + // Still in progress + setTimeout(checkStatus, 500); + } + }) + .catch(function (err) { + setTimeout(checkStatus, 500); + }); + } + + checkStatus(); + } + + function renderInstalledLuaSection() { + const sectionEl = document.createElement("div"); + sectionEl.id = "luatools-installed-lua-section"; + const sectionLuaColors = getThemeColors(); + sectionEl.style.cssText = `margin-top:28px;padding:20px;background:rgba(${sectionLuaColors.rgbString},0.04);border:1px solid ${sectionLuaColors.border};border-radius:10px;`; + + const sectionTitle = document.createElement("div"); + const luaTitleColors = getThemeColors(); + sectionTitle.style.cssText = `font-size:16px;color:${luaTitleColors.text};margin-bottom:14px;font-weight:600;`; + sectionTitle.innerHTML = + '' + + t("settings.installedLua.title", "Installed Lua Scripts"); + sectionEl.appendChild(sectionTitle); + + const listContainer = document.createElement("div"); + listContainer.id = "luatools-lua-list"; + listContainer.style.cssText = "min-height:50px;"; + sectionEl.appendChild(listContainer); + + contentWrap.appendChild(sectionEl); + + loadInstalledLuaScripts(listContainer); + } + + function loadInstalledLuaScripts(container) { + const loadingLuaColors = getThemeColors(); + container.innerHTML = + `
` + + t( + "settings.installedLua.loading", + "Scanning for installed Lua scripts...", + ) + + "
"; + + Millennium.callServerMethod("luatools", "GetInstalledLuaScripts", { + contentScriptQuery: "", + }) + .then(function (res) { + const response = typeof res === "string" ? JSON.parse(res) : res; + if (!response || !response.success) { + const errLuaColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedLua.error", "Failed to load installed Lua scripts.")}
`; + return; + } + + const scripts = Array.isArray(response.scripts) + ? response.scripts + : []; + if (scripts.length === 0) { + const emptyLuaColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedLua.empty", "No Lua scripts installed yet.")}
`; + return; + } + + container.innerHTML = ""; + + // Check if there are any unknown games + const hasUnknownGames = scripts.some(function (s) { + return s.gameName && s.gameName.startsWith("Unknown Game"); + }); + + // Show info banner if there are unknown games + if (hasUnknownGames) { + const infoBanner = document.createElement("div"); + infoBanner.style.cssText = + "margin-bottom:16px;padding:12px 14px;background:rgba(255,193,7,0.1);border:1px solid rgba(255,193,7,0.3);border-radius:6px;color:#ffc107;font-size:13px;display:flex;align-items:center;gap:10px;"; + infoBanner.innerHTML = + '' + + t( + "settings.installedLua.unknownInfo", + "Games showing 'Unknown Game' were installed manually (not via LuaTools).", + ) + + ""; + container.appendChild(infoBanner); + } + + for (let i = 0; i < scripts.length; i++) { + const script = scripts[i]; + const scriptEl = createLuaListItem(script, container); + container.appendChild(scriptEl); + } + + // Re-apply search filter after loading + if (state.searchQuery) { + setTimeout(applySearchFilter, 50); + } + }) + .catch(function (err) { + const catchLuaColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedLua.error", "Failed to load installed Lua scripts.")}
`; + }); + } + + function createLuaListItem(script, container) { + const itemEl = document.createElement("div"); + const itemLuaColors = getThemeColors(); + itemEl.style.cssText = `padding:14px 16px;background:rgba(${itemLuaColors.rgbString},0.04);border:1px solid ${itemLuaColors.border};border-radius:8px;display:flex;justify-content:space-between;align-items:center;transition:all 0.15s ease;`; + + itemEl.onmouseover = function () { + const c = getThemeColors(); + this.style.borderColor = c.accent; + this.style.background = `rgba(${c.rgbString},0.08)`; + }; + itemEl.onmouseout = function () { + const c = getThemeColors(); + this.style.borderColor = c.border; + this.style.background = `rgba(${c.rgbString},0.04)`; + }; + + // Add search data attributes + itemEl.dataset.luaItem = script.appid; + const gameNameText = script.gameName || "Unknown Game"; + itemEl.dataset.searchText = ( + gameNameText + + " " + + script.appid + + " lua script" + + (script.isDisabled ? " disabled" : "") + ).toLowerCase(); + + const infoDiv = document.createElement("div"); + infoDiv.style.cssText = "flex:1;padding-right:15px;"; + + const gameName = document.createElement("div"); + const gameNameLuaColors = getThemeColors(); + gameName.style.cssText = `font-size:15px;font-weight:600;color:${gameNameLuaColors.text};margin-bottom:3px;display:flex;align-items:center;flex-wrap:wrap;`; + gameName.textContent = gameNameText; + + if (!script.gameName || script.gameName.startsWith("Unknown Game")) { + fetchSteamGameName(script.appid).then(function (name) { + if (name) { + script.gameName = name; + gameName.textContent = name; + itemEl.dataset.searchText = ( + name + + " " + + script.appid + + " lua script" + + (script.isDisabled ? " disabled" : "") + ).toLowerCase(); + } + }); + } + + if (script.isDisabled) { + const disabledBadge = document.createElement("span"); + disabledBadge.style.cssText = + "margin-left:10px;padding:3px 10px;background:rgba(255,193,7,0.15);border:1px solid rgba(255,193,7,0.4);border-radius:20px;font-size:11px;color:#ffc107;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;"; + disabledBadge.textContent = t( + "settings.installedLua.disabled", + "Disabled", + ); + gameName.appendChild(disabledBadge); + } + + infoDiv.appendChild(gameName); + + const detailsDiv = document.createElement("div"); + const detailsLuaColors = getThemeColors(); + detailsDiv.style.cssText = `font-size:12px;color:${detailsLuaColors.textSecondary};display:flex;flex-wrap:wrap;gap:10px;`; + + if (script.modifiedDate) { + const dateSpan = document.createElement("div"); + const dateLuaColors = getThemeColors(); + dateSpan.innerHTML = `${t("settings.installedLua.modified", "Modified:")} ${script.modifiedDate}`; + detailsDiv.appendChild(dateSpan); + } + + infoDiv.appendChild(detailsDiv); + itemEl.appendChild(infoDiv); + + const luaDeleteBtn = document.createElement("a"); + luaDeleteBtn.href = "#"; + luaDeleteBtn.style.cssText = + "display:flex;align-items:center;justify-content:center;width:38px;height:38px;background:rgba(255,80,80,0.1);border:1px solid rgba(255,80,80,0.3);border-radius:8px;color:#ff5050;font-size:15px;text-decoration:none;transition:all 0.15s ease;cursor:pointer;flex-shrink:0;"; + luaDeleteBtn.innerHTML = ''; + luaDeleteBtn.title = t("settings.installedLua.delete", "Remove"); + luaDeleteBtn.onmouseover = function () { + this.style.background = "rgba(255,80,80,0.2)"; + this.style.borderColor = "rgba(255,80,80,0.5)"; + this.style.color = "#ff6b6b"; + }; + luaDeleteBtn.onmouseout = function () { + this.style.background = "rgba(255,80,80,0.1)"; + this.style.borderColor = "rgba(255,80,80,0.3)"; + this.style.color = "#ff5050"; + this.style.transform = "translateY(0) scale(1)"; + this.style.boxShadow = "none"; + }; + + luaDeleteBtn.addEventListener("click", function (e) { + e.preventDefault(); + if (luaDeleteBtn.dataset.busy === "1") return; + + showLuaToolsConfirm( + script.gameName || "LuaTools", + t( + "settings.installedLua.deleteConfirm", + "Remove via LuaTools for this game?", + ), + function () { + // User confirmed + luaDeleteBtn.dataset.busy = "1"; + luaDeleteBtn.style.opacity = "0.6"; + luaDeleteBtn.innerHTML = + ''; + + Millennium.callServerMethod("luatools", "DeleteLuaToolsForApp", { + appid: script.appid, + contentScriptQuery: "", + }) + .then(function (res) { + const response = + typeof res === "string" ? JSON.parse(res) : res; + if (!response || !response.success) { + alert( + t( + "settings.installedLua.deleteError", + "Failed to remove Lua script.", + ), + ); + luaDeleteBtn.dataset.busy = "0"; + luaDeleteBtn.style.opacity = "1"; + luaDeleteBtn.innerHTML = + ' ' + + t("settings.installedLua.delete", "Delete") + + ""; + return; + } + + // Success - remove item from list with animation + itemEl.style.transition = "all 0.3s ease"; + itemEl.style.opacity = "0"; + itemEl.style.transform = "translateX(-20px)"; + setTimeout(function () { + itemEl.remove(); + // Check if list is now empty + if (container.children.length === 0) { + const emptyLuaColors = getThemeColors(); + container.innerHTML = `
${t("settings.installedLua.empty", "No Lua scripts installed yet.")}
`; + } + }, 300); + }) + .catch(function (err) { + alert( + t( + "settings.installedLua.deleteError", + "Failed to remove Lua script.", + ) + + " " + + (err && err.message ? err.message : ""), + ); + luaDeleteBtn.dataset.busy = "0"; + luaDeleteBtn.style.opacity = "1"; + luaDeleteBtn.innerHTML = + ' ' + + t("settings.installedLua.delete", "Delete") + + ""; + }); + }, + function () { + // User cancelled - do nothing + }, + ); + }); + + itemEl.appendChild(luaDeleteBtn); + return itemEl; + } + + function handleLoad(force) { + setStatus(t("settings.loading", "Loading settings..."), "#c7d5e0"); + saveBtn.dataset.disabled = "1"; + saveBtn.style.opacity = "0.6"; + contentWrap.innerHTML = + '
' + + t("common.status.loading", "Loading...") + + "
"; + + return fetchSettingsConfig(force) + .then(function (config) { + state.config = { + schemaVersion: config.schemaVersion, + schema: Array.isArray(config.schema) ? config.schema : [], + values: initialiseSettingsDraft(config), + language: config.language, + locales: config.locales, + }; + state.draft = initialiseSettingsDraft(config); + applyStaticTranslations(); + renderSettings(); + setStatus("", "#c7d5e0"); + }) + .catch(function (err) { + const message = + err && err.message + ? err.message + : t("settings.error", "Failed to load settings."); + contentWrap.innerHTML = + '
' + message + "
"; + setStatus( + t("common.status.error", "Error") + ": " + message, + "#ff5c5c", + ); + }); + } + + backBtn.addEventListener("click", function (e) { + e.preventDefault(); + if (typeof onBack === "function") { + overlay.remove(); + onBack(); + } + }); + + rightButtons.appendChild(refreshBtn); + rightButtons.appendChild(saveBtn); + btnRow.appendChild(backBtn); + btnRow.appendChild(rightButtons); + + refreshBtn.addEventListener("click", function (e) { + e.preventDefault(); + if (refreshBtn.dataset.busy === "1") return; + refreshBtn.dataset.busy = "1"; + handleLoad(true).finally(function () { + refreshBtn.dataset.busy = "0"; + refreshBtn.style.opacity = "1"; + applyStaticTranslations(); + }); + }); + + saveBtn.addEventListener("click", function (e) { + e.preventDefault(); + if (saveBtn.dataset.disabled === "1" || saveBtn.dataset.busy === "1") + return; + + const changes = collectChanges(); + try { + backendLog( + "LuaTools: collectChanges payload " + JSON.stringify(changes), + ); + } catch (_) {} + if (!changes || Object.keys(changes).length === 0) { + setStatus(t("settings.noChanges", "No changes to save."), "#c7d5e0"); + updateSaveState(); + return; + } + + saveBtn.dataset.busy = "1"; + saveBtn.style.opacity = "0.6"; + setStatus(t("settings.saving", "Saving..."), "#c7d5e0"); + saveBtn.style.opacity = "0.6"; + + const payloadToSend = JSON.parse(JSON.stringify(changes)); + try { + backendLog( + "LuaTools: sending settings payload " + JSON.stringify(payloadToSend), + ); + } catch (_) {} + // Pass flattened keys so Millennium handles the RPC arguments as expected. + Millennium.callServerMethod("luatools", "ApplySettingsChanges", { + contentScriptQuery: "", + changesJson: JSON.stringify(payloadToSend), + }) + .then(function (res) { + const response = typeof res === "string" ? JSON.parse(res) : res; + if (!response || response.success !== true) { + if (response && response.errors) { + const errorParts = []; + for (const groupKey in response.errors) { + if ( + !Object.prototype.hasOwnProperty.call( + response.errors, + groupKey, + ) + ) + continue; + const optionErrors = response.errors[groupKey]; + for (const optionKey in optionErrors) { + if ( + !Object.prototype.hasOwnProperty.call( + optionErrors, + optionKey, + ) + ) + continue; + const errorMsg = optionErrors[optionKey]; + errorParts.push(groupKey + "." + optionKey + ": " + errorMsg); + } + } + const errText = errorParts.length + ? errorParts.join("\n") + : "Validation failed."; + setStatus(errText, "#ff5c5c"); + } else { + const message = + response && response.error + ? response.error + : t("settings.saveError", "Failed to save settings."); + setStatus(message, "#ff5c5c"); + } + return; + } + + const newValues = + response && response.values && typeof response.values === "object" + ? response.values + : state.draft; + state.config.values = initialiseSettingsDraft({ + schema: state.config.schema, + values: newValues, + }); + state.draft = initialiseSettingsDraft({ + schema: state.config.schema, + values: newValues, + }); + + try { + if (window.__LuaToolsSettings) { + window.__LuaToolsSettings.values = JSON.parse( + JSON.stringify(state.config.values), + ); + window.__LuaToolsSettings.schemaVersion = + state.config.schemaVersion; + window.__LuaToolsSettings.lastFetched = Date.now(); + if ( + response && + response.translations && + typeof response.translations === "object" + ) { + window.__LuaToolsSettings.translations = response.translations; + } + if (response && response.language) { + window.__LuaToolsSettings.language = response.language; + } + } + } catch (_) {} + + // Invalidate the settings cache to force a fresh fetch on next settings load + // This ensures any changes persist across page navigations + try { + if (window.__LuaToolsSettings) { + window.__LuaToolsSettings.schema = null; + } + } catch (_) {} + + if ( + response && + response.translations && + typeof response.translations === "object" + ) { + applyTranslationBundle({ + language: + response.language || + (window.__LuaToolsI18n && window.__LuaToolsI18n.language) || + "en", + locales: + (window.__LuaToolsI18n && window.__LuaToolsI18n.locales) || + (state.config && state.config.locales) || + [], + strings: response.translations, + }); + applyStaticTranslations(); + updateButtonTranslations(); + } + + renderSettings(); + setStatus( + t("settings.saveSuccess", "Settings saved successfully."), + "#8bc34a", + ); + + // Reload theme if it changed + const oldTheme = state.config.values?.general?.theme; + const newTheme = state.draft?.general?.theme; + if (oldTheme !== newTheme) { + ensureLuaToolsStyles(); + } + }) + .catch(function (err) { + const message = + err && err.message + ? err.message + : t("settings.saveError", "Failed to save settings."); + setStatus(message, "#ff5c5c"); + }) + .finally(function () { + saveBtn.dataset.busy = "0"; + applyStaticTranslations(); + updateSaveState(); + }); + }); + + closeIconBtn.addEventListener("click", function (e) { + e.preventDefault(); + overlay.remove(); + }); + + discordIconBtn.addEventListener("click", function (e) { + e.preventDefault(); + const url = "https://discord.gg/luatools"; + try { + Millennium.callServerMethod("luatools", "OpenExternalUrl", { + url, + contentScriptQuery: "", + }); + } catch (_) {} + }); + + overlay.addEventListener("click", function (e) { + if (e.target === overlay) { + overlay.remove(); + } + }); + + handleLoad(!!forceRefresh); + } + + // Force-close any open settings overlays to avoid stacking + function closeSettingsOverlay() { + try { + // Remove all settings overlays (robust against older NodeList forEach support) + var list = document.getElementsByClassName("luatools-settings-overlay"); + while (list && list.length > 0) { + try { + list[0].remove(); + } catch (_) { + break; + } + } + // Also remove any download/progress overlays if present + var list2 = document.getElementsByClassName("luatools-overlay"); + while (list2 && list2.length > 0) { + try { + list2[0].remove(); + } catch (_) { + break; + } + } + } catch (_) {} + } + + // Custom modern alert dialog + function showLuaToolsAlert(title, message, onClose) { + if (document.querySelector(".luatools-alert-overlay")) return; + + ensureLuaToolsStyles(); + ensureFontAwesome(); + const overlay = document.createElement("div"); + overlay.className = "luatools-alert-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const alertModalColors = getThemeColors(); + modal.style.cssText = `background:${alertModalColors.modalBg};color:${alertModalColors.text};border:1px solid ${alertModalColors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${alertModalColors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const alertIconWrap = document.createElement("div"); + alertIconWrap.style.cssText = "text-align:center;margin-bottom:12px;"; + const alertIcon = document.createElement("i"); + alertIcon.className = "fa-solid fa-circle-info"; + alertIcon.style.cssText = `color:${alertModalColors.accent};font-size:32px;`; + alertIconWrap.appendChild(alertIcon); + + const titleEl = document.createElement("div"); + titleEl.style.cssText = `font-size:20px;color:${alertModalColors.text};margin-bottom:12px;font-weight:600;text-align:center;`; + titleEl.textContent = String(title || "LuaTools"); + + const messageEl = document.createElement("div"); + messageEl.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:24px;color:${alertModalColors.textSecondary};text-align:center;`; + messageEl.textContent = String(message || ""); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = "display:flex;justify-content:center;"; + + const okBtn = document.createElement("a"); + okBtn.href = "#"; + okBtn.className = "luatools-btn primary"; + okBtn.style.cssText = + "min-width:140px;display:flex;align-items:center;justify-content:center;text-align:center;"; + okBtn.innerHTML = `${lt("Close")}`; + okBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + try { + onClose && onClose(); + } catch (_) {} + }; + + btnRow.appendChild(okBtn); + + modal.appendChild(alertIconWrap); + modal.appendChild(titleEl); + modal.appendChild(messageEl); + modal.appendChild(btnRow); + overlay.appendChild(modal); + + overlay.addEventListener("click", function (e) { + if (e.target === overlay) { + overlay.remove(); + try { + onClose && onClose(); + } catch (_) {} + } + }); + + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + } + + // Helper to show alert with fallback + function ShowLuaToolsAlert(title, message) { + try { + showLuaToolsAlert(title, message); + } catch (err) { + backendLog("LuaTools: Alert error, falling back: " + err); + try { + alert(String(title) + "\n\n" + String(message)); + } catch (_) {} + } + } + + // Steam-style confirm helper (ShowConfirmDialog only) + function showLuaToolsConfirm(title, message, onConfirm, onCancel) { + // Always close settings popup first so the confirm is visible on top + closeSettingsOverlay(); + + // Create custom modern confirmation dialog + if (document.querySelector(".luatools-confirm-overlay")) return; + + ensureLuaToolsStyles(); + ensureFontAwesome(); + const overlay = document.createElement("div"); + overlay.className = "luatools-confirm-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const confirmColors = getThemeColors(); + modal.style.cssText = `background:${confirmColors.modalBg};color:${confirmColors.text};border:1px solid ${confirmColors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${confirmColors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const confirmIconWrap = document.createElement("div"); + confirmIconWrap.style.cssText = "text-align:center;margin-bottom:12px;"; + const confirmIcon = document.createElement("i"); + confirmIcon.className = "fa-solid fa-circle-question"; + confirmIcon.style.cssText = `color:${confirmColors.accent};font-size:32px;`; + confirmIconWrap.appendChild(confirmIcon); + + const titleEl = document.createElement("div"); + titleEl.style.cssText = `font-size:20px;color:${confirmColors.text};margin-bottom:12px;font-weight:600;text-align:center;`; + titleEl.textContent = String(title || "LuaTools"); + + const messageEl = document.createElement("div"); + messageEl.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:24px;color:${confirmColors.textSecondary};text-align:center;`; + messageEl.textContent = String(message || lt("Are you sure?")); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = "display:flex;gap:12px;justify-content:center;"; + + const cancelBtn = document.createElement("a"); + cancelBtn.href = "#"; + cancelBtn.className = "luatools-btn"; + cancelBtn.style.cssText = + "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; + cancelBtn.innerHTML = `${lt("Cancel")}`; + cancelBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + try { + onCancel && onCancel(); + } catch (_) {} + }; + const confirmBtn = document.createElement("a"); + confirmBtn.href = "#"; + confirmBtn.className = "luatools-btn primary"; + confirmBtn.style.cssText = + "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; + confirmBtn.innerHTML = `${lt("Confirm")}`; + confirmBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + try { + onConfirm && onConfirm(); + } catch (_) {} + }; + + btnRow.appendChild(cancelBtn); + btnRow.appendChild(confirmBtn); + + modal.appendChild(confirmIconWrap); + modal.appendChild(titleEl); + modal.appendChild(messageEl); + modal.appendChild(btnRow); + overlay.appendChild(modal); + + overlay.addEventListener("click", function (e) { + if (e.target === overlay) { + overlay.remove(); + try { + onCancel && onCancel(); + } catch (_) {} + } + }); + + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + } + + // DLC warning modal + function showDlcWarning(appid, fullgameAppid, fullgameName) { + // Close settings so modal is visible + closeSettingsOverlay(); + if (document.querySelector(".luatools-dlc-warning-overlay")) return; + + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement("div"); + overlay.className = "luatools-dlc-warning-overlay luatools-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const colors = getThemeColors(); + modal.style.cssText = `background:${colors.modalBg};color:${colors.text};border:1px solid ${colors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${colors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const header = document.createElement("div"); + header.style.cssText = "text-align:center;margin-bottom:16px;"; + const icon = document.createElement("i"); + icon.className = "fa-solid fa-circle-info"; + icon.style.cssText = `color:${colors.accent};font-size:32px;`; + header.appendChild(icon); + + const titleEl = document.createElement("div"); + titleEl.style.cssText = `font-size:20px;font-weight:600;text-align:center;margin-bottom:12px;color:${colors.text};`; + titleEl.textContent = lt("DLC Detected"); + + const messageEl = document.createElement("div"); + messageEl.style.cssText = `font-size:14px;line-height:1.6;margin-bottom:24px;color:${colors.textSecondary};text-align:center;`; + messageEl.innerHTML = lt( + "DLCs are added together with the base game. To add fixes for this DLC, please go to the base game page:

{gameName}", + ).replace("{gameName}", fullgameName || lt("Base Game")); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = "display:flex;gap:12px;justify-content:center;"; + + const cancelBtn = document.createElement("a"); + cancelBtn.href = "#"; + cancelBtn.className = "luatools-btn"; + cancelBtn.style.cssText = + "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; + cancelBtn.innerHTML = `${lt("Cancel")}`; + cancelBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + }; + + const goBtn = document.createElement("a"); + goBtn.href = "https://store.steampowered.com/app/" + fullgameAppid; + goBtn.className = "luatools-btn primary"; + goBtn.style.cssText = + "flex:1.5;display:flex;align-items:center;justify-content:center;text-align:center;"; + goBtn.innerHTML = `${lt("Go to Base Game")}`; + goBtn.onclick = function (e) { + // Let the default link behavior happen (navigation) + // But we can also remove the overlay + setTimeout(() => overlay.remove(), 100); + }; + + btnRow.appendChild(cancelBtn); + btnRow.appendChild(goBtn); + + modal.appendChild(header); + modal.appendChild(titleEl); + modal.appendChild(messageEl); + modal.appendChild(btnRow); + overlay.appendChild(modal); + + overlay.addEventListener("click", function (e) { + if (e.target === overlay) overlay.remove(); + }); + + document.body.appendChild(overlay); + + setTimeout(function () { + if (window.GamepadNav) window.GamepadNav.scanElements(); + }, 150); + } + + function showLuaToolsPlayableWarning(message, onProceed, onCancel) { + // Close settings so modal is visible + closeSettingsOverlay(); + if (document.querySelector(".luatools-playable-warning-overlay")) return; + + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement("div"); + overlay.className = "luatools-playable-warning-overlay luatools-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100001;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const playableColors = getThemeColors(); + modal.style.cssText = `background:${playableColors.modalBg};color:${playableColors.text};border:1px solid ${playableColors.border};border-radius:16px;width:420px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${playableColors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const header = document.createElement("div"); + header.style.cssText = + "display:flex;align-items:center;gap:12px;margin-bottom:14px;justify-content:center;"; + const icon = document.createElement("i"); + icon.className = "fa-solid fa-triangle-exclamation"; + icon.style.cssText = `color:${playableColors.accent};font-size:22px;`; + const titleEl = document.createElement("div"); + titleEl.style.cssText = `font-size:18px;font-weight:600;text-align:center;color:${playableColors.text};`; + titleEl.textContent = t("common.warning", "Warning"); + header.appendChild(icon); + header.appendChild(titleEl); + + const messageEl = document.createElement("div"); + messageEl.style.cssText = `font-size:14px;line-height:1.5;margin-bottom:20px;color:${playableColors.textSecondary};text-align:center;padding:0 6px;`; + messageEl.textContent = String( + message || + "This game may not work, support for it wont be given in our discord", + ); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = "display:flex;gap:12px;justify-content:center;"; + + const cancelBtn = document.createElement("a"); + cancelBtn.href = "#"; + cancelBtn.className = "luatools-btn"; + cancelBtn.style.cssText = + "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; + cancelBtn.innerHTML = `${lt("Cancel")}`; + cancelBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + try { + onCancel && onCancel(); + } catch (_) {} + }; + + const proceedBtn = document.createElement("a"); + proceedBtn.href = "#"; + proceedBtn.className = "luatools-btn primary"; + proceedBtn.style.cssText = + "flex:1;display:flex;align-items:center;justify-content:center;text-align:center;"; + proceedBtn.innerHTML = `${lt("Proceed")}`; + proceedBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); + try { + onProceed && onProceed(); + } catch (_) {} + }; + + btnRow.appendChild(cancelBtn); + btnRow.appendChild(proceedBtn); + + modal.appendChild(header); + modal.appendChild(messageEl); + modal.appendChild(btnRow); + overlay.appendChild(modal); + + overlay.addEventListener("click", function (e) { + if (e.target === overlay) { + overlay.remove(); + try { + onCancel && onCancel(); + } catch (_) {} + } + }); + + document.body.appendChild(overlay); + + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + } + + // Millennium disclaimer modal + function showMillenniumDisclaimerModal() { + if (document.querySelector(".luatools-disclaimer-overlay")) return; + + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement("div"); + overlay.className = "luatools-disclaimer-overlay luatools-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(12px);z-index:100005;display:flex;align-items:center;justify-content:center;"; + + const modal = document.createElement("div"); + const disclaimerColors = getThemeColors(); + modal.style.cssText = `background:${disclaimerColors.modalBg};color:${disclaimerColors.text};border:1px solid ${disclaimerColors.border};border-radius:16px;width:460px;padding:28px 32px;box-shadow:0 24px 80px rgba(0,0,0,.65), 0 0 0 1px ${disclaimerColors.shadowRgba};animation:slideUp 0.12s ease-out;`; + + const iconContainer = document.createElement("div"); + iconContainer.style.cssText = "text-align:center;margin-bottom:16px;"; + const icon = document.createElement("i"); + icon.className = "fa-solid fa-triangle-exclamation"; + icon.style.cssText = `color:#FFD54F;font-size:32px;`; + iconContainer.appendChild(icon); + + const titleEl = document.createElement("div"); + titleEl.style.cssText = `font-size:20px;font-weight:600;text-align:center;margin-bottom:16px;color:#FFD54F;`; + titleEl.textContent = t("disclaimer.title", "Quick Note"); + + const messageEl = document.createElement("div"); + messageEl.style.cssText = `font-size:13px;line-height:1.6;margin-bottom:20px;color:${disclaimerColors.textSecondary};text-align:center;`; + + const line1 = document.createElement("div"); + line1.style.cssText = `margin-bottom:8px;font-weight:500;color:${disclaimerColors.text};font-size:14px;`; + line1.textContent = t( + "disclaimer.line1", + "LuaTools is not affiliated with Millennium", + ); + + const line2 = document.createElement("div"); + line2.style.cssText = "margin-bottom:8px;"; + line2.textContent = t( + "disclaimer.line2", + "Millennium will not offer support for this plugin on their server", + ); + + const line3 = document.createElement("div"); + line3.style.cssText = `font-weight:500;color:#FFD54F;font-size:13px;`; + line3.textContent = t( + "disclaimer.line3", + "Please use our Discord for any questions — asking in Millennium servers may result in a ban", + ); + + messageEl.appendChild(line1); + messageEl.appendChild(line2); + messageEl.appendChild(line3); + + const inputGroup = document.createElement("div"); + inputGroup.style.cssText = "margin-bottom:16px;"; + + const inputLabel = document.createElement("div"); + inputLabel.style.cssText = `font-size:11px;color:${disclaimerColors.textSecondary};margin-bottom:8px;text-align:center;text-transform:uppercase;letter-spacing:1px;`; + inputLabel.textContent = t( + "disclaimer.inputLabel", + 'type "I Understand" in the box bellow to continue', + ); + + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = t("disclaimer.inputPlaceholder", "I Understand"); + input.style.cssText = `width:100%;box-sizing:border-box;background:${disclaimerColors.bgTertiary};border:1px solid ${disclaimerColors.borderRgba};border-radius:10px;padding:10px 14px;color:${disclaimerColors.text};font-size:14px;outline:none;text-align:center;transition:all 0.2s ease;`; + input.onfocus = function () { + this.style.borderColor = disclaimerColors.accent; + this.style.boxShadow = `0 0 0 2px rgba(${disclaimerColors.rgbString},0.2)`; + }; + input.onblur = function () { + this.style.borderColor = disclaimerColors.borderRgba; + this.style.boxShadow = "none"; + }; + + inputGroup.appendChild(inputLabel); + inputGroup.appendChild(input); + + const btnRow = document.createElement("div"); + btnRow.style.cssText = "display:flex;justify-content:center;"; + + const confirmBtn = document.createElement("a"); + confirmBtn.href = "#"; + confirmBtn.className = "luatools-btn primary"; + confirmBtn.style.minWidth = "160px"; + confirmBtn.style.justifyContent = "center"; + confirmBtn.style.textAlign = "center"; + confirmBtn.style.display = "flex"; + confirmBtn.innerHTML = `${lt("Confirm")}`; + confirmBtn.style.opacity = "0.5"; + confirmBtn.style.pointerEvents = "none"; + + var expectedPhrase = t("disclaimer.inputPlaceholder", "I Understand") + .trim() + .toLowerCase(); + input.oninput = function () { + if (this.value.trim().toLowerCase() === expectedPhrase) { + confirmBtn.style.opacity = "1"; + confirmBtn.style.pointerEvents = "auto"; + confirmBtn.style.boxShadow = `0 4px 12px ${disclaimerColors.shadow}`; + } else { + confirmBtn.style.opacity = "0.5"; + confirmBtn.style.pointerEvents = "none"; + confirmBtn.style.boxShadow = "none"; + } + }; + + confirmBtn.onclick = function (e) { + e.preventDefault(); + if (input.value.trim().toLowerCase() === expectedPhrase) { + localStorage.setItem("luatools millennium disclaimer accepted", "1"); + overlay.remove(); + } + }; + + btnRow.appendChild(confirmBtn); + + modal.appendChild(iconContainer); + modal.appendChild(titleEl); + modal.appendChild(messageEl); + modal.appendChild(inputGroup); + modal.appendChild(btnRow); + overlay.appendChild(modal); + + document.body.appendChild(overlay); + + // Focus input after a short delay + setTimeout(() => input.focus(), 300); + + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + } + + // Ensure consistent spacing for our buttons + function ensureStyles() { + if (!document.getElementById("luatools-spacing-styles")) { + const style = document.createElement("style"); + style.id = "luatools-spacing-styles"; + style.textContent = ` + .luatools-restart-button { margin-left: 6px !important; margin-right: 6px !important; } + .luatools-button { margin-right: 0 !important; position: relative !important; } + .luatools-pills-container { + position: absolute !important; + top: -25px !important; + left: 50% !important; + transform: translateX(-50%) !important; + display: inline-flex; + gap: 4px; + align-items: center; + pointer-events: none; + z-index: 10; + white-space: nowrap; + } + .luatools-pill { + padding: 2px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + display: inline-flex; + align-items: center; + height: 16px; + line-height: 1; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + cursor: default; + } + .luatools-pill.red { background: rgba(255, 80, 80, 0.15); color: #ff5050; border: 1px solid rgba(255, 80, 80, 0.3); } + .luatools-pill.green { background: rgba(92, 184, 92, 0.15); color: #5cb85c; border: 1px solid rgba(92, 184, 92, 0.3); } + .luatools-pill.yellow { background: rgba(255, 193, 7, 0.15); color: #ffc107; border: 1px solid rgba(255, 193, 7, 0.3); } + .luatools-pill.orange { background: rgba(255, 136, 0, 0.15); color: #ff8800; border: 1px solid rgba(255, 136, 0, 0.3); } + .luatools-pill.gray { background: rgba(150, 150, 150, 0.15); color: #a0a0a0; border: 1px solid rgba(150, 150, 150, 0.3); } + `; + document.head.appendChild(style); // This is now separate from the main style block + } + } + + // Function to update button text with current translations + function updateButtonTranslations() { + try { + // Update Restart Steam button + const restartBtn = document.querySelector(".luatools-restart-button"); + if (restartBtn) { + const restartText = lt("Restart Steam"); + restartBtn.title = restartText; + restartBtn.setAttribute("data-tooltip-text", restartText); + const rspan = restartBtn.querySelector("span"); + if (rspan) { + rspan.textContent = restartText; + } + } + + // Update Add via LuaTools button + const luatoolsBtn = document.querySelector(".luatools-button"); + if (luatoolsBtn) { + const addViaText = lt("Add via LuaTools"); + luatoolsBtn.title = addViaText; + luatoolsBtn.setAttribute("data-tooltip-text", addViaText); + const span = luatoolsBtn.querySelector("span"); + if (span) { + span.textContent = addViaText; + } + } + } catch (err) { + backendLog("LuaTools: updateButtonTranslations error: " + err); + } + } + + // Function to add the LuaTools button + // Add throttle to prevent excessive executions + let lastButtonCheckTime = 0; + const BUTTON_CHECK_THROTTLE = 500; // Only run once every 500ms + + function addLuaToolsButton() { + // Throttle to prevent blocking gamepad input + const now = Date.now(); + if (now - lastButtonCheckTime < BUTTON_CHECK_THROTTLE) { + return; // Skip this execution, too soon + } + lastButtonCheckTime = now; + + // Track current URL to detect page changes + const currentUrl = window.location.href; + if (window.__LuaToolsLastUrl !== currentUrl) { + // Page changed - reset button insertion flag and update translations + window.__LuaToolsLastUrl = currentUrl; + window.__LuaToolsButtonInserted = false; + window.__LuaToolsRestartInserted = false; + window.__LuaToolsIconInserted = false; + window.__LuaToolsHeaderInserted = false; + window.__LuaToolsPresenceCheckInFlight = false; + window.__LuaToolsPresenceCheckAppId = undefined; + // Ensure translations are loaded and update existing buttons + ensureTranslationsLoaded(false).then(function () { + updateButtonTranslations(); + }); + } + + // Store Header Button Logic (always visible) + const headerContainer = document.querySelector("._1wn1lBlAzl3HMRqS1llwie"); + if ( + headerContainer && + !document.querySelector(".luatools-header-button") && + !window.__LuaToolsHeaderInserted + ) { + ensureLuaToolsStyles(); + const headerBtn = document.createElement("button"); + headerBtn.type = "button"; + headerBtn.className = "luatools-header-button Focusable"; + headerBtn.tabIndex = "0"; + headerBtn.title = "LuaTools Settings"; + headerBtn.setAttribute("data-tooltip-text", "LuaTools Settings"); + + const img = document.createElement("img"); + img.style.height = "18px"; + img.style.width = "18px"; + img.style.verticalAlign = "middle"; + + img.onerror = function () { + // cogwheel fallback + headerBtn.innerHTML = + ''; + }; + + img.src = "LuaTools/luatools-icon.png"; + + Millennium.callServerMethod("luatools", "GetIconDataUrl", {}) + .then(function (res) { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success && payload.dataUrl) { + img.src = payload.dataUrl; + } + }) + .catch(function () {}); + + headerBtn.appendChild(img); + + headerBtn.onclick = function (e) { + e.preventDefault(); + showSettingsPopup(); + }; + + headerContainer.appendChild(headerBtn); + window.__LuaToolsHeaderInserted = true; + backendLog("Inserted store header button"); + } + + // Check if we're in Big Picture mode + const isBigPicture = window.__LUATOOLS_IS_BIG_PICTURE__; + + // Look for the appropriate container based on mode + let targetContainer; + if (isBigPicture) { + // In Big Picture mode, use the queue button's parent as reference + const queueBtn = document.querySelector("#queueBtnFollow"); + targetContainer = queueBtn ? queueBtn.parentElement : null; + } else { + // In normal mode, use the SteamDB buttons container + targetContainer = + document.querySelector(".steamdb-buttons") || + document.querySelector("[data-steamdb-buttons]") || + document.querySelector(".apphub_OtherSiteInfo"); + } + + if (targetContainer) { + const steamdbContainer = targetContainer; + + // Insert a Restart Steam button between Community Hub and our LuaTools button + try { + if ( + !document.querySelector(".luatools-restart-button") && + !window.__LuaToolsRestartInserted + ) { + ensureStyles(); + // In Big Picture mode, use queue button as reference; otherwise use first link in container + const referenceBtn = isBigPicture + ? document.querySelector("#queueBtnFollow") + : steamdbContainer.querySelector("a"); + + // Use same custom button for both modes + const restartBtn = document.createElement("a"); + if (referenceBtn && referenceBtn.className) { + restartBtn.className = + referenceBtn.className + " luatools-restart-button"; + } else { + restartBtn.className = + "btnv6_blue_hoverfade btn_medium luatools-restart-button"; + } + restartBtn.href = "#"; + const restartText = lt("Restart Steam"); + restartBtn.title = restartText; + restartBtn.setAttribute("data-tooltip-text", restartText); + const rspan = document.createElement("span"); + rspan.textContent = restartText; + restartBtn.appendChild(rspan); + + // Normalize margins to match native buttons + try { + if (referenceBtn) { + const cs = window.getComputedStyle(referenceBtn); + restartBtn.style.marginLeft = cs.marginLeft; + restartBtn.style.marginRight = cs.marginRight; + } + } catch (_) {} + + restartBtn.addEventListener("click", function (e) { + e.preventDefault(); + try { + // Ensure any settings overlays are closed before confirm + closeSettingsOverlay(); + askRestartConfirmation(); + } catch (_) { + askRestartConfirmation(); + } + }); + + if (referenceBtn && referenceBtn.parentElement) { + referenceBtn.after(restartBtn); + } else { + steamdbContainer.appendChild(restartBtn); + } + window.__LuaToolsRestartInserted = true; + backendLog("Inserted Restart Steam button"); + } + } catch (_) {} + + // Status Pills Logic + // Always update translations for existing buttons (even if not a page change) + const existingBtn = document.querySelector(".luatools-button"); + if (existingBtn) { + ensureTranslationsLoaded(false).then(function () { + updateButtonTranslations(); + }); + } + + // Check if button already exists to avoid duplicates + if (!existingBtn && !window.__LuaToolsButtonInserted) { + // Create the LuaTools button modeled after existing SteamDB/PCGW buttons + // In Big Picture mode, use queue button as reference; otherwise use first link in container + let referenceBtn = isBigPicture + ? document.querySelector("#queueBtnFollow") + : steamdbContainer.querySelector("a"); + + // Use same custom button for both modes + const luatoolsButton = document.createElement("a"); + luatoolsButton.href = "#"; + // Copy classes from an existing button to match look-and-feel, but set our own label + if (referenceBtn && referenceBtn.className) { + luatoolsButton.className = + referenceBtn.className + " luatools-button"; + } else { + luatoolsButton.className = + "btnv6_blue_hoverfade btn_medium luatools-button"; + } + const span = document.createElement("span"); + const addViaText = lt("Add via LuaTools"); + span.textContent = addViaText; + luatoolsButton.appendChild(span); + // Tooltip/title + luatoolsButton.title = addViaText; + luatoolsButton.setAttribute("data-tooltip-text", addViaText); + + // Normalize margins to match native buttons + try { + if (referenceBtn) { + const cs = window.getComputedStyle(referenceBtn); + luatoolsButton.style.marginLeft = cs.marginLeft; + luatoolsButton.style.marginRight = cs.marginRight; + } + } catch (_) {} + + // Local click handler suppressed; delegated handler manages actions + luatoolsButton.addEventListener("click", function (e) { + e.preventDefault(); + backendLog( + "LuaTools button clicked (delegated handler will process)", + ); + }); + + // Before inserting, ask backend if LuaTools already exists for this appid + try { + const match = + window.location.href.match( + /https:\/\/store\.steampowered\.com\/app\/(\d+)/, + ) || + window.location.href.match( + /https:\/\/steamcommunity\.com\/app\/(\d+)/, + ); + const appid = match ? parseInt(match[1], 10) : NaN; + if ( + !isNaN(appid) && + typeof Millennium !== "undefined" && + typeof Millennium.callServerMethod === "function" + ) { + // prevent multiple concurrent checks + if ( + window.__LuaToolsPresenceCheckInFlight && + window.__LuaToolsPresenceCheckAppId === appid + ) { + return; + } + window.__LuaToolsPresenceCheckInFlight = true; + window.__LuaToolsPresenceCheckAppId = appid; + window.__LuaToolsCurrentAppId = appid; + Millennium.callServerMethod("luatools", "HasLuaToolsForApp", { + appid, + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.success && payload.exists === true) { + backendLog( + "LuaTools already present for this app; not inserting button", + ); + window.__LuaToolsPresenceCheckInFlight = false; + return; // do not insert + } + // Re-check in case another caller inserted during async + if ( + !document.querySelector(".luatools-button") && + !window.__LuaToolsButtonInserted + ) { + // Insert after restart button (order: Restart → Add) + const restartExisting = steamdbContainer.querySelector( + ".luatools-restart-button", + ); + if (restartExisting && restartExisting.after) { + restartExisting.after(luatoolsButton); + } else if (referenceBtn && referenceBtn.after) { + referenceBtn.after(luatoolsButton); + } else { + steamdbContainer.appendChild(luatoolsButton); + } + window.__LuaToolsButtonInserted = true; + backendLog("LuaTools button inserted"); + } + window.__LuaToolsPresenceCheckInFlight = false; + } catch (_) { + if ( + !document.querySelector(".luatools-button") && + !window.__LuaToolsButtonInserted + ) { + steamdbContainer.appendChild(luatoolsButton); + window.__LuaToolsButtonInserted = true; + backendLog("LuaTools button inserted"); + } + window.__LuaToolsPresenceCheckInFlight = false; + } + }); + } else { + if ( + !document.querySelector(".luatools-button") && + !window.__LuaToolsButtonInserted + ) { + // Insert after restart button (order: Restart → Add) + const restartExisting = steamdbContainer.querySelector( + ".luatools-restart-button", + ); + if (restartExisting && restartExisting.after) { + restartExisting.after(luatoolsButton); + } else if (referenceBtn && referenceBtn.after) { + referenceBtn.after(luatoolsButton); + } else { + steamdbContainer.appendChild(luatoolsButton); + } + window.__LuaToolsButtonInserted = true; + backendLog("LuaTools button inserted"); + } + } + } catch (_) { + if ( + !document.querySelector(".luatools-button") && + !window.__LuaToolsButtonInserted + ) { + const restartExisting = steamdbContainer.querySelector( + ".luatools-restart-button", + ); + if (restartExisting && restartExisting.after) { + restartExisting.after(luatoolsButton); + } else if (referenceBtn && referenceBtn.after) { + referenceBtn.after(luatoolsButton); + } else { + steamdbContainer.appendChild(luatoolsButton); + } + window.__LuaToolsButtonInserted = true; + backendLog("LuaTools button inserted"); + } + } + } + + // status pills — only run once per appid + try { + const match = + window.location.href.match( + /https:\/\/store\.steampowered\.com\/app\/(\d+)/, + ) || + window.location.href.match( + /https:\/\/steamcommunity\.com\/app\/(\d+)/, + ); + const appid = match + ? parseInt(match[1], 10) + : window.__LuaToolsCurrentAppId || NaN; + + if (!isNaN(appid)) { + const pillBtn = steamdbContainer.querySelector(".luatools-button"); + if (pillBtn) { + // Skip if pills already built for this appid + var existingPills = pillBtn.querySelector( + ".luatools-pills-container", + ); + if ( + !( + existingPills && + existingPills.dataset.appid === String(appid) && + existingPills.dataset.content + ) + ) { + fetchGamesDatabase().then(function (db) { + const btn = steamdbContainer.querySelector(".luatools-button"); + if (!btn) return; + + let pillsContainer = btn.querySelector( + ".luatools-pills-container", + ); + + if (!pillsContainer) { + pillsContainer = document.createElement("div"); + pillsContainer.className = "luatools-pills-container"; + btn.appendChild(pillsContainer); + } + pillsContainer.dataset.appid = String(appid); + + const key = String(appid); + const gameData = db && db[key] ? db[key] : null; + + // check denuvo + const drmNotice = document.querySelector(".DRM_notice"); + const hasDenuvo = + drmNotice && drmNotice.textContent.includes("Denuvo"); + + fetchFixes(appid).then(function (fixesData) { + const hasFixes = + fixesData && + ((fixesData.genericFix && + fixesData.genericFix.status === 200) || + (fixesData.onlineFix && + fixesData.onlineFix.status === 200)); + const showDenuvoPill = hasDenuvo && !hasFixes; + + const cacheKey = JSON.stringify({ + d: gameData || "untested", + showDenuvo: showDenuvoPill, + hasFixes: hasFixes, + }); + + if (pillsContainer.dataset.content === cacheKey) return; + pillsContainer.dataset.content = cacheKey; + + pillsContainer.innerHTML = ""; + + let status = "untested"; + if (gameData && typeof gameData.playable !== "undefined") { + if (gameData.playable === 1) status = "playable"; + else if (gameData.playable === 0) status = "unplayable"; + else if (gameData.playable === 2) status = "needs_fixes"; + } + + if (status === "untested" && hasFixes) { + status = "needs_fixes"; + } + + if (status !== "untested") { + const pill = document.createElement("span"); + pill.className = "luatools-pill"; + if (status === "playable") { + pill.classList.add("green"); + pill.textContent = t("gameStatus.playable", "Playable"); + } else if (status === "unplayable") { + pill.classList.add("red"); + pill.textContent = t( + "gameStatus.unplayable", + "Unplayable", + ); + } else if (status === "needs_fixes") { + pill.classList.add("yellow"); + pill.textContent = t( + "gameStatus.needsFixes", + "Needs fixes", + ); + } + pillsContainer.appendChild(pill); + } + + // reset button state + const btn = + steamdbContainer.querySelector(".luatools-button"); + if (btn) { + btn.style.opacity = ""; + btn.style.pointerEvents = ""; + btn.style.cursor = ""; + const span = btn.querySelector("span"); + if (span && span.textContent === "Unplayable") { + span.textContent = lt("Add via LuaTools"); + } + } + + if (showDenuvoPill) { + const pill = document.createElement("span"); + pill.className = "luatools-pill orange"; + pill.textContent = t("gameStatus.denuvo", "Denuvo"); + pillsContainer.appendChild(pill); + } + }); + }); + } + } + } + } catch (e) { + /* ignore */ + } + } else { + if (!logState.missingOnce) { + backendLog("LuaTools: steamdbContainer not found on this page"); + logState.missingOnce = true; + } + } + } + + // Try to add the button immediately if DOM is ready + function onFrontendReady() { + // Fetch settings + translations FIRST, then insert the button once in the correct language + try { + fetchSettingsConfig(true) + .then(function (cfg) { + try { + ensureLuaToolsStyles(); + } catch (_) {} + + // Show disclaimer after translations are loaded so it displays in the correct language + try { + if (window.location.hostname === "store.steampowered.com") { + if ( + localStorage.getItem( + "luatools millennium disclaimer accepted", + ) !== "1" + ) { + showMillenniumDisclaimerModal(); + } + } + } catch (_) {} + + // Now translations are ready — insert the button in the correct language + addLuaToolsButton(); + }) + .catch(function (_) { + // Settings failed, still insert button (English fallback) + addLuaToolsButton(); + }); + } catch (_) { + addLuaToolsButton(); + } + + // Show gamepad hint if connected (only in Big Picture mode) + setTimeout(function () { + if ( + window.GamepadNav && + window.GamepadNav.isConnected && + window.GamepadNav.isConnected() + ) { + backendLog("[LuaTools] Gamepad detected - Navigation enabled"); + + // Only show visual hint in Big Picture mode + if (window.__LUATOOLS_IS_BIG_PICTURE__) { + const hint = document.createElement("div"); + hint.id = "luatools-gamepad-hint"; + hint.innerHTML = "🎮 " + lt("bigpicture.mouseTip"); + hint.style.cssText = + "\ + position: fixed;\ + bottom: 20px;\ + right: 20px;\ + background: rgba(11, 20, 30, 0.9);\ + color: #66c0f4;\ + padding: 12px 16px;\ + border-radius: 8px;\ + font-size: 14px;\ + z-index: 99998;\ + border: 1px solid rgba(102, 192, 244, 0.3);\ + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);\ + animation: fadeInOut 3s ease-in-out;\ + "; + + // Add CSS animation if not already present + if (!document.querySelector("#luatools-gamepad-hint-styles")) { + const style = document.createElement("style"); + style.id = "luatools-gamepad-hint-styles"; + style.textContent = + "\ + @keyframes fadeInOut {\ + 0% { opacity: 0; transform: translateY(10px); }\ + 10% { opacity: 1; transform: translateY(0); }\ + 90% { opacity: 1; transform: translateY(0); }\ + 100% { opacity: 0; transform: translateY(10px); }\ + }\ + "; + document.head.appendChild(style); + } + + document.body.appendChild(hint); + + // Auto-remove after animation + setTimeout(function () { + if (hint && hint.parentElement) { + hint.remove(); + } + }, 3000); + } + } + }, 500); + + // Ask backend if there is a queued startup message from InitApis + try { + if ( + typeof Millennium !== "undefined" && + typeof Millennium.callServerMethod === "function" + ) { + Millennium.callServerMethod("luatools", "GetInitApisMessage", { + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (payload && payload.message) { + const msg = String(payload.message); + // Check if this is an update message (contains "update" or "restart") + const isUpdateMsg = + msg.toLowerCase().includes("update") || + msg.toLowerCase().includes("restart"); + + if (isUpdateMsg) { + // For update messages, use confirm dialog with OK (restart) and Cancel options + askRestartConfirmation(); + } else { + // For non-update messages, use regular alert + ShowLuaToolsAlert("LuaTools", msg); + } + } + } catch (_) {} + }); + // Also show loaded apps list if present (only once per session, store page only) + try { + if (window.location.hostname === "store.steampowered.com") { + if (!sessionStorage.getItem("LuaToolsLoadedAppsGate")) { + sessionStorage.setItem("LuaToolsLoadedAppsGate", "1"); + Millennium.callServerMethod("luatools", "ReadLoadedApps", { + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = + typeof res === "string" ? JSON.parse(res) : res; + const apps = + payload && payload.success && Array.isArray(payload.apps) + ? payload.apps + : []; + if (apps.length > 0) { + showLoadedAppsPopup(apps); + } + } catch (_) {} + }); + } + } + } catch (_) {} + } + } catch (_) {} + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", onFrontendReady); + } else { + onFrontendReady(); + } + + // Delegate click handling in case the DOM is re-rendered and listeners are lost + // Use bubble phase instead of capture phase to avoid interfering with gamepad navigation + document.addEventListener( + "click", + function (evt) { + // Quick exit if target doesn't have closest method or isn't an element + if (!evt.target || !evt.target.closest) return; + + const anchor = evt.target.closest(".luatools-button"); + if (anchor) { + evt.preventDefault(); + evt.stopPropagation(); // Stop propagation to avoid conflicts + backendLog("LuaTools delegated click"); + try { + const match = + window.location.href.match( + /https:\/\/store\.steampowered\.com\/app\/(\d+)/, + ) || + window.location.href.match( + /https:\/\/steamcommunity\.com\/app\/(\d+)/, + ); + const appid = match ? parseInt(match[1], 10) : NaN; + if ( + !isNaN(appid) && + typeof Millennium !== "undefined" && + typeof Millennium.callServerMethod === "function" + ) { + if (runState.inProgress && runState.appid === appid) { + backendLog( + "LuaTools: operation already in progress for this appid", + ); + return; + } + + // Helper that continues with the multi-API check flow + const continueWithAdd = function () { + // Open the loading popup first to show "Searching..." + showTestPopup(); + const overlay = document.querySelector(".luatools-overlay"); + const status = overlay + ? overlay.querySelector(".luatools-status") + : null; + const apiList = overlay + ? overlay.querySelector(".luatools-api-list") + : null; + + if (status) + status.textContent = lt("Searching across sources..."); + + Millennium.callServerMethod("luatools", "CheckApisForApp", { + appid, + contentScriptQuery: "", + }) + .then(function (res) { + try { + const payload = + typeof res === "string" ? JSON.parse(res) : res; + if (!payload || !payload.success) { + throw new Error(payload.error || "Check failed"); + } + + const results = payload.results || []; + const available = results.filter((r) => r.available); + + if (available.length === 0) { + const msg = lt("Game not found on any available API."); + if (status) status.textContent = msg; + const hideBtn = overlay + ? overlay.querySelector(".luatools-hide-btn") + : null; + if (hideBtn) + hideBtn.innerHTML = "" + lt("Close") + ""; + return; + } + + let isFastDownload = true; // default + try { + if ( + window.__LuaToolsSettings && + window.__LuaToolsSettings.values && + window.__LuaToolsSettings.values.general + ) { + if ( + typeof window.__LuaToolsSettings.values.general + .fastDownload !== "undefined" + ) { + isFastDownload = + window.__LuaToolsSettings.values.general + .fastDownload; + } + } + } catch (e) {} + + if (available.length === 1 || isFastDownload) { + // Only one source or fast download enabled, proceed automatically with the first available + const source = available[0]; + backendLog( + "LuaTools: Auto-selecting " + + (available.length === 1 + ? "only source" + : "source via fast download") + + ": " + + source.name, + ); + startDirectDownload(appid, available, 0); + } else { + // Multiple sources, let user select + showSourceSelectionModal(appid, available); + } + } catch (err) { + backendLog("LuaTools: CheckApisForApp error: " + err); + if (status) + status.textContent = lt("Error: {error}").replace( + "{error}", + err.message, + ); + } + }) + .catch(function (err) { + backendLog("LuaTools: CheckApisForApp promise error: " + err); + }); + }; + + const startDirectDownload = function ( + appid, + availableSources, + index = 0, + ) { + const source = availableSources[index]; + const url = source.url; + const apiName = source.name; + + const performDownload = function () { + runState.inProgress = true; + runState.appid = appid; + + // If the selection modal was open, it should be replaced by showTestPopup or updated + const overlay = document.querySelector(".luatools-overlay"); + if (overlay) { + // Reset for progress + const status = overlay.querySelector(".luatools-status"); + if (status) { + if (index > 0) { + status.textContent = lt( + "Failed on {previous}. Trying {current}...", + ) + .replace("{previous}", availableSources[index - 1].name) + .replace("{current}", apiName); + } else { + status.textContent = lt("Initializing download..."); + } + } + const progressWrap = overlay.querySelector( + ".luatools-progress-wrap", + ); + if (progressWrap) progressWrap.style.display = "block"; + const progressInfo = overlay.querySelector( + ".luatools-progress-info", + ); + if (progressInfo) progressInfo.style.display = "block"; + const cancelBtn = overlay.querySelector( + ".luatools-cancel-btn", + ); + if (cancelBtn) cancelBtn.style.display = "flex"; + } else { + showTestPopup(); + } + + Millennium.callServerMethod( + "luatools", + "StartAddViaLuaToolsFromUrl", + { + appid, + url, + apiName, + contentScriptQuery: "", + }, + ); + + const onFailedCallback = function (errMsg) { + if (index + 1 < availableSources.length) { + backendLog( + "LuaTools: Fast download failed on " + + apiName + + " (" + + errMsg + + "). Trying next API: " + + availableSources[index + 1].name, + ); + setTimeout(function () { + startDirectDownload(appid, availableSources, index + 1); + }, 1500); + } + }; + + startPolling(appid, onFailedCallback); + }; + + if (apiName && apiName.toLowerCase().includes("morrenus")) { + let hubcapKey = ""; + try { + if ( + window.__LuaToolsSettings && + window.__LuaToolsSettings.values && + window.__LuaToolsSettings.values.advanced + ) { + hubcapKey = + window.__LuaToolsSettings.values.advanced + .morrenusApiKey || ""; + } + if (!hubcapKey) { + for (const group in window.__LuaToolsSettings.values) { + if ( + window.__LuaToolsSettings.values[group] && + window.__LuaToolsSettings.values[group].morrenusApiKey + ) { + hubcapKey = + window.__LuaToolsSettings.values[group] + .morrenusApiKey; + break; + } + } + } + } catch (e) {} + + if (hubcapKey && /^smm_[0-9a-f]{96}$/.test(hubcapKey)) { + // Wait, check the limits + showTestPopup(); // Ensures basic loading modal is up + const overlay = document.querySelector(".luatools-overlay"); + if (overlay) { + const status = overlay.querySelector(".luatools-status"); + if (status) + status.textContent = lt("Verifying API limits..."); + const cancelBtn = overlay.querySelector( + ".luatools-cancel-btn", + ); + if (cancelBtn) cancelBtn.style.display = "none"; + } + + Millennium.callServerMethod("luatools", "GetMorrenusStats", { + api_key: hubcapKey, + force_refresh: true, + contentScriptQuery: "", + }) + .then((r) => (typeof r === "string" ? JSON.parse(r) : r)) + .then((res) => { + if ( + res && + res.detail === "API key not found or expired" + ) { + // 401 - invalid or expired key + showLuaToolsPlayableWarning( + lt( + "Your Morrenus API key is invalid or expired. Please check your key in the settings or regenerate it on the Morrenus website.", + ), + function () { + showSettingsManagerPopup(false, null); + }, + null, + ); + runState.inProgress = false; + } else if ( + res && + typeof res.detail === "string" && + res.detail.startsWith("Daily limit reached") + ) { + // 429 - daily limit exhausted + showLuaToolsPlayableWarning( + lt( + "You have exceeded your daily download limit. Please wait until tomorrow for more uses, or upgrade your plan on the Morrenus website.", + ), + function () { + showSettingsManagerPopup(false, null); + }, + null, + ); + runState.inProgress = false; + } else if ( + res && + typeof res.daily_usage !== "undefined" && + typeof res.daily_limit !== "undefined" && + res.daily_usage >= res.daily_limit + ) { + // usage fields show limit reached (fallback) + showLuaToolsPlayableWarning( + lt( + "You have exceeded your daily download limit. Please wait until tomorrow for more uses, or upgrade your plan on the Morrenus website.", + ), + function () { + showSettingsManagerPopup(false, null); + }, + null, + ); + runState.inProgress = false; + } else { + performDownload(); + } + }) + .catch((e) => { + backendLog( + "LuaTools: Error checking Morrenus API limit: " + e, + ); + // Network error or other, try to proceed and let the backend error it if needed + performDownload(); + }); + return; // yield execution to async fetch + } + } + + // Normal flow if not Morrenus or no key present + performDownload(); + }; + + function showSourceSelectionModal(appid, available) { + const overlay = document.querySelector(".luatools-overlay"); + if (!overlay) return; + + const colors = getThemeColors(); + const title = overlay.querySelector(".luatools-title"); + const status = overlay.querySelector(".luatools-status"); + const apiList = overlay.querySelector(".luatools-api-list"); + + if (title) title.textContent = lt("Select Download Source"); + if (status) status.style.display = "none"; // Remove "Multiple sources found" text + + if (apiList) { + apiList.innerHTML = ""; + apiList.style.cssText = + "display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:16px;"; + + available.forEach((source) => { + const btn = document.createElement("a"); + btn.href = "#"; + btn.className = "luatools-btn focusable"; + btn.style.cssText = `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;flex:1;min-width:80px;padding:12px 8px;background:rgba(${colors.rgbString},0.06);border:1px solid ${colors.borderRgba};border-radius:12px;text-decoration:none;transition:all 0.2s ease;text-align:center;`; + + const srcIcon = document.createElement("i"); + srcIcon.className = "fa-solid fa-server"; + srcIcon.style.cssText = `font-size:18px;color:${colors.accent};`; + + const name = document.createElement("div"); + name.style.cssText = `font-size:11px; font-weight:500; color:${colors.text};line-height:1.2;`; + name.textContent = source.name; + + btn.appendChild(srcIcon); + btn.appendChild(name); + + btn.onmouseover = function () { + this.style.background = `rgba(${colors.rgbString},0.25)`; + this.style.borderColor = colors.accent; + this.style.transform = "translateY(-1px)"; + }; + btn.onmouseout = function () { + this.style.background = `rgba(${colors.rgbString},0.1)`; + this.style.borderColor = colors.borderRgba; + this.style.transform = "translateY(0)"; + }; + + btn.onclick = function (e) { + e.preventDefault(); + apiList.style.display = "block"; // Reset layout for progress + apiList.style.flexDirection = ""; + apiList.innerHTML = ""; // Clear selection buttons + if (status) status.style.display = ""; // Restore status text + startDirectDownload(appid, [source], 0); + }; + + apiList.appendChild(btn); + }); + } + + // Update Cancel button: show it, hide the Hide/Close button, and make it close the modal + const cancelBtn = overlay.querySelector(".luatools-cancel-btn"); + const hideBtn = overlay.querySelector(".luatools-hide-btn"); + + if (cancelBtn) { + cancelBtn.style.display = "flex"; + cancelBtn.innerHTML = `${lt("Cancel")}`; + cancelBtn.onclick = function (e) { + e.preventDefault(); + overlay.remove(); // Close modal immediately + }; + } + + if (hideBtn) { + hideBtn.style.display = "none"; // Remove "Hide" button as per request + } + + // Re-scan for gamepad + if (window.GamepadNav) window.GamepadNav.scanElements(); + } + + // Check if this is a dlc + const isdlc = !!document.querySelector(".game_area_dlc_bubble"); + const parentdiv = document.querySelector( + '.glance_details a[href*="/app/"]', + ); + + if (isdlc && parentdiv) { + const id = parseInt( + parentdiv.href.match(/app\/(\d+)\//)?.[1] ?? "", + ); + const name = parentdiv.innerText ?? "name not found"; + + showDlcWarning(appid, id, name); + } else { + // Not a dlc (or failed) ? Then continue normally + return fetchGamesDatabase().then(function (db) { + try { + const gameData = db?.[String(appid)] ?? null; + if (gameData?.playable === 0) { + // warning modal + showLuaToolsPlayableWarning( + "This game may not work, support for it wont be given in our discord", + function () { + continueWithAdd(); + }, + function () {}, + ); + } else { + continueWithAdd(); + } + } catch (_) { + continueWithAdd(); + } + }); + } + } + } catch (_) {} + } + }, + false, + ); // Changed from true to false (bubble phase instead of capture phase) + + // Poll backend for progress and update progress bar and text + function startPolling(appid, onFailedCallback) { + let done = false; + let lastCheckedApi = null; + let successfulApi = null; // Track which API successfully found the file + const timer = setInterval(() => { + if (done) { + clearInterval(timer); + return; + } + try { + Millennium.callServerMethod("luatools", "GetAddViaLuaToolsStatus", { + appid, + contentScriptQuery: "", + }).then(function (res) { + try { + const payload = typeof res === "string" ? JSON.parse(res) : res; + const st = payload && payload.state ? payload.state : {}; + + // Try to find overlay (may or may not be visible) + const overlay = document.querySelector(".luatools-overlay"); + const title = overlay + ? overlay.querySelector(".luatools-title") + : null; + const status = overlay + ? overlay.querySelector(".luatools-status") + : null; + const wrap = overlay + ? overlay.querySelector(".luatools-progress-wrap") + : null; + const progressInfo = overlay + ? overlay.querySelector(".luatools-progress-info") + : null; + const percent = overlay + ? overlay.querySelector(".luatools-percent") + : null; + const downloadSize = overlay + ? overlay.querySelector(".luatools-download-size") + : null; + const bar = overlay + ? overlay.querySelector(".luatools-progress-bar") + : null; + + // Update individual API status in the list + if (overlay) { + const colors = getThemeColors(); + const apiItems = overlay.querySelectorAll(".luatools-api-item"); + + // Track successful API when download/processing starts + if ( + (st.status === "downloading" || + st.status === "processing" || + st.status === "installing" || + st.status === "done") && + st.currentApi && + !successfulApi + ) { + successfulApi = st.currentApi; + + // Mark all APIs: not found before successful, skipped after + let foundSuccessful = false; + apiItems.forEach((item) => { + const apiName = item.getAttribute("data-api-name"); + const apiStatus = item.querySelector(".luatools-api-status"); + if (!apiStatus) return; + + if (apiName === successfulApi) { + foundSuccessful = true; + item.style.background = `rgba(${colors.rgbString},0.2)`; + item.style.borderColor = colors.accent; + apiStatus.innerHTML = `${lt("Found")}`; + } else if (!foundSuccessful) { + // This API comes before the successful one, check if it has an error first + if (st.apiErrors && st.apiErrors[apiName]) { + const apiError = st.apiErrors[apiName]; + item.style.background = `rgba(255, 0, 0, 0.15)`; + item.style.borderColor = "#ff5c5c"; + if (apiError.type === "timeout") { + apiStatus.innerHTML = `${lt("Error, Timed Out")}`; + } else if (apiError.type === "error") { + const code = apiError.code ? String(apiError.code) : ""; + apiStatus.innerHTML = `${lt("Error, Code: {code}").replace("{code}", code)}`; + } + } else { + // Mark as not found + item.style.background = `rgba(0,0,0,0.2)`; + item.style.borderColor = colors.borderRgba; + apiStatus.innerHTML = `${lt("Not found")}`; + } + } else { + // This API comes after the successful one, mark as skipped + item.style.background = `rgba(0,0,0,0.15)`; + item.style.borderColor = colors.borderRgba; + apiStatus.innerHTML = `${lt("Skipped")}`; + } + }); + } + + // Mark previous API as not found if we moved to a new one (only during checking phase) + if ( + st.status === "checking" && + st.currentApi && + st.currentApi !== lastCheckedApi && + lastCheckedApi + ) { + apiItems.forEach((item) => { + const apiName = item.getAttribute("data-api-name"); + const apiStatus = item.querySelector(".luatools-api-status"); + if (!apiStatus) return; + + if (apiName === lastCheckedApi) { + item.style.background = `rgba(0,0,0,0.2)`; + item.style.borderColor = colors.borderRgba; + apiStatus.innerHTML = `${lt("Not found")}`; + } + }); + } + + // Update current API status during checking + if (st.status === "checking" && st.currentApi) { + apiItems.forEach((item) => { + const apiName = item.getAttribute("data-api-name"); + const apiStatus = item.querySelector(".luatools-api-status"); + if (!apiStatus) return; + + if (apiName === st.currentApi) { + item.style.background = `rgba(${colors.rgbString},0.15)`; + item.style.borderColor = colors.accent; + apiStatus.innerHTML = `${lt("Checking…")}`; + } + }); + + lastCheckedApi = st.currentApi; + } + + // Show error statuses for APIs that errored (when not checking them anymore) + if (st.apiErrors && typeof st.apiErrors === "object") { + apiItems.forEach((item) => { + const apiName = item.getAttribute("data-api-name"); + const apiStatus = item.querySelector(".luatools-api-status"); + if (!apiStatus || !apiName) return; + + const apiError = st.apiErrors[apiName]; + if (!apiError) return; + + // Only show error if this API is not currently being checked + if (st.currentApi === apiName && st.status === "checking") + return; + + // Don't overwrite "Found" status + const statusText = apiStatus.textContent || ""; + if ( + statusText.includes("Found") || + statusText.includes("Encontrado") + ) + return; + + item.style.background = `rgba(255, 0, 0, 0.15)`; + item.style.borderColor = "#ff5c5c"; + + if (apiError.type === "timeout") { + apiStatus.innerHTML = `${lt("Error, Timed Out")}`; + } else if (apiError.type === "error") { + const code = apiError.code ? String(apiError.code) : ""; + apiStatus.innerHTML = `${lt("Error, Code: {code}").replace("{code}", code)}`; + } + }); + } + } + + // Update UI if overlay is present + if (st.status === "checking" && st.currentApi && title) { + title.textContent = lt("LuaTools · {api}").replace( + "{api}", + st.currentApi, + ); + } else if ( + (st.status === "downloading" || + st.status === "processing" || + st.status === "installing") && + title + ) { + title.textContent = t("common.appName", "LuaTools"); + } + + if (status) { + const spinner = + ''; + const dlIcon = + ''; + const gearIcon = + ''; + + if (st.status === "checking") + status.innerHTML = + spinner + "" + lt("Checking availability…") + ""; + if (st.status === "downloading") + status.innerHTML = + dlIcon + "" + lt("Downloading…") + ""; + if (st.status === "processing") + status.innerHTML = + gearIcon + "" + lt("Processing package…") + ""; + if (st.status === "installing") + status.innerHTML = + gearIcon + "" + lt("Installing…") + ""; + if (st.status === "checking content") + status.innerHTML = + spinner + "" + lt("Checking content…") + ""; + if (st.status === "failed") + status.innerHTML = + '' + + lt("Failed") + + ""; + } + if ( + ["downloading", "processing", "installing"].includes(st.status) + ) { + // reveal progress UI (if overlay visible) + if (wrap && wrap.style.display === "none") + wrap.style.display = "block"; + if (progressInfo && progressInfo.style.display === "none") { + progressInfo.style.display = "flex"; + progressInfo.style.justifyContent = "space-between"; + } + + const total = st.totalBytes || 0; + const read = st.bytesRead || 0; + let pct = + total > 0 ? Math.floor((read / total) * 100) : read ? 1 : 0; + if (pct > 100) pct = 100; + if (pct < 0) pct = 0; + + // Update bar and percentage + if (bar) bar.style.width = pct + "%"; + if (percent) percent.textContent = pct + "%"; + + // Format file sizes (only if we have size data) + if (downloadSize) { + if (total > 0) { + const formatBytes = (bytes) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return ( + Math.round((bytes / Math.pow(k, i)) * 100) / 100 + + " " + + sizes[i] + ); + }; + downloadSize.textContent = + formatBytes(read) + " / " + formatBytes(total); + } else { + downloadSize.textContent = ""; + } + } + // Show Cancel button during download + const cancelBtn = overlay + ? overlay.querySelector(".luatools-cancel-btn") + : null; + if (cancelBtn && st.status === "downloading") + cancelBtn.style.display = ""; + } + + if (["checking content", "done"].includes(st.status)) { + // Update popup if visible + if (title) title.textContent = t("common.appName", "LuaTools"); + if (bar) bar.style.width = "100%"; + if (percent) percent.textContent = "100%"; + + // hide progress visuals after a short beat + if (wrap || progressInfo) { + setTimeout(function () { + if (wrap) wrap.style.display = "none"; + if (progressInfo) progressInfo.style.display = "none"; + }, 300); + } + + // Hide Cancel button + const cancelBtn = overlay + ? overlay.querySelector(".luatools-cancel-btn") + : null; + if (cancelBtn) cancelBtn.style.display = "none"; + } + + if (st.status === "done") { + // Update popup if visible + if (overlay) { + const doneColors = getThemeColors(); + // Hide API list for clean look + const apiList = overlay.querySelector(".luatools-api-list"); + if (apiList) apiList.style.display = "none"; + // Hide progress + if (wrap) wrap.style.display = "none"; + if (progressInfo) progressInfo.style.display = "none"; + // Hide cancel + const cancelBtn = overlay.querySelector(".luatools-cancel-btn"); + if (cancelBtn) cancelBtn.style.display = "none"; + + // Update title with success icon + if (title) { + title.innerHTML = ""; + title.style.cssText = `display:flex;align-items:center;justify-content:center;gap:10px;font-size:20px;color:${doneColors.text};margin-bottom:12px;font-weight:600;`; + const checkIcon = document.createElement("i"); + checkIcon.className = "fa-solid fa-circle-check"; + checkIcon.style.cssText = `color:${doneColors.accent};font-size:24px;`; + const checkText = document.createElement("span"); + checkText.textContent = lt("Game Added!"); + title.appendChild(checkIcon); + title.appendChild(checkText); + } + + // Build status content + if (status) { + const result = st.contentCheckResult; + status.style.textAlign = "center"; + + if (!result) { + status.innerText = lt( + "The game has been added successfully.", + ); + } else { + const status_content = [ + lt("Content details =>"), + `\u00A0\u00A0• ${lt("Workshop: ")}${lt(result.workshop)}`, + ]; + if ( + result.dlc.missing.length || + result.dlc.included.length + ) { + status_content.push(`\u00A0\u00A0• ${lt("Dlc: ")}`); + if (result.dlc.included.length > 0) { + status_content.push( + `\u00A0\u00A0\u00A0\u00A0◦ ${lt("Included")}: ${result.dlc.included.length}`, + ); + } + if (result.dlc.missing.length > 0) { + const missingLinks = result.dlc.missing + .map( + (id) => + `${id}`, + ) + .join(", "); + status_content.push( + `\u00A0\u00A0\u00A0\u00A0◦ ${lt("Missing")}: ${result.dlc.missing.length} (${missingLinks})`, + ); + } + } + status.style.whiteSpace = "pre-line"; + status.innerHTML = status_content.join("\n"); + status + .querySelectorAll(".lt-dlc-link") + .forEach(function (link) { + link.addEventListener("click", function (e) { + e.preventDefault(); + try { + Millennium.callServerMethod( + "luatools", + "OpenExternalUrl", + { + url: + "https://steamdb.info/app/" + + link.dataset.dlcId + + "/", + contentScriptQuery: "", + }, + ); + } catch (_) {} + }); + }); + } + } + + // Update Hide button to styled Close + const hideBtn = overlay.querySelector(".luatools-hide-btn"); + if (hideBtn) { + hideBtn.className = "luatools-btn primary luatools-hide-btn"; + hideBtn.style.cssText = + "min-width:140px;display:flex;align-items:center;justify-content:center;text-align:center;"; + hideBtn.innerHTML = + '' + + lt("Close") + + ""; + } + } + done = true; + clearInterval(timer); + runState.inProgress = false; + runState.appid = null; + // Remove button since game is added (works even if popup is hidden) + const btnEl = document.querySelector(".luatools-button"); + if (btnEl && btnEl.parentElement) { + btnEl.parentElement.removeChild(btnEl); + } + } + if (st.status === "failed") { + // Mark all APIs as not found when failed (unless they have error status) + if (overlay && !successfulApi) { + const colors = getThemeColors(); + const apiItems = overlay.querySelectorAll(".luatools-api-item"); + apiItems.forEach((item) => { + const apiName = item.getAttribute("data-api-name"); + const apiStatus = item.querySelector(".luatools-api-status"); + if (!apiStatus) return; + + // Skip if this API already has an error status + if (st.apiErrors && st.apiErrors[apiName]) { + const apiError = st.apiErrors[apiName]; + item.style.background = `rgba(255, 0, 0, 0.15)`; + item.style.borderColor = "#ff5c5c"; + if (apiError.type === "timeout") { + apiStatus.innerHTML = `${lt("Error, Timed Out")}`; + } else if (apiError.type === "error") { + const code = apiError.code ? String(apiError.code) : ""; + apiStatus.innerHTML = `${lt("Error, Code: {code}").replace("{code}", code)}`; + } + return; + } + + // Check if this API is still in "Waiting..." or "Checking..." state + const statusText = apiStatus.textContent || ""; + if ( + statusText.includes("Waiting") || + statusText.includes("Esperando") || + statusText.includes("Checking") || + statusText.includes("Verificando") + ) { + item.style.background = `rgba(0,0,0,0.2)`; + item.style.borderColor = colors.borderRgba; + apiStatus.innerHTML = `${lt("Not found")}`; + } + }); + } + + // show error in the popup if visible + if (status) + status.textContent = lt("Failed: {error}").replace( + "{error}", + st.error || lt("Unknown error"), + ); + // Hide Cancel button and update Hide to Close + const cancelBtn = overlay + ? overlay.querySelector(".luatools-cancel-btn") + : null; + if (cancelBtn) cancelBtn.style.display = "none"; + const hideBtn = overlay + ? overlay.querySelector(".luatools-hide-btn") + : null; + if (hideBtn) { + hideBtn.style.display = "flex"; + hideBtn.className = "luatools-btn primary luatools-hide-btn"; + hideBtn.innerHTML = + '' + + lt("Close") + + ""; + } + if (wrap) wrap.style.display = "none"; + if (progressInfo) progressInfo.style.display = "none"; + done = true; + clearInterval(timer); + runState.inProgress = false; + runState.appid = null; + + if (onFailedCallback) { + onFailedCallback(st.error || "Unknown error"); + } + } + } catch (_) {} + }); + } catch (_) { + clearInterval(timer); + } + }, 300); + } + + // Also try after a delay to catch dynamically loaded content + setTimeout(addLuaToolsButton, 1000); + setTimeout(addLuaToolsButton, 3000); + + // Listen for URL changes (Steam uses pushState for navigation) + let lastUrl = window.location.href; + + function checkUrlChange() { + const currentUrl = window.location.href; + if (currentUrl !== lastUrl) { + lastUrl = currentUrl; + // URL changed - reset flags and update buttons + window.__LuaToolsButtonInserted = false; + window.__LuaToolsRestartInserted = false; + window.__LuaToolsIconInserted = false; + window.__LuaToolsHeaderInserted = false; + + window.__LuaToolsPresenceCheckInFlight = false; + window.__LuaToolsPresenceCheckAppId = undefined; + // Update translations and re-add buttons + ensureTranslationsLoaded(false).then(function () { + updateButtonTranslations(); + addLuaToolsButton(); + }); + } + } + // Check URL changes periodically and on popstate + // Reduced frequency to avoid blocking gamepad input + setInterval(checkUrlChange, 2000); // Changed from 500ms to 2000ms (2 seconds) + window.addEventListener("popstate", checkUrlChange); + // Override pushState/replaceState to detect navigation + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + history.pushState = function () { + originalPushState.apply(history, arguments); + setTimeout(checkUrlChange, 100); + }; + history.replaceState = function () { + originalReplaceState.apply(history, arguments); + setTimeout(checkUrlChange, 100); + }; + + // Pre-fetch settings quietly to ensure background values (like fastDownload) are populated immediately, + // and apply themes immediately once settings load. + function bootSettings() { + if ( + typeof Millennium === "undefined" || + typeof Millennium.callServerMethod !== "function" + ) { + setTimeout(bootSettings, 200); + return; + } + Promise.all([loadThemes(), fetchSettingsConfig()]) + .then(function () { + if (typeof ensureLuaToolsStyles === "function") ensureLuaToolsStyles(); + }) + .catch(function (e) { + try { + backendLog("LuaTools: Boot fetchSettingsConfig failed: " + String(e)); + } catch (_) {} + }); + } + bootSettings(); + + // Use MutationObserver to catch dynamically added content + // Heavily optimized and throttled version to avoid blocking gamepad + if (typeof MutationObserver !== "undefined") { + let mutationTimeout; + let lastMutationProcessTime = 0; + const MUTATION_THROTTLE = 1000; // Only process once per second + + const observer = new MutationObserver(function (mutations) { + // Additional throttle on top of debounce + const now = Date.now(); + if (now - lastMutationProcessTime < MUTATION_THROTTLE) { + return; // Skip if processed recently + } + + // Debounce mutations to avoid blocking the UI + clearTimeout(mutationTimeout); + mutationTimeout = setTimeout(function () { + lastMutationProcessTime = Date.now(); + + let shouldUpdate = false; + // Quick check: only process first 10 mutations to avoid long loops + const mutationsToCheck = Math.min(mutations.length, 10); + + for (let i = 0; i < mutationsToCheck; i++) { + const mutation = mutations[i]; + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { + // Only check first 3 added nodes to avoid blocking + const nodesToCheck = Math.min(mutation.addedNodes.length, 3); + + for (let j = 0; j < nodesToCheck; j++) { + const node = mutation.addedNodes[j]; + if (node.nodeType === 1) { + // Element node + // Quick class check without querySelector (faster) + if ( + node.classList && + (node.classList.contains("steamdb-buttons") || + node.classList.contains("apphub_OtherSiteInfo") || + node.id === "queueBtnFollow") + ) { + shouldUpdate = true; + break; + } + } + } + } + if (shouldUpdate) break; + } + + if (shouldUpdate) { + updateButtonTranslations(); + addLuaToolsButton(); + } + }, 300); // Increased debounce to 300ms + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } + + function showLoadedAppsPopup(apps) { + // Avoid duplicates + if (document.querySelector(".luatools-loadedapps-overlay")) return; + ensureFontAwesome(); + ensureLuaToolsStyles(); + const overlay = document.createElement("div"); + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease-out;"; + overlay.className = "luatools-loadedapps-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease-out;"; + overlay.className = "luatools-loadedapps-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;"; + const modal = document.createElement("div"); + const loadedAppsModalColors = getThemeColors(); + modal.style.cssText = `background:${loadedAppsModalColors.modalBg};color:${loadedAppsModalColors.text};border:2px solid ${loadedAppsModalColors.border};border-radius:8px;width:560px;padding:28px 32px;box-shadow:0 20px 60px rgba(0,0,0,.8), 0 0 0 1px ${loadedAppsModalColors.shadowRgba};animation:slideUp 0.1s ease-out;`; + const title = document.createElement("div"); + const loadedAppsTitleColors = getThemeColors(); + title.style.cssText = `font-size:24px;color:${loadedAppsTitleColors.text};margin-bottom:20px;font-weight:700;text-shadow:0 2px 8px ${loadedAppsTitleColors.shadow};background:${loadedAppsTitleColors.gradientLight};-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-align:center;`; + title.textContent = lt("LuaTools · Added Games"); + const body = document.createElement("div"); + const loadedAppsBodyColors = getThemeColors(); + body.style.cssText = `font-size:14px;line-height:1.8;margin-bottom:16px;max-height:320px;overflow:auto;padding:16px;border:1px solid ${loadedAppsBodyColors.border};border-radius:12px;background:${loadedAppsBodyColors.bgContainer};`; + if (apps && apps.length) { + const list = document.createElement("div"); + apps.forEach(function (item) { + const a = document.createElement("a"); + a.href = "steam://install/" + String(item.appid); + a.textContent = String(item.name || item.appid); + const linkColors = getThemeColors(); + a.style.cssText = `display:block;color:${linkColors.textSecondary};text-decoration:none;padding:10px 16px;margin-bottom:8px;background:rgba(${linkColors.rgbString},0.08);border:1px solid rgba(${linkColors.rgbString},0.2);border-radius:4px;transition:all 0.3s ease;`; + a.onmouseover = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.2)`; + this.style.borderColor = c.accent; + this.style.transform = "translateX(4px)"; + this.style.color = c.text; + }; + a.onmouseout = function () { + const c = getThemeColors(); + this.style.background = `rgba(${c.rgbString},0.08)`; + this.style.borderColor = `rgba(${c.rgbString},0.2)`; + this.style.transform = "translateX(0)"; + this.style.color = c.textSecondary; + }; + a.onclick = function (e) { + e.preventDefault(); + try { + window.location.href = a.href; + } catch (_) {} + }; + a.oncontextmenu = function (e) { + e.preventDefault(); + const url = "https://steamdb.info/app/" + String(item.appid) + "/"; + try { + Millennium.callServerMethod("luatools", "OpenExternalUrl", { + url, + contentScriptQuery: "", + }); + } catch (_) {} + }; + list.appendChild(a); + }); + body.appendChild(list); + } else { + body.style.textAlign = "center"; + body.textContent = lt("No games found."); + } + const btnRow = document.createElement("div"); + btnRow.style.cssText = + "margin-top:16px;display:flex;gap:8px;justify-content:space-between;align-items:center;"; + const instructionText = document.createElement("div"); + instructionText.style.cssText = "font-size:12px;color:#8f98a0;"; + instructionText.textContent = lt( + "Left click to install, Right click for SteamDB", + ); + const dismissBtn = document.createElement("a"); + dismissBtn.className = "luatools-btn"; + dismissBtn.innerHTML = "" + lt("Dismiss") + ""; + dismissBtn.href = "#"; + dismissBtn.onclick = function (e) { + e.preventDefault(); + try { + Millennium.callServerMethod("luatools", "DismissLoadedApps", { + contentScriptQuery: "", + }); + } catch (_) {} + try { + sessionStorage.setItem("LuaToolsLoadedAppsShown", "1"); + } catch (_) {} + overlay.remove(); + }; + btnRow.appendChild(instructionText); + btnRow.appendChild(dismissBtn); + modal.appendChild(title); + modal.appendChild(body); + modal.appendChild(btnRow); + overlay.appendChild(modal); + overlay.addEventListener("click", function (e) { + if (e.target === overlay) overlay.remove(); + }); + document.body.appendChild(overlay); + + // Re-scan elements for gamepad navigation + setTimeout(function () { + if (window.GamepadNav) { + window.GamepadNav.scanElements(); + } + }, 150); + } + + // ============================================ + // GAMEPAD NAVIGATION INTEGRATION + // ============================================ + // Note: The gamepad back handler is configured in the gamepad system at the top of this file + // It already handles all overlay types automatically using OVERLAY_SELECTOR_STRING +})(); From 7a605074e677822dbc7cdc3c98af3049963b9f52 Mon Sep 17 00:00:00 2001 From: piq Date: Mon, 1 Jun 2026 19:40:48 -0300 Subject: [PATCH 04/10] clean gitignore, reduce dependence on melly's proxy and other general fixes --- .gitignore | 44 +- backend/api.json | 13 +- backend/api_manifest.lua | 159 ++++++- backend/downloads.lua | 52 +-- backend/main.lua | 64 ++- backend/plugin_utils.lua | 32 +- backend/scripts/downloader.ps1 | 35 ++ backend/scripts/downloader.sh | 35 ++ plugin.json | 2 +- public/luatools.js | 731 ++++++++++++++++++++++++--------- 10 files changed, 890 insertions(+), 277 deletions(-) create mode 100644 backend/scripts/downloader.ps1 create mode 100644 backend/scripts/downloader.sh diff --git a/.gitignore b/.gitignore index b2f4599..4605014 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,8 @@ venv.bak/ .settings/ *.sublime-project *.sublime-workspace -.claude +.claude/ +.gemini/ # OS Files .DS_Store @@ -56,29 +57,16 @@ Thumbs.db Desktop.ini # Runtime / Generated Files (Backend) -backend/data/ backend/temp_dl/ -backend/__pycache__/ -backend/*.pyc -backend/*.pyo backend/appidlogs.txt backend/loadedappids.txt -# Millennium Framework Generated Files -.millennium/ # Build Artifacts *.zip -ltsteamplugin.zip -update_pending.zip - -# Locale Scripts / Test Files -backend/locales/check_indent.py -backend/locales/check_missing.py -backend/locales/test.json - -# Package Scripts -scripts/package.py +build.sh +build.ps1 +BUILD.md # Logs *.log @@ -89,24 +77,6 @@ logs/ *.temp *.bak *.cache -GAMEPAD-CHANGES.md -GAMEPAD-SYSTEM.md -.millennium/Dist/index.js -.millennium/Dist/webkit.js -/.millennium -build.sh -build.ps1 -BUILD.md -readme -.claude/settings.local.json -.claude/settings.local.json -/tools -.claude/settings.local.json -/.claude -.claude/settings.local.json -.claude/settings.local.json -.claude/settings.local.json -CLAUDE.md -scripts/fill_translations.py -scripts/ +# Tools / Scripts +/tools/ diff --git a/backend/api.json b/backend/api.json index 3c18f15..1bbbb46 100644 --- a/backend/api.json +++ b/backend/api.json @@ -1,4 +1,5 @@ -{"api_list": [ +{ + "api_list": [ { "name": "Morrenus", "url": "https://hubcapmanifest.com/api/v1/manifest/?api_key=", @@ -26,5 +27,13 @@ "success_code": 200, "unavailable_code": 404, "enabled": true + }, + { + "name": "SkyAPI", + "url": "https://raw.githubusercontent.com/skyflarefox/Skyapi/refs/heads/main/.zip", + "success_code": 200, + "unavailable_code": 404, + "enabled": true } - ]} \ No newline at end of file + ] +} \ No newline at end of file diff --git a/backend/api_manifest.lua b/backend/api_manifest.lua index e3b908d..8372535 100644 --- a/backend/api_manifest.lua +++ b/backend/api_manifest.lua @@ -200,4 +200,161 @@ function api_manifest.get_api_list() return { success = true, apis = api_names } end -return api_manifest +function api_manifest.get_all_apis() + local path = paths.backend_path(config.API_JSON_FILE) + local data = utils.read_json(path) + local apis = {} + if data and type(data.api_list) == "table" then + for _, api in ipairs(data.api_list) do + table.insert(apis, { + name = api.name or "Unknown", + url = api.url or "", + enabled = api.enabled ~= false -- default true + }) + end + end + return { success = true, apis = apis } +end + +function api_manifest.toggle_api(name) + if not name or type(name) ~= "string" or name == "" then + return { success = false, error = "name is required" } + end + + local path = paths.backend_path(config.API_JSON_FILE) + local data = utils.read_json(path) + if not data or type(data.api_list) ~= "table" then + return { success = false, error = "Failed to load api.json" } + end + + local found = false + local new_state = false + for _, api in ipairs(data.api_list) do + if api.name == name then + api.enabled = not (api.enabled ~= false) + new_state = api.enabled + found = true + break + end + end + + if not found then + return { success = false, error = "API not found: " .. name } + end + + local new_text = utils.encode_json(data) + local formatted = utils.normalize_manifest_text(new_text) + utils.write_text(path, formatted) + + logger.log("LuaTools: Toggled API '" .. name .. "' -> " .. tostring(new_state)) + return { success = true, enabled = new_state } +end + +function api_manifest.remove_api(name) + if not name or type(name) ~= "string" or name == "" then + return { success = false, error = "name is required" } + end + + local path = paths.backend_path(config.API_JSON_FILE) + local data = utils.read_json(path) + if not data or type(data.api_list) ~= "table" then + return { success = false, error = "Failed to load api.json" } + end + + local new_list = {} + local found = false + for _, api in ipairs(data.api_list) do + if api.name == name then + found = true + else + table.insert(new_list, api) + end + end + + if not found then + return { success = false, error = "API not found: " .. name } + end + + data.api_list = new_list + local new_text = utils.encode_json(data) + local formatted = utils.normalize_manifest_text(new_text) + utils.write_text(path, formatted) + + logger.log("LuaTools: Removed API '" .. name .. "'") + return { success = true } +end + +function api_manifest.rename_api(old_name, new_name) + if not old_name or old_name == "" or not new_name or new_name == "" then + return { success = false, error = "old_name and new_name are required" } + end + + local path = paths.backend_path(config.API_JSON_FILE) + local data = utils.read_json(path) + if not data or type(data.api_list) ~= "table" then + return { success = false, error = "Failed to load api.json" } + end + + local found = false + for _, api in ipairs(data.api_list) do + if api.name == old_name then + api.name = new_name + found = true + break + end + end + + if not found then + return { success = false, error = "API not found: " .. old_name } + end + + local new_text = utils.encode_json(data) + local formatted = utils.normalize_manifest_text(new_text) + utils.write_text(path, formatted) + + logger.log("LuaTools: Renamed API '" .. old_name .. "' -> '" .. new_name .. "'") + return { success = true } +end + +function api_manifest.set_api_order(ordered_names) + if type(ordered_names) ~= "table" then + return { success = false, error = "ordered_names must be a table" } + end + + local path = paths.backend_path(config.API_JSON_FILE) + local data = utils.read_json(path) + if not data or type(data.api_list) ~= "table" then + return { success = false, error = "Failed to load api.json" } + end + + local new_list = {} + local added = {} + + -- Add items in the requested order + for _, name in ipairs(ordered_names) do + for _, api in ipairs(data.api_list) do + if api.name == name and not added[name] then + table.insert(new_list, api) + added[name] = true + break + end + end + end + + -- Add any items that were left out of the ordered list (safeguard) + for _, api in ipairs(data.api_list) do + if not added[api.name] then + table.insert(new_list, api) + end + end + + data.api_list = new_list + local new_text = utils.encode_json(data) + local formatted = utils.normalize_manifest_text(new_text) + utils.write_text(path, formatted) + + logger.log("LuaTools: Reordered APIs") + return { success = true } +end + +return api_manifest diff --git a/backend/downloads.lua b/backend/downloads.lua index 1950aa8..31c67bb 100644 --- a/backend/downloads.lua +++ b/backend/downloads.lua @@ -262,21 +262,6 @@ function downloads.check_apis_for_app(appid) local results = {} local morrenus_api_key = settings_manager.get_morrenus_api_key() - local fast_check_succeeded = false - local fast_check_data = {} - local fast_resp = http_client.get("http://167.235.229.108/check_apis?appid=" .. tostring(appid), { - headers = { ["User-Agent"] = "secretgoonpoon" }, - timeout = 5 - }) - - if fast_resp and fast_resp.status == 200 and fast_resp.body then - local ok, data = pcall(utils.decode_json, fast_resp.body) - if ok and type(data) == "table" then - fast_check_data = data - fast_check_succeeded = true - end - end - for _, api in ipairs(apis) do local name = api.name or "Unknown" local template = api.url or "" @@ -298,35 +283,28 @@ function downloads.check_apis_for_app(appid) local url = template:gsub("", tostring(appid)) local available = false - if fast_check_succeeded then - local check_key = (string.lower(name) == "morrenus") and "Sadie (Morrenus)" or name - if fast_check_data[check_key] == "available" then + if string.lower(name) == "morrenus" then + local status_url = "https://hubcapmanifest.com/api/v1/status/" .. tostring(appid) .. "?api_key=" .. tostring(morrenus_api_key) + local resp = http_client.get(status_url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if resp and resp.status == success_code then available = true end else - if string.lower(name) == "morrenus" then - local status_url = "https://hubcapmanifest.com/api/v1/status/" .. tostring(appid) .. "?api_key=" .. tostring(morrenus_api_key) - local resp = http_client.get(status_url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) - if resp and resp.status == success_code then - available = true - end + local success = false + local resp = http_client.head(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if resp and resp.status == success_code then + success = true else - local success = false - local resp = http_client.head(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) - if resp and resp.status == success_code then + -- Fallback to GET if HEAD fails + local get_resp = http_client.get(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) + if get_resp and get_resp.status == success_code then success = true - else - -- Fallback to GET if HEAD fails - local get_resp = http_client.get(url, { headers = { ["User-Agent"] = config.USER_AGENT }, timeout = 5 }) - if get_resp and get_resp.status == success_code then - success = true - end - end - - if success then - available = true end end + + if success then + available = true + end end table.insert(results, { diff --git a/backend/main.lua b/backend/main.lua index dabb001..a02029b 100644 --- a/backend/main.lua +++ b/backend/main.lua @@ -91,7 +91,7 @@ local function on_load() local keys = {} for k, v in pairs(millennium) do table.insert(keys, k .. ":" .. type(v)) end - logger.warn("MILLENNIUM KEYS: " .. table.concat(keys, ", ")) + logger.log("MILLENNIUM KEYS: " .. table.concat(keys, ", ")) end local function on_unload() @@ -212,6 +212,62 @@ function AddCustomApi(api_key, contentScriptQuery, name, url) return json_ok(res) end +function GetAllApis() + local ok, res = pcall(api_manifest.get_all_apis) + if not ok then return json_err(res) end + return json_ok(res) +end + +function ToggleApi(params, contentScriptQuery) + local apiName = params + if type(params) == "table" then apiName = params.apiName or params.name end + local ok, res = pcall(api_manifest.toggle_api, tostring(apiName or "")) + if not ok then return json_err(res) end + return json_ok(res) +end + +function RemoveApi(params, contentScriptQuery) + local apiName = params + if type(params) == "table" then apiName = params.apiName or params.name end + local ok, res = pcall(api_manifest.remove_api, tostring(apiName or "")) + if not ok then return json_err(res) end + return json_ok(res) +end + +function RenameApi(params, contentScriptQuery) + local old_name, new_name + if type(params) == "table" then + new_name = params.new_name + old_name = params.old_name or params.apiName or params.name + else + -- If somehow positional + old_name = params + end + local ok, res = pcall(api_manifest.rename_api, tostring(old_name or ""), tostring(new_name or "")) + if not ok then return json_err(res) end + return json_ok(res) +end + +function ReorderApis(params, contentScriptQuery) + local names = params + if type(params) == "table" and params.apiNames then + names = params.apiNames + end + -- Millennium's Lua bridge doesn't deep-deserialize nested JSON arrays/objects + if type(names) == "string" then + local ok, parsed = pcall(cjson.decode, names) + if ok and type(parsed) == "table" then + names = parsed + end + end + if type(names) ~= "table" then + return json_ok({ success = false, error = "Invalid argument, got type: " .. type(names) }) + end + local ok, res = pcall(api_manifest.set_api_order, names) + if not ok then return json_err(res) end + return json_ok(res) +end + function CancelAddViaLuaTools(appid) -- No-op cancel stub; download is synchronous in Lua return json_ok({ success = true }) @@ -471,14 +527,14 @@ end function GetThemes() local themes_json_path = fs.join(paths.get_plugin_dir(), "public", "themes", "themes.json") - local themes_list = {} + local themes_dict = {} if fs.exists(themes_json_path) then local success, data = pcall(cjson.decode, utils.read_text(themes_json_path)) if success and type(data) == "table" then for _, item in ipairs(data) do if type(item) == "table" and item.value then - table.insert(themes_list, item) + themes_dict[item.value] = item end end else @@ -488,7 +544,7 @@ function GetThemes() logger.warn("GetThemes: themes.json not found") end - return json_ok({ success = true, themes = themes_list }) + return json_ok({ success = true, themes = themes_dict }) end function ApplySettingsChanges(changes) diff --git a/backend/plugin_utils.lua b/backend/plugin_utils.lua index d1b9cf0..c48738c 100644 --- a/backend/plugin_utils.lua +++ b/backend/plugin_utils.lua @@ -31,8 +31,38 @@ function utils.decode_json(text) end function utils.encode_json(data) + -- If it's the api_manifest table structure, use a custom strict formatter + if type(data) == "table" and data.api_list and type(data.api_list) == "table" then + local lines = {} + table.insert(lines, '{"api_list": [') + + for i, api in ipairs(data.api_list) do + table.insert(lines, ' {') + -- Ensure "name" is always first + table.insert(lines, ' "name": ' .. cjson.encode(api.name or "") .. ',') + table.insert(lines, ' "url": ' .. cjson.encode(api.url or ""):gsub("\\/", "/") .. ',') + table.insert(lines, ' "success_code": ' .. tostring(api.success_code or 200) .. ',') + table.insert(lines, ' "unavailable_code": ' .. tostring(api.unavailable_code or 404) .. ',') + table.insert(lines, ' "enabled": ' .. tostring(api.enabled ~= false)) + + if i == #data.api_list then + table.insert(lines, ' }') + else + table.insert(lines, ' },') + end + end + + table.insert(lines, ' ]}') + return table.concat(lines, "\n") + end + + -- Fallback for all other normal JSON serializations local success, content = pcall(cjson.encode, data) - if success then return content else return "{}" end + if success then + return content + else + return "{}" + end end function utils.write_json(path, data) diff --git a/backend/scripts/downloader.ps1 b/backend/scripts/downloader.ps1 new file mode 100644 index 0000000..9a06188 --- /dev/null +++ b/backend/scripts/downloader.ps1 @@ -0,0 +1,35 @@ +param( + [Parameter(Mandatory = $true)][string]$Url, + [Parameter(Mandatory = $true)][string]$DestPath, + [Parameter(Mandatory = $true)][string]$ExtractDir, + [string]$StateFile, + [string]$UserAgent = "discord(dot)gg/luatools" +) + +$ErrorActionPreference = 'Stop' + +function Update-State($s) { + if ([string]::IsNullOrWhiteSpace($StateFile)) { return } + Set-Content -Path $StateFile -Value ("{`"status`":`"" + $s + "`"}") +} + +try { + Update-State 'downloading' + Invoke-WebRequest -Uri $Url -OutFile $DestPath -UserAgent $UserAgent -UseBasicParsing + + if (-not [string]::IsNullOrWhiteSpace($ExtractDir)) { + Update-State 'extracting' + Expand-Archive -Force -Path $DestPath -DestinationPath $ExtractDir + Update-State 'extracted' + } + else { + Update-State 'done' + } +} +catch { + if (-not [string]::IsNullOrWhiteSpace($StateFile)) { + $errMsg = $_.Exception.Message.Replace('\', '\\').Replace('"', '\"') + Set-Content -Path $StateFile -Value ("{`"status`":`"failed`",`"error`":`"" + $errMsg + "`"}") + } + exit 1 +} diff --git a/backend/scripts/downloader.sh b/backend/scripts/downloader.sh new file mode 100644 index 0000000..784371d --- /dev/null +++ b/backend/scripts/downloader.sh @@ -0,0 +1,35 @@ +#!/bin/bash +URL="$1" +DEST_PATH="$2" +EXTRACT_DIR="$3" +STATE_FILE="$4" +USER_AGENT="${5:-discord(dot)gg/luatools}" + +update_state() { + if [ -n "$STATE_FILE" ]; then + echo "{\"status\": \"$1\"}" > "$STATE_FILE" + fi +} + +update_state "downloading" +curl -L -A "$USER_AGENT" -o "$DEST_PATH" "$URL" +if [ $? -ne 0 ]; then + if [ -n "$STATE_FILE" ]; then + echo '{"status": "failed", "error": "curl failed"}' > "$STATE_FILE" + fi + exit 1 +fi + +if [ -n "$EXTRACT_DIR" ]; then + update_state "extracting" + unzip -o -q "$DEST_PATH" -d "$EXTRACT_DIR" + if [ $? -ne 0 ]; then + if [ -n "$STATE_FILE" ]; then + echo '{"status": "failed", "error": "unzip failed"}' > "$STATE_FILE" + fi + exit 1 + fi + update_state "extracted" +else + update_state "done" +fi diff --git a/plugin.json b/plugin.json index 0edac9a..de0c76f 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "luatools", "common_name": "LuaTools", "description": "LuaTools Steam Plugin!", - "version": "8.0.0", + "version": "8.0.1", "backendType": "lua", "include": [ "public" diff --git a/public/luatools.js b/public/luatools.js index fe46232..fc3b796 100644 --- a/public/luatools.js +++ b/public/luatools.js @@ -709,6 +709,38 @@ // Cache for game names fetched from Steam API const steamGameNameCache = {}; + // Track in-flight promises so we don't fire duplicate requests for the same appid + const steamGameNameInFlight = {}; + // Throttle: max 2 concurrent fetch calls to avoid overwhelming Millennium's network interceptor + let _steamFetchActive = 0; + const _steamFetchQueue = []; + const _STEAM_FETCH_CONCURRENCY = 2; + + function _runSteamFetchQueue() { + if (_steamFetchActive >= _STEAM_FETCH_CONCURRENCY || _steamFetchQueue.length === 0) return; + const { appid, resolve, reject } = _steamFetchQueue.shift(); + _steamFetchActive++; + fetch( + "https://store.steampowered.com/api/appdetails?appids=" + appid + "&filters=basic" + ) + .then(function(res) { return res.json(); }) + .then(function(data) { + let name = null; + if (data && data[appid] && data[appid].success && data[appid].data && data[appid].data.name) { + name = data[appid].data.name; + steamGameNameCache[appid] = name; + } + resolve(name); + }) + .catch(function(err) { + resolve(null); + }) + .finally(function() { + _steamFetchActive--; + delete steamGameNameInFlight[appid]; + _runSteamFetchQueue(); + }); + } /** * get game name separately without cached full appid @@ -717,37 +749,16 @@ */ function fetchSteamGameName(appid) { if (!appid) return Promise.resolve(null); - if (steamGameNameCache[appid]) - return Promise.resolve(steamGameNameCache[appid]); + if (steamGameNameCache[appid]) return Promise.resolve(steamGameNameCache[appid]); + // Deduplicate: return the same promise if already in-flight + if (steamGameNameInFlight[appid]) return steamGameNameInFlight[appid]; - return fetch( - "https://store.steampowered.com/api/appdetails?appids=" + - appid + - "&filters=basic", - ) - .then(function (res) { - return res.json(); - }) - .then(function (data) { - if ( - data && - data[appid] && - data[appid].success && - data[appid].data && - data[appid].data.name - ) { - const name = data[appid].data.name; - steamGameNameCache[appid] = name; - return name; - } - return null; - }) - .catch(function (err) { - backendLog( - "LuaTools: fetchSteamGameName error for " + appid + ": " + err, - ); - return null; - }); + const promise = new Promise(function(resolve, reject) { + _steamFetchQueue.push({ appid: appid, resolve: resolve, reject: reject }); + _runSteamFetchQueue(); + }); + steamGameNameInFlight[appid] = promise; + return promise; } const TRANSLATION_PLACEHOLDER = "translation missing"; @@ -1246,7 +1257,7 @@ } } - function showCustomApiModal() { + function showCustomApiModal(onSuccess) { try { const old = document.querySelector(".luatools-custom-api-overlay"); if (old) old.remove(); @@ -1408,7 +1419,11 @@ const payload = typeof res === "string" ? JSON.parse(res) : res; if (payload && payload.success) { overlay.remove(); - ShowLuaToolsAlert("Success", lt("Custom API added successfully!")); + if (typeof onSuccess === "function") { + onSuccess(); + } else { + ShowLuaToolsAlert("Success", lt("Custom API added successfully!")); + } } else { saveBtn.textContent = lt("Save API"); saveBtn.disabled = false; @@ -3618,6 +3633,11 @@ config: null, draft: {}, searchQuery: "", + fixes: [], + fixesPage: 1, + luas: [], + luasPage: 1, + luasPerPage: 10 }; // Search functionality @@ -3670,80 +3690,16 @@ } }); - // Filter installed fixes - const fixItems = contentWrap.querySelectorAll("[data-fix-item]"); - let visibleFixes = 0; - fixItems.forEach(function (el) { - const searchText = (el.dataset.searchText || "").toLowerCase(); - if (!query || searchText.includes(query)) { - el.style.display = ""; - visibleFixes++; - } else { - el.style.display = "none"; - } - }); - - // Show/hide fixes empty state - const fixesSection = document.getElementById( - "luatools-installed-fixes-section", - ); - const fixesEmptySearch = fixesSection - ? fixesSection.querySelector(".search-empty-state") - : null; - if (fixesSection && query && fixItems.length > 0 && visibleFixes === 0) { - if (!fixesEmptySearch) { - const emptyEl = document.createElement("div"); - emptyEl.className = "search-empty-state"; - const emptyColors = getThemeColors(); - emptyEl.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};text-align:center;margin-top:10px;`; - emptyEl.textContent = t( - "settings.search.noResults", - "No matches found", - ); - const listContainer = fixesSection.querySelector( - "#luatools-fixes-list", - ); - if (listContainer) listContainer.appendChild(emptyEl); - } - } else if (fixesEmptySearch) { - fixesEmptySearch.remove(); + // Filter installed fixes via pagination + state.fixesPage = 1; + if (typeof renderFixesList === "function") { + renderFixesList(); } - // Filter installed lua scripts - const luaItems = contentWrap.querySelectorAll("[data-lua-item]"); - let visibleLua = 0; - luaItems.forEach(function (el) { - const searchText = (el.dataset.searchText || "").toLowerCase(); - if (!query || searchText.includes(query)) { - el.style.display = ""; - visibleLua++; - } else { - el.style.display = "none"; - } - }); - - // Show/hide lua empty state - const luaSection = document.getElementById( - "luatools-installed-lua-section", - ); - const luaEmptySearch = luaSection - ? luaSection.querySelector(".search-empty-state") - : null; - if (luaSection && query && luaItems.length > 0 && visibleLua === 0) { - if (!luaEmptySearch) { - const emptyEl = document.createElement("div"); - emptyEl.className = "search-empty-state"; - const emptyColors = getThemeColors(); - emptyEl.style.cssText = `padding:14px;background:${emptyColors.bgTertiary};border:1px solid ${emptyColors.border};border-radius:4px;color:${emptyColors.textSecondary};text-align:center;margin-top:10px;`; - emptyEl.textContent = t( - "settings.search.noResults", - "No matches found", - ); - const listContainer = luaSection.querySelector("#luatools-lua-list"); - if (listContainer) listContainer.appendChild(emptyEl); - } - } else if (luaEmptySearch) { - luaEmptySearch.remove(); + // Filter installed lua scripts via pagination + state.luasPage = 1; + if (typeof renderLuaList === "function") { + renderLuaList(); } } @@ -4464,6 +4420,9 @@ contentWrap.appendChild(groupEl); } + // Render API Toggles section + renderApiTogglesSection(); + // Render Installed Fixes section renderInstalledFixesSection(); @@ -4517,6 +4476,78 @@ loadInstalledFixes(listContainer); } + function renderFixesList() { + const container = document.getElementById("luatools-fixes-list"); + if (!container) return; + + const query = state.searchQuery || ""; + const filteredFixes = state.fixes.filter(function(fix) { + if (!query) return true; + const gameNameText = fix.gameName || "Unknown Game"; + const searchText = (gameNameText + " " + fix.appid + " " + (fix.fixType || "") + " fix").toLowerCase(); + return searchText.includes(query); + }); + + const itemsPerPage = 10; + const totalPages = Math.max(1, Math.ceil(filteredFixes.length / itemsPerPage)); + if (state.fixesPage < 1) state.fixesPage = 1; + if (state.fixesPage > totalPages) state.fixesPage = totalPages; + + container.innerHTML = ""; + + if (filteredFixes.length === 0) { + const emptyColors = getThemeColors(); + const msg = query ? t("settings.search.noResults", "No matches found") : t("settings.installedFixes.empty", "No fixes installed yet."); + container.innerHTML = `
${msg}
`; + return; + } + + const startIndex = (state.fixesPage - 1) * itemsPerPage; + const pageItems = filteredFixes.slice(startIndex, startIndex + itemsPerPage); + + for (let i = 0; i < pageItems.length; i++) { + const fix = pageItems[i]; + const fixEl = createFixListItem(fix, container); + container.appendChild(fixEl); + } + + if (totalPages > 1) { + const paginationDiv = document.createElement("div"); + paginationDiv.style.cssText = "display:flex;justify-content:center;align-items:center;margin-top:14px;gap:15px;margin-bottom:10px;"; + + const btnColors = getThemeColors(); + + const prevBtn = document.createElement("a"); + prevBtn.href = "#"; + prevBtn.innerHTML = ''; + prevBtn.style.cssText = `padding:5px 12px;color:${btnColors.accent};text-decoration:none;border-radius:4px;background:rgba(${btnColors.rgbString},0.1);transition:all 0.15s ease;`; + if (state.fixesPage <= 1) { + prevBtn.style.opacity = "0.5"; + prevBtn.style.pointerEvents = "none"; + } + prevBtn.onclick = function(e) { e.preventDefault(); state.fixesPage--; renderFixesList(); }; + + const pageInfo = document.createElement("span"); + pageInfo.style.cssText = `color:${btnColors.textSecondary};font-size:13px;`; + pageInfo.textContent = t("settings.pagination", "Page {page} of {total}").replace("{page}", state.fixesPage).replace("{total}", totalPages); + + const nextBtn = document.createElement("a"); + nextBtn.href = "#"; + nextBtn.innerHTML = ''; + nextBtn.style.cssText = `padding:5px 12px;color:${btnColors.accent};text-decoration:none;border-radius:4px;background:rgba(${btnColors.rgbString},0.1);transition:all 0.15s ease;`; + if (state.fixesPage >= totalPages) { + nextBtn.style.opacity = "0.5"; + nextBtn.style.pointerEvents = "none"; + } + nextBtn.onclick = function(e) { e.preventDefault(); state.fixesPage++; renderFixesList(); }; + + paginationDiv.appendChild(prevBtn); + paginationDiv.appendChild(pageInfo); + paginationDiv.appendChild(nextBtn); + container.appendChild(paginationDiv); + } + } + function loadInstalledFixes(container) { const loadingColors = getThemeColors(); container.innerHTML = `
${t("settings.installedFixes.loading", "Scanning for installed fixes...")}
`; @@ -4540,24 +4571,9 @@ return; } - const fixes = Array.isArray(response.fixes) ? response.fixes : []; - if (fixes.length === 0) { - const emptyColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedFixes.empty", "No fixes installed yet.")}
`; - return; - } - - container.innerHTML = ""; - for (let i = 0; i < fixes.length; i++) { - const fix = fixes[i]; - const fixEl = createFixListItem(fix, container); - container.appendChild(fixEl); - } - - // Re-apply search filter after loading - if (state.searchQuery) { - setTimeout(applySearchFilter, 50); - } + state.fixes = Array.isArray(response.fixes) ? response.fixes : []; + state.fixesPage = 1; + renderFixesList(); }) .catch(function (err) { backendLog("LuaTools: GetInstalledFixes catch error: " + err); @@ -4826,19 +4842,399 @@ checkStatus(); } + function renderApiTogglesSection() { + const c = getThemeColors(); + const sectionEl = document.createElement("div"); + sectionEl.id = "luatools-api-toggles-section"; + sectionEl.style.cssText = `margin-top:28px;padding:20px;background:rgba(${c.rgbString},0.04);border:1px solid ${c.border};border-radius:10px;`; + + const sectionTitle = document.createElement("div"); + sectionTitle.style.cssText = `font-size:16px;color:${c.text};margin-bottom:6px;font-weight:600;`; + sectionTitle.innerHTML = '' + t("settings.apiToggles.title", "Download Sources"); + sectionEl.appendChild(sectionTitle); + + const sectionDesc = document.createElement("div"); + sectionDesc.style.cssText = `font-size:12px;color:${c.textSecondary};margin-bottom:14px;`; + sectionDesc.textContent = t("settings.apiToggles.desc", "Toggle which download sources are active. Click a name to rename. Disabled sources will be skipped."); + sectionEl.appendChild(sectionDesc); + + const listEl = document.createElement("div"); + listEl.id = "luatools-api-list"; + listEl.innerHTML = `
Loading...
`; + sectionEl.appendChild(listEl); + + contentWrap.appendChild(sectionEl); + + Millennium.callServerMethod("luatools", "GetAllApis", { contentScriptQuery: "" }) + .then(function(res) { + const payload = typeof res === "string" ? JSON.parse(res) : res; + if (!payload || !payload.success || !Array.isArray(payload.apis)) { + alert("Payload error in GetAllApis: " + JSON.stringify(payload)); + listEl.innerHTML = `
${t("settings.apiToggles.error", "Failed to load APIs.")}
`; + return; + } + listEl.innerHTML = ""; + if (payload.apis.length === 0) { + listEl.innerHTML = `
${t("settings.apiToggles.empty", "No APIs configured.")}
`; + } else { + payload.apis.forEach(function(api) { + // currentName tracks renames so other ops reference the right key + let currentName = api.name; + + const row = document.createElement("div"); + const rc = getThemeColors(); + row.style.cssText = `display:flex;align-items:center;gap:10px;padding:10px 12px;background:rgba(${rc.rgbString},0.04);border:1px solid ${rc.border};border-radius:8px;margin-bottom:8px;transition:all 0.2s ease;`; + row.draggable = false; + + // Reorder tracking + // Since currentName can change if the user renames the API, we update dataset.apiName on rename + row.dataset.apiName = currentName; + + // Drag Events + row.addEventListener('dragstart', function(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', currentName); + row.classList.add('luatools-dragging'); + row.style.opacity = '0.5'; + }); + row.addEventListener('dragend', function() { + row.classList.remove('luatools-dragging'); + row.style.opacity = '1'; + row.draggable = false; + Array.from(listEl.children).forEach(function(c) { + if(c.dataset.apiName) { + c.style.borderTopColor = rc.border; + c.style.borderBottomColor = rc.border; + } + }); + + // Collect new order and save ONLY once drag is completed + const newOrder = Array.from(listEl.children) + .filter(function(c) { return c.dataset.apiName; }) + .map(function(c) { return c.dataset.apiName; }); + + Millennium.callServerMethod("luatools", "ReorderApis", { apiNames: JSON.stringify(newOrder), contentScriptQuery: "" }) + .then(function(r) { + const rp = typeof r === "string" ? JSON.parse(r) : r; + if (!rp || !rp.success) { + alert("ReorderApis Failed: " + JSON.stringify(rp)); + } + }) + .catch(function(err){ alert("ReorderApis Error: " + err); }); + }); + row.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const dragging = listEl.querySelector('.luatools-dragging'); + if (dragging && dragging !== row) { + const bounding = row.getBoundingClientRect(); + const offset = bounding.y + (bounding.height / 2); + if (e.clientY - offset > 0) { + row.style.borderBottomColor = rc.accent; + row.style.borderTopColor = rc.border; + } else { + row.style.borderTopColor = rc.accent; + row.style.borderBottomColor = rc.border; + } + } + return false; + }); + row.addEventListener('dragleave', function(e) { + row.style.borderTopColor = rc.border; + row.style.borderBottomColor = rc.border; + }); + row.addEventListener('drop', function(e) { + e.stopPropagation(); + row.style.borderTopColor = rc.border; + row.style.borderBottomColor = rc.border; + + const dragging = listEl.querySelector('.luatools-dragging'); + if (dragging && dragging !== row) { + const bounding = row.getBoundingClientRect(); + const offset = bounding.y + (bounding.height / 2); + if (e.clientY - offset > 0) { + row.after(dragging); + } else { + row.before(dragging); + } + } + return false; + }); + + // ── Drag handle ──────────────────────────────────────────── + const handle = document.createElement("div"); + handle.innerHTML = ''; + handle.style.cssText = `color:${rc.textSecondary};cursor:grab;padding:0 5px;font-size:14px;opacity:0.5;transition:opacity 0.2s;`; + handle.onmouseover = function() { this.style.opacity = "1"; }; + handle.onmouseout = function() { this.style.opacity = "0.5"; }; + handle.onmousedown = function() { row.draggable = true; }; + handle.onmouseup = function() { row.draggable = false; }; + handle.onmouseleave = function() { row.draggable = false; }; + row.appendChild(handle); + + // ── Editable name ────────────────────────────────────────── + const nameWrap = document.createElement("div"); + nameWrap.style.cssText = "flex:1;min-width:0;"; + + const nameDisplay = document.createElement("span"); + nameDisplay.style.cssText = `font-size:14px;color:${rc.text};font-weight:500;cursor:pointer;border-bottom:1px dashed transparent;transition:border-color 0.15s;display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;`; + nameDisplay.title = t("settings.apiToggles.clickToRename", "Click to rename"); + nameDisplay.textContent = currentName; + nameDisplay.onmouseover = function() { this.style.borderBottomColor = getThemeColors().accent; }; + nameDisplay.onmouseout = function() { this.style.borderBottomColor = "transparent"; }; + + nameDisplay.onclick = function() { + // Switch to input + const input = document.createElement("input"); + input.type = "text"; + input.value = currentName; + const ic = getThemeColors(); + input.style.cssText = `font-size:14px;font-weight:500;color:${ic.text};background:rgba(${ic.rgbString},0.12);border:1px solid ${ic.accent};border-radius:4px;padding:2px 8px;outline:none;width:100%;box-sizing:border-box;`; + nameWrap.replaceChild(input, nameDisplay); + input.focus(); + input.select(); + + function commitRename() { + const newVal = input.value.trim(); + if (newVal && newVal !== currentName) { + Millennium.callServerMethod("luatools", "RenameApi", { old_name: currentName, new_name: newVal, contentScriptQuery: "" }) + .then(function(r) { + const rp = typeof r === "string" ? JSON.parse(r) : r; + if (rp && rp.success) { + currentName = newVal; + row.dataset.apiName = newVal; + nameDisplay.textContent = newVal; + } + }).catch(function() {}); + } + nameDisplay.textContent = currentName; + nameWrap.replaceChild(nameDisplay, input); + } + + input.onblur = commitRename; + input.onkeydown = function(e) { + if (e.key === "Enter") { e.preventDefault(); commitRename(); } + if (e.key === "Escape") { nameWrap.replaceChild(nameDisplay, input); } + }; + }; + + nameWrap.appendChild(nameDisplay); + row.appendChild(nameWrap); + + // ── Toggle pill ──────────────────────────────────────────── + const pill = document.createElement("div"); + const isEnabled = api.enabled !== false; + pill.style.cssText = `width:42px;height:22px;border-radius:11px;cursor:pointer;transition:background 0.2s ease;background:${isEnabled ? rc.accent : "rgba(255,255,255,0.15)"};position:relative;flex-shrink:0;`; + const knob = document.createElement("div"); + knob.style.cssText = `position:absolute;top:3px;left:${isEnabled ? "22px" : "3px"};width:16px;height:16px;border-radius:50%;background:#fff;transition:left 0.2s ease;box-shadow:0 1px 3px rgba(0,0,0,0.4);`; + pill.appendChild(knob); + pill.dataset.enabled = isEnabled ? "1" : "0"; + pill.title = t("settings.apiToggles.toggle", "Enable / disable"); + + pill.onclick = function() { + const nowEnabled = pill.dataset.enabled !== "1"; + pill.dataset.enabled = nowEnabled ? "1" : "0"; + const tc = getThemeColors(); + pill.style.background = nowEnabled ? tc.accent : "rgba(255,255,255,0.15)"; + knob.style.left = nowEnabled ? "22px" : "3px"; + Millennium.callServerMethod("luatools", "ToggleApi", { apiName: currentName, contentScriptQuery: "" }) + .then(function(r) { + const rp = typeof r === "string" ? JSON.parse(r) : r; + if (!rp || !rp.success) { + alert("ToggleApi Failed: " + JSON.stringify(rp)); + pill.dataset.enabled = nowEnabled ? "0" : "1"; + pill.style.background = nowEnabled ? "rgba(255,255,255,0.15)" : getThemeColors().accent; + knob.style.left = nowEnabled ? "3px" : "22px"; + } + }).catch(function(err) { + alert("ToggleApi Error: " + err); + pill.dataset.enabled = nowEnabled ? "0" : "1"; + pill.style.background = nowEnabled ? "rgba(255,255,255,0.15)" : getThemeColors().accent; + knob.style.left = nowEnabled ? "3px" : "22px"; + }); + }; + + row.appendChild(pill); + + // ── Delete button ────────────────────────────────────────── + const delBtn = document.createElement("a"); + delBtn.href = "#"; + const dc = getThemeColors(); + delBtn.style.cssText = `display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:rgba(255,92,92,0.08);border:1px solid rgba(255,92,92,0.25);color:#ff5c5c;font-size:12px;text-decoration:none;flex-shrink:0;transition:all 0.15s ease;cursor:pointer;`; + delBtn.innerHTML = ''; + delBtn.title = t("settings.apiToggles.remove", "Remove source"); + delBtn.onmouseover = function() { this.style.background = "rgba(255,92,92,0.2)"; this.style.borderColor = "rgba(255,92,92,0.6)"; }; + delBtn.onmouseout = function() { this.style.background = "rgba(255,92,92,0.08)"; this.style.borderColor = "rgba(255,92,92,0.25)"; }; + + delBtn.onclick = function(e) { + e.preventDefault(); + if (delBtn.dataset.busy === "1") return; + delBtn.dataset.busy = "1"; + delBtn.style.opacity = "0.5"; + Millennium.callServerMethod("luatools", "RemoveApi", { apiName: currentName, contentScriptQuery: "" }) + .then(function(r) { + const rp = typeof r === "string" ? JSON.parse(r) : r; + if (rp && rp.success) { + row.style.opacity = "0"; + row.style.transform = "translateX(10px)"; + setTimeout(function() { row.remove(); }, 200); + } else { + alert("RemoveApi Failed: " + JSON.stringify(rp)); + delBtn.dataset.busy = "0"; + delBtn.style.opacity = "1"; + } + }).catch(function(err) { + alert("RemoveApi Error: " + err); + delBtn.dataset.busy = "0"; + delBtn.style.opacity = "1"; + }); + }; + + row.appendChild(delBtn); + listEl.appendChild(row); + }); // end forEach + } // end else + + // ── Add Source button ────────────────────────────────────── + const addBtnRow = document.createElement("div"); + addBtnRow.style.cssText = "display:flex;justify-content:flex-end;margin-top:10px;"; + const addBtn = document.createElement("a"); + addBtn.href = "#"; + const abc = getThemeColors(); + addBtn.style.cssText = `display:inline-flex;align-items:center;gap:7px;padding:8px 16px;border-radius:8px;background:rgba(${abc.rgbString},0.12);border:1px solid ${abc.border};color:${abc.accent};font-size:13px;font-weight:500;text-decoration:none;transition:all 0.15s ease;cursor:pointer;`; + addBtn.innerHTML = '' + t("settings.apiToggles.addSource", "Add Source") + ''; + addBtn.onmouseover = function() { const c = getThemeColors(); this.style.background = `rgba(${c.rgbString},0.22)`; this.style.borderColor = c.accent; }; + addBtn.onmouseout = function() { const c = getThemeColors(); this.style.background = `rgba(${c.rgbString},0.12)`; this.style.borderColor = c.border; }; + addBtn.onclick = function(e) { + e.preventDefault(); + showCustomApiModal(function() { + // Reload the API list in-place after a successful add + listEl.innerHTML = `
Loading...
`; + Millennium.callServerMethod("luatools", "GetAllApis", { contentScriptQuery: "" }) + .then(function(r2) { + const p2 = typeof r2 === "string" ? JSON.parse(r2) : r2; + if (!p2 || !p2.success || !Array.isArray(p2.apis)) { return; } + listEl.innerHTML = ""; + p2.apis.forEach(function(a2) { + // Simple read-only rows for the reload (user can reopen settings to get full interactive rows) + const r = document.createElement("div"); + const rc = getThemeColors(); + r.style.cssText = `padding:10px 12px;background:rgba(${rc.rgbString},0.04);border:1px solid ${rc.border};border-radius:8px;margin-bottom:8px;font-size:14px;color:${rc.text};`; + r.textContent = a2.name; + listEl.insertBefore(r, addBtnRow); + }); + }).catch(function() {}); + ShowLuaToolsAlert("Success", lt("Custom API added successfully!")); + }); + }; + addBtnRow.appendChild(addBtn); + sectionEl.appendChild(addBtnRow); + }) + .catch(function(err) { + alert("GetAllApis Catch Error: " + err); + listEl.innerHTML = `
${t("settings.apiToggles.error", "Failed to load APIs.")}
`; + }); + } + + function renderLuaList() { + const container = document.getElementById("luatools-lua-list"); + if (!container) return; + + const query = state.searchQuery || ""; + const filteredLuas = state.luas.filter(function(s) { + if (!query) return true; + const gameNameText = s.gameName || "Unknown Game"; + const searchText = (gameNameText + " " + s.appid + " lua script").toLowerCase(); + return searchText.includes(query); + }); + + const itemsPerPage = state.luasPerPage || 10; + const totalPages = Math.max(1, Math.ceil(filteredLuas.length / itemsPerPage)); + if (state.luasPage < 1) state.luasPage = 1; + if (state.luasPage > totalPages) state.luasPage = totalPages; + + container.innerHTML = ""; + + if (filteredLuas.length === 0) { + const ec = getThemeColors(); + const msg = query ? t("settings.search.noResults", "No matches found") : t("settings.installedLua.empty", "No Lua scripts installed yet."); + container.innerHTML = `
${msg}
`; + return; + } + + const startIndex = (state.luasPage - 1) * itemsPerPage; + const pageItems = filteredLuas.slice(startIndex, startIndex + itemsPerPage); + + for (let i = 0; i < pageItems.length; i++) { + container.appendChild(createLuaListItem(pageItems[i], container)); + } + + if (totalPages > 1) { + const paginationDiv = document.createElement("div"); + paginationDiv.style.cssText = "display:flex;justify-content:center;align-items:center;margin-top:14px;gap:15px;margin-bottom:10px;"; + const bc = getThemeColors(); + + const prevBtn = document.createElement("a"); + prevBtn.href = "#"; + prevBtn.innerHTML = ''; + prevBtn.style.cssText = `padding:5px 12px;color:${bc.accent};text-decoration:none;border-radius:4px;background:rgba(${bc.rgbString},0.1);transition:all 0.15s ease;`; + if (state.luasPage <= 1) { prevBtn.style.opacity = "0.5"; prevBtn.style.pointerEvents = "none"; } + prevBtn.onclick = function(e) { e.preventDefault(); state.luasPage--; renderLuaList(); }; + + const pageInfo = document.createElement("span"); + pageInfo.style.cssText = `color:${bc.textSecondary};font-size:13px;`; + pageInfo.textContent = t("settings.pagination", "Page {page} of {total}").replace("{page}", state.luasPage).replace("{total}", totalPages); + + const nextBtn = document.createElement("a"); + nextBtn.href = "#"; + nextBtn.innerHTML = ''; + nextBtn.style.cssText = `padding:5px 12px;color:${bc.accent};text-decoration:none;border-radius:4px;background:rgba(${bc.rgbString},0.1);transition:all 0.15s ease;`; + if (state.luasPage >= totalPages) { nextBtn.style.opacity = "0.5"; nextBtn.style.pointerEvents = "none"; } + nextBtn.onclick = function(e) { e.preventDefault(); state.luasPage++; renderLuaList(); }; + + paginationDiv.appendChild(prevBtn); + paginationDiv.appendChild(pageInfo); + paginationDiv.appendChild(nextBtn); + container.appendChild(paginationDiv); + } + } + function renderInstalledLuaSection() { const sectionEl = document.createElement("div"); sectionEl.id = "luatools-installed-lua-section"; const sectionLuaColors = getThemeColors(); sectionEl.style.cssText = `margin-top:28px;padding:20px;background:rgba(${sectionLuaColors.rgbString},0.04);border:1px solid ${sectionLuaColors.border};border-radius:10px;`; + const sectionTitleContainer = document.createElement("div"); + sectionTitleContainer.style.cssText = "display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;"; + const sectionTitle = document.createElement("div"); const luaTitleColors = getThemeColors(); - sectionTitle.style.cssText = `font-size:16px;color:${luaTitleColors.text};margin-bottom:14px;font-weight:600;`; + sectionTitle.style.cssText = `font-size:16px;color:${luaTitleColors.text};font-weight:600;`; sectionTitle.innerHTML = '' + t("settings.installedLua.title", "Installed Lua Scripts"); - sectionEl.appendChild(sectionTitle); + sectionTitleContainer.appendChild(sectionTitle); + + const perPageSelect = document.createElement("select"); + perPageSelect.style.cssText = `background:rgba(${luaTitleColors.rgbString},0.08);color:${luaTitleColors.text};border:1px solid ${luaTitleColors.border};border-radius:6px;padding:4px 8px;font-size:12px;outline:none;cursor:pointer;width:fit-content;`; + [5, 10, 25, 50, 100].forEach(function(val) { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = val + " " + t("settings.perPage", "per page"); + if (val === state.luasPerPage) opt.selected = true; + opt.style.background = luaTitleColors.bgTertiary || "#1a1a1a"; + opt.style.color = luaTitleColors.text; + perPageSelect.appendChild(opt); + }); + perPageSelect.onchange = function(e) { + state.luasPerPage = parseInt(e.target.value, 10); + state.luasPage = 1; + renderLuaList(); + }; + sectionTitleContainer.appendChild(perPageSelect); + + sectionEl.appendChild(sectionTitleContainer); const listContainer = document.createElement("div"); listContainer.id = "luatools-lua-list"; @@ -4854,10 +5250,7 @@ const loadingLuaColors = getThemeColors(); container.innerHTML = `
` + - t( - "settings.installedLua.loading", - "Scanning for installed Lua scripts...", - ) + + t("settings.installedLua.loading", "Scanning for installed Lua scripts...") + "
"; Millennium.callServerMethod("luatools", "GetInstalledLuaScripts", { @@ -4866,55 +5259,15 @@ .then(function (res) { const response = typeof res === "string" ? JSON.parse(res) : res; if (!response || !response.success) { - const errLuaColors = getThemeColors(); container.innerHTML = `
${t("settings.installedLua.error", "Failed to load installed Lua scripts.")}
`; return; } - const scripts = Array.isArray(response.scripts) - ? response.scripts - : []; - if (scripts.length === 0) { - const emptyLuaColors = getThemeColors(); - container.innerHTML = `
${t("settings.installedLua.empty", "No Lua scripts installed yet.")}
`; - return; - } - - container.innerHTML = ""; - - // Check if there are any unknown games - const hasUnknownGames = scripts.some(function (s) { - return s.gameName && s.gameName.startsWith("Unknown Game"); - }); - - // Show info banner if there are unknown games - if (hasUnknownGames) { - const infoBanner = document.createElement("div"); - infoBanner.style.cssText = - "margin-bottom:16px;padding:12px 14px;background:rgba(255,193,7,0.1);border:1px solid rgba(255,193,7,0.3);border-radius:6px;color:#ffc107;font-size:13px;display:flex;align-items:center;gap:10px;"; - infoBanner.innerHTML = - '' + - t( - "settings.installedLua.unknownInfo", - "Games showing 'Unknown Game' were installed manually (not via LuaTools).", - ) + - ""; - container.appendChild(infoBanner); - } - - for (let i = 0; i < scripts.length; i++) { - const script = scripts[i]; - const scriptEl = createLuaListItem(script, container); - container.appendChild(scriptEl); - } - - // Re-apply search filter after loading - if (state.searchQuery) { - setTimeout(applySearchFilter, 50); - } + state.luas = Array.isArray(response.scripts) ? response.scripts : []; + state.luasPage = 1; + renderLuaList(); }) .catch(function (err) { - const catchLuaColors = getThemeColors(); container.innerHTML = `
${t("settings.installedLua.error", "Failed to load installed Lua scripts.")}
`; }); } @@ -6639,20 +6992,15 @@ } } catch (e) {} - if (available.length === 1 || isFastDownload) { - // Only one source or fast download enabled, proceed automatically with the first available + if (isFastDownload) { + // Fast download enabled, proceed automatically with the first available const source = available[0]; backendLog( - "LuaTools: Auto-selecting " + - (available.length === 1 - ? "only source" - : "source via fast download") + - ": " + - source.name, + "LuaTools: Auto-selecting source via fast download: " + source.name, ); startDirectDownload(appid, available, 0); } else { - // Multiple sources, let user select + // Fast download disabled, let user select showSourceSelectionModal(appid, available); } } catch (err) { @@ -7500,22 +7848,17 @@ // Pre-fetch settings quietly to ensure background values (like fastDownload) are populated immediately, // and apply themes immediately once settings load. function bootSettings() { - if ( - typeof Millennium === "undefined" || - typeof Millennium.callServerMethod !== "function" - ) { - setTimeout(bootSettings, 200); - return; + if (typeof Millennium === "undefined" || typeof Millennium.callServerMethod !== "function") { + setTimeout(bootSettings, 200); + return; } - Promise.all([loadThemes(), fetchSettingsConfig()]) - .then(function () { + loadThemes().then(function() { + return fetchSettingsConfig(); + }).then(function() { if (typeof ensureLuaToolsStyles === "function") ensureLuaToolsStyles(); - }) - .catch(function (e) { - try { - backendLog("LuaTools: Boot fetchSettingsConfig failed: " + String(e)); - } catch (_) {} - }); + }).catch(function(e) { + try { backendLog("LuaTools: Boot sequence failed: " + String(e)); } catch(_) {} + }); } bootSettings(); From e0a9d64281f62e84a8f626e2a2d00599c4f7a53e Mon Sep 17 00:00:00 2001 From: piq Date: Mon, 1 Jun 2026 19:41:04 -0300 Subject: [PATCH 05/10] defaults (im lazy) --- backend/data/settings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 backend/data/settings.json diff --git a/backend/data/settings.json b/backend/data/settings.json new file mode 100644 index 0000000..6081e07 --- /dev/null +++ b/backend/data/settings.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "values": { + "general": { + "donateKeys": true, + "useSteamLanguage": true, + "morrenusApiKey": "", + "language": "en", + "fastDownload": false, + "theme": "original" + } + } +} From 6a64282641ac6ef33a211ed1f71d1d6c8f2e43e2 Mon Sep 17 00:00:00 2001 From: piq Date: Mon, 1 Jun 2026 20:14:10 -0300 Subject: [PATCH 06/10] fix themes (array momento) --- backend/main.lua | 10 +++------- plugin.json | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/backend/main.lua b/backend/main.lua index a02029b..6c8add4 100644 --- a/backend/main.lua +++ b/backend/main.lua @@ -527,16 +527,12 @@ end function GetThemes() local themes_json_path = fs.join(paths.get_plugin_dir(), "public", "themes", "themes.json") - local themes_dict = {} + local themes_array = {} if fs.exists(themes_json_path) then local success, data = pcall(cjson.decode, utils.read_text(themes_json_path)) if success and type(data) == "table" then - for _, item in ipairs(data) do - if type(item) == "table" and item.value then - themes_dict[item.value] = item - end - end + themes_array = data else logger.warn("GetThemes failed to decode themes.json") end @@ -544,7 +540,7 @@ function GetThemes() logger.warn("GetThemes: themes.json not found") end - return json_ok({ success = true, themes = themes_dict }) + return json_ok({ success = true, themes = themes_array }) end function ApplySettingsChanges(changes) diff --git a/plugin.json b/plugin.json index de0c76f..5714e32 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "luatools", "common_name": "LuaTools", "description": "LuaTools Steam Plugin!", - "version": "8.0.1", + "version": "8.0.2", "backendType": "lua", "include": [ "public" From 3bf7a8d4634826b7417a036cb664e837778361e8 Mon Sep 17 00:00:00 2001 From: piq Date: Tue, 2 Jun 2026 17:37:38 -0300 Subject: [PATCH 07/10] auto-update fix, final until rewrite i guess --- backend/auto_update.lua | 36 ++++++++++++++++++---------------- backend/scripts/downloader.ps1 | 31 +++++++++++++++++++++++++---- plugin.json | 2 +- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/backend/auto_update.lua b/backend/auto_update.lua index b3d3ed7..95d05c8 100644 --- a/backend/auto_update.lua +++ b/backend/auto_update.lua @@ -83,25 +83,27 @@ function auto_update.check_for_updates_now() local pending_zip = paths.backend_path(config.UPDATE_PENDING_ZIP) - local dl_resp = http_client.get(zip_url, { timeout = 30 }) - if dl_resp and dl_resp.status == 200 and dl_resp.body then - m_utils.write_file(pending_zip, dl_resp.body) - - local is_windows = m_utils.getenv("OS") == "Windows_NT" - local cmd - if is_windows then - cmd = 'powershell -Command "Expand-Archive -Force -Path \'' .. pending_zip .. '\' -DestinationPath \'' .. paths.get_plugin_dir() .. '\'"' - else - cmd = 'unzip -o -q "' .. pending_zip .. '" -d "' .. paths.get_plugin_dir() .. '"' - end - m_utils.exec(cmd) - fs.remove(pending_zip) - - local msg = "LuaTools updated to " .. latest_version .. ". Please restart Steam." - return { success = true, message = msg } + local is_windows = m_utils.getenv("OS") == "Windows_NT" + local cmd + if is_windows then + local ps1_path = fs.join(paths.get_plugin_dir(), "backend", "scripts", "downloader.ps1") + local temp_ps1 = fs.join(paths.get_backend_dir(), "temp_updater.ps1") + m_utils.write_file(temp_ps1, m_utils.read_file(ps1_path)) + cmd = string.format('powershell -ExecutionPolicy Bypass -Command "& \'%s\' -Url \'%s\' -DestPath \'%s\' -ExtractDir \'%s\'"', temp_ps1, zip_url, pending_zip, paths.get_plugin_dir()) + else + cmd = string.format('curl -L -o "%s" "%s" && unzip -o -q "%s" -d "%s"', pending_zip, zip_url, pending_zip, paths.get_plugin_dir()) + end + + m_utils.exec(cmd) + + if fs.exists(pending_zip) then fs.remove(pending_zip) end + if is_windows then + local temp_ps1 = fs.join(paths.get_backend_dir(), "temp_updater.ps1") + if fs.exists(temp_ps1) then fs.remove(temp_ps1) end end - return { success = false, error = "Update download failed" } + local msg = "LuaTools updated to " .. latest_version .. ". Please restart Steam." + return { success = true, message = msg } end function auto_update.restart_steam() diff --git a/backend/scripts/downloader.ps1 b/backend/scripts/downloader.ps1 index 9a06188..ac9e0fb 100644 --- a/backend/scripts/downloader.ps1 +++ b/backend/scripts/downloader.ps1 @@ -1,5 +1,5 @@ param( - [Parameter(Mandatory = $true)][string]$Url, + [string]$Url, [Parameter(Mandatory = $true)][string]$DestPath, [Parameter(Mandatory = $true)][string]$ExtractDir, [string]$StateFile, @@ -14,22 +14,45 @@ function Update-State($s) { } try { - Update-State 'downloading' - Invoke-WebRequest -Uri $Url -OutFile $DestPath -UserAgent $UserAgent -UseBasicParsing + if (-not [string]::IsNullOrWhiteSpace($Url)) { + Update-State 'downloading' + Write-Host "Downloading $Url to $DestPath..." + Invoke-WebRequest -Uri $Url -OutFile $DestPath -UserAgent $UserAgent -UseBasicParsing + } if (-not [string]::IsNullOrWhiteSpace($ExtractDir)) { Update-State 'extracting' - Expand-Archive -Force -Path $DestPath -DestinationPath $ExtractDir + Write-Host "Extracting $DestPath to $ExtractDir..." + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($DestPath) + foreach ($entry in $zip.Entries) { + $target = [System.IO.Path]::Combine($ExtractDir, $entry.FullName) + $dir = [System.IO.Path]::GetDirectoryName($target) + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + if ($entry.Name -ne '') { + Write-Host "Extracting $($entry.FullName)..." + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) + } + } + $zip.Dispose() Update-State 'extracted' + Write-Host "Extraction complete!" + Start-Sleep -Seconds 2 } else { Update-State 'done' } } catch { + Write-Host "ERROR ENCOUNTERED:" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + $errLog = [System.IO.Path]::Combine($ExtractDir, "update_error.log") + Set-Content -Path $errLog -Value $_.Exception.ToString() + if (-not [string]::IsNullOrWhiteSpace($StateFile)) { $errMsg = $_.Exception.Message.Replace('\', '\\').Replace('"', '\"') Set-Content -Path $StateFile -Value ("{`"status`":`"failed`",`"error`":`"" + $errMsg + "`"}") } + try { Read-Host "Press Enter to exit" } catch {} exit 1 } diff --git a/plugin.json b/plugin.json index 5714e32..fc8aa2b 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "luatools", "common_name": "LuaTools", "description": "LuaTools Steam Plugin!", - "version": "8.0.2", + "version": "8.0.3", "backendType": "lua", "include": [ "public" From 0f39c722aebe8b2ef99c93ba484105c03ef27ac7 Mon Sep 17 00:00:00 2001 From: piq Date: Tue, 2 Jun 2026 17:55:14 -0300 Subject: [PATCH 08/10] readme update --- readme | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 readme diff --git a/readme b/readme deleted file mode 100644 index ad1c5f3..0000000 --- a/readme +++ /dev/null @@ -1,5 +0,0 @@ -erm the releases are open source ngl - -dont redistribute as somethinng else plz - -https://discord.gg/luatools From 7376f285cb47a64105cf0f1b6f6653030ae1ede0 Mon Sep 17 00:00:00 2001 From: piq Date: Tue, 2 Jun 2026 19:58:11 -0300 Subject: [PATCH 09/10] insanity --- backend/api.json | 7 ------- backend/config.lua | 2 +- readme.md | 4 ++++ 3 files changed, 5 insertions(+), 8 deletions(-) create mode 100644 readme.md diff --git a/backend/api.json b/backend/api.json index 1bbbb46..726101d 100644 --- a/backend/api.json +++ b/backend/api.json @@ -27,13 +27,6 @@ "success_code": 200, "unavailable_code": 404, "enabled": true - }, - { - "name": "SkyAPI", - "url": "https://raw.githubusercontent.com/skyflarefox/Skyapi/refs/heads/main/.zip", - "success_code": 200, - "unavailable_code": 404, - "enabled": true } ] } \ No newline at end of file diff --git a/backend/config.lua b/backend/config.lua index b743737..ed97d6c 100644 --- a/backend/config.lua +++ b/backend/config.lua @@ -26,7 +26,7 @@ local config = { UPDATE_CHECK_INTERVAL_SECONDS = 2 * 60 * 60, -- 2 hours - USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + USER_AGENT = "discord(dot)gg/luatools", LOADED_APPS_FILE = "loadedappids.txt", APPID_LOG_FILE = "appidlogs.txt", diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..3ddd483 --- /dev/null +++ b/readme.md @@ -0,0 +1,4 @@ +# before submitting PRs, keep in mind: +the plugin will go through a full rewrite in the near future, so if you want to contribute, wait until the rewrite is open sourced +issues will still be open and fixed on a case by case basis. +https://discord.gg/luatools From 058394a0b64993fa40cdf9eb14b6aafb02b3c9c8 Mon Sep 17 00:00:00 2001 From: piq Date: Tue, 2 Jun 2026 19:59:44 -0300 Subject: [PATCH 10/10] bump --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index fc8aa2b..9292e44 100644 --- a/plugin.json +++ b/plugin.json @@ -3,7 +3,7 @@ "name": "luatools", "common_name": "LuaTools", "description": "LuaTools Steam Plugin!", - "version": "8.0.3", + "version": "8.0.4", "backendType": "lua", "include": [ "public"