Compare commits

..

23 Commits

Author SHA1 Message Date
ikun0014
93096ffc08 2.1.2 2026-01-11 03:46:57 +08:00
ikun0014
2cf9af811a Refactor to use aiohttp and ujson, update dependencies
Replaced httpx and json with aiohttp and ujson throughout the codebase for improved async performance and faster JSON handling. Updated requirements.txt to reflect new dependencies and removed unused ones. Refactored manifest handling to remove steam.client.cdn dependency and implemented custom manifest serialization. Updated logger to use loguru instead of logzero. Adjusted i18n keys and tray menu logic to match new window-based UI. Updated about.html to reflect backend technology change from HTTPX to AIOHTTP.
2026-01-11 03:46:45 +08:00
ikun0014
b91bf42981 2.1.1 2025-11-10 22:33:24 +08:00
ikun0014
dacaa1c62e update web ver 2025-11-10 22:33:22 +08:00
ikun0014
22619df037 fix: GreenLuma 2025-11-10 22:32:49 +08:00
ikun0014
840a99a1e9 2.1.0 2025-10-25 13:08:49 +08:00
ikun0014
ba3c60c775 feat: custom port 2025-10-25 13:08:40 +08:00
ikun0014
80628ad17a 更新版本 2025-10-12 17:05:27 +08:00
ikun0014
9880b90d19 2.0.9 2025-10-12 17:05:04 +08:00
ikun0014
3f4d327f6e 修复Nuitka编译未包含icon.jpg的问题
在dev.yml和release.yml的编译步骤中,新增--include-data-file参数,确保icon.jpg被正确打包进可执行文件,避免因缺失图标文件导致的运行异常。
2025-10-12 17:04:49 +08:00
ikun0014
361150b3bc 新增多语言支持并重构前端资源结构
引入英文和中文多语言支持,web 目录下静态资源和模板按语言分目录存放。新增 src/utils/i18n.py 实现国际化,main.py 增加多语言错误提示。CI/CD 工作流升级 Python 版本与 Nuitka 编译方式,提升兼容性和构建效率。
2025-10-12 16:32:36 +08:00
ikun0014
4f213f9514 Update about.html 2025-10-12 00:23:38 +08:00
ikun0014
56eab758cd 2.0.8 2025-10-11 23:31:11 +08:00
ikun0014
9c4890db37 Update about.html 2025-10-11 23:31:10 +08:00
ikun0014
ddd1cb7b6c Update oobe.html 2025-10-11 23:30:44 +08:00
ikun0014
839e922d79 2.0.7 2025-09-20 18:57:20 +08:00
ikun0014
2bd2f23838 Update version to v2.0.7 and remove license section
Bumped the displayed version in about.html from v2.0.6 to v2.0.7. Removed the commented-out project license agreement section from README.md for clarity.
2025-09-20 18:57:15 +08:00
ikun0014
9e68621e76 Remove 'verify=False' from HTTP client instantiations
Eliminated the 'verify=False' parameter from all httpx.Client and httpx.AsyncClient initializations to enforce SSL certificate verification. This improves security by ensuring HTTPS requests validate server certificates.
2025-09-20 18:56:29 +08:00
ikun0014
2a6a9421ca Refactor IP location check and update HTTP client config
Moved the IP location check logic to constants.py and set IS_CN at import time, removing the async check_ip method from OnekeyApp. Updated all httpx client instantiations to use verify=False for SSL verification. Adjusted imports and usages accordingly in web/app.py and main.py.
2025-09-20 18:55:49 +08:00
ikun0014
1a6cf47882 Refactor constants and update HTTP client config
Added type annotations to constants in constants.py and set IS_CN default to True. Updated main.py to use 'flag' instead of 'ip_flag' from API response. Removed 'verify=False' and 'proxy=None' from httpx.AsyncClient initialization in client.py for improved security and clarity.
2025-09-20 18:32:28 +08:00
ikun0014
09f94c5f11 2.0.6 2025-09-19 22:48:25 +08:00
ikun0014
5307dc0766 Update about.html 2025-09-19 22:48:24 +08:00
ikun0014
15b3caddcf Add region-based CDN selection and IP check
Introduces an IS_CN flag and logic to select Steam CDN endpoints based on region in constants.py. Adds an IP check in OnekeyApp to set the region flag at runtime. Also removes a commercial use warning from about.html and applies minor formatting changes to index.html.
2025-09-19 22:46:53 +08:00
53 changed files with 6087 additions and 505 deletions

View File

@@ -19,37 +19,20 @@ jobs:
echo "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV
- name: 安装Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: 3.13.1
python-version: "3.11"
- name: 安装依赖
run: |
python -m pip install --upgrade pip
pip install imageio
pip install -r requirements.txt
pip install nuitka
- name: 编译
uses: Nuitka/Nuitka-Action@main
with:
nuitka-version: main
script-name: main.py
mode: onefile
show-memory: true
onefile-tempdir-spec: "%TEMP%\\onekey_%PID%_%TIME%"
windows-icon-from-ico: icon.jpg
company-name: "ikunshare"
product-name: "Onekey"
include-data-dir: |
./web/templates=web/templates
./web/static=web/static
file-version: ${{ env.PACKAGE_VERSION }}
product-version: ${{ env.PACKAGE_VERSION }}
file-description: "Onekey Depot Manifest Downloader."
copyright: "Copyright © 2025 ikunshare All Rights Reserved."
output-file: Onekey_v${{ env.PACKAGE_VERSION }}.exe
assume-yes-for-downloads: true
output-dir: build
run: |
python -m nuitka --standalone --onefile --assume-yes-for-downloads --show-memory --show-progress --onefile-tempdir-spec="%TEMP%\\onekey_%PID%_%TIME%" --windows-icon-from-ico="icon.jpg" --company-name="ikunshare" --product-name="Onekey" --file-version="${{ env.PACKAGE_VERSION }}" --product-version="${{ env.PACKAGE_VERSION }}" --file-description="Onekey Depot Manifest Downloader." --copyright="Copyright © 2025 ikunshare All Rights Reserved." --include-data-dir="web=web" --output-dir="build" --output-filename="Onekey_v${{ env.PACKAGE_VERSION }}.exe" --include-data-file="./icon.jpg=./icon.jpg" main.py
- name: 上传包
uses: actions/upload-artifact@v4

View File

@@ -19,37 +19,20 @@ jobs:
echo "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV
- name: 安装Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: 3.13.1
python-version: "3.11"
- name: 安装依赖
run: |
python -m pip install --upgrade pip
pip install imageio
pip install -r requirements.txt
pip install nuitka
- name: 编译
uses: Nuitka/Nuitka-Action@main
with:
nuitka-version: main
script-name: main.py
mode: onefile
show-memory: true
onefile-tempdir-spec: "%TEMP%\\onekey_%PID%_%TIME%"
windows-icon-from-ico: icon.jpg
company-name: "ikunshare"
product-name: "Onekey"
include-data-dir: |
./web/templates=web/templates
./web/static=web/static
file-version: ${{ env.PACKAGE_VERSION }}
product-version: ${{ env.PACKAGE_VERSION }}
file-description: "Onekey Depot Manifest Downloader."
copyright: "Copyright © 2025 ikunshare All Rights Reserved."
output-file: Onekey_v${{ env.PACKAGE_VERSION }}.exe
assume-yes-for-downloads: true
output-dir: build
run: |
python -m nuitka --standalone --onefile --assume-yes-for-downloads --show-memory --show-progress --onefile-tempdir-spec="%TEMP%\\onekey_%PID%_%TIME%" --windows-icon-from-ico="icon.jpg" --company-name="ikunshare" --product-name="Onekey" --file-version="${{ env.PACKAGE_VERSION }}" --product-version="${{ env.PACKAGE_VERSION }}" --file-description="Onekey Depot Manifest Downloader." --copyright="Copyright © 2025 ikunshare All Rights Reserved." --include-data-dir="web=web" --output-dir="build" --output-filename="Onekey_v${{ env.PACKAGE_VERSION }}.exe" --include-data-file="./icon.jpg=./icon.jpg" main.py
- name: 创建标签
uses: pkgdeps/git-tag-action@v3

View File

@@ -47,21 +47,6 @@ git clone https://github.com/ikunshare/Onekey
pip install -r requirements.txt
```
<!-- ## 项目协议
本项目基于 GPL-2.0 许可证发行,以下协议是对于 GPL-2.0 原协议的补充,如有冲突,以以下协议为准。
词语约定: “使用者”指签署本协议的使用者;“版权数据”指包括但不限于图像、音频、名字等在内的他人拥有所属版权的数据。
本项目的数据来源原理是从Steam官方的CDN服务器中拉取游戏清单数据经过对数据简单地筛选与合并后进行展示因此本项目不对数据的准确性负责。
使用本项目的过程中可能会产生版权数据对于这些版权数据本项目不拥有它们的所有权为了避免造成侵权使用者务必在24 小时内清除使用本项目的过程中所产生的版权数据。
由于使用本项目产生的包括由于本协议或由于使用或无法使用本项目而引起的任何性质的任何直接、间接、特殊、偶然或结果性损害(包括但不限于因商誉损失、停工、计算机故障或故障引起的损害赔偿,或任何及所有其他商业损害或损失)由使用者负责。
本项目完全免费,且开源发布于 GitHub 面向全世界人用作对技术的学习交流,本项目不对项目内的技术可能存在违反当地法律法规的行为作保证,禁止在违反当地法律法规的情况下使用本项目,对于使用者在明知或不知当地法律法规不允许的情况下使用本项目所造成的任何违法违规行为由使用者承担,本项目不承担由此造成的任何直接、间接、特殊、偶然或结果性责任。
而且,本项目已禁止使用于商业用途,以及不得进行未经允许的二次修改,否则必须同时发布源代码。
若你使用了本项目,将代表你接受以上协议。
Steam正版平台不易请尊重版权支持正版。
本项目仅用于对技术可行性的探索及研究,不接受任何商业(包括但不限于广告等)合作。 -->
## Star 趋势图
[![Stargazers over time](https://starchart.cc/ikunshare/Onekey.svg)](https://starchart.cc/ikunshare/Onekey)

139
main.py

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"name": "onekey",
"version": "2.0.5",
"version": "2.1.2",
"description": "一个Steam仓库清单下载器",
"main": "index.js",
"scripts": {

View File

@@ -1,9 +1,15 @@
vdf
httpx
Pillow
pystray
# Server
loguru
fastapi
uvicorn
logzero
colorama
fastapi[all]
steam[client]
# Encode
vdf
ujson
# Web
jinja2
aiohttp
pywebview
websockets
# Any things
Pillow
pystray

View File

@@ -1,19 +1,22 @@
import os
import sys
import json
import ujson
import winreg
from pathlib import Path
from typing import Dict, Optional
from .constants import CONFIG_FILE
from .models import AppConfig
from .utils.i18n import t
DEFAULT_CONFIG = {
"KEY": "",
"Port": 5000,
"Debug_Mode": False,
"Logging_Files": True,
"Show_Console": False,
"Custom_Steam_Path": "",
"Language": "zh",
}
@@ -31,12 +34,12 @@ class ConfigManager:
"""生成默认配置文件"""
try:
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, indent=2, ensure_ascii=False)
print("配置文件已生成")
ujson.dump(DEFAULT_CONFIG, f, indent=2, ensure_ascii=False)
print(t("config.generated"))
os.system("pause")
sys.exit(1)
except IOError as e:
print(f"配置文件创建失败: {str(e)}")
print(t("config.create_failed", error=str(e)))
os.system("pause")
sys.exit(1)
@@ -47,45 +50,51 @@ class ConfigManager:
try:
with open(self.config_path, "r", encoding="utf-8") as f:
self._config_data = json.load(f)
self._config_data = ujson.load(f)
self.app_config = AppConfig(
key=self._config_data.get("KEY", ""),
port=self._config_data.get("Port", 5000),
custom_steam_path=self._config_data.get("Custom_Steam_Path", ""),
debug_mode=self._config_data.get("Debug_Mode", False),
logging_files=self._config_data.get("Logging_Files", True),
show_console=self._config_data.get("Show_Console", True),
language=self._config_data.get("Language", "zh"),
)
self.steam_path = self._get_steam_path()
except json.JSONDecodeError:
print("配置文件损坏,正在重新生成...")
except ujson.JSONDecodeError:
print(t("config.corrupted"))
self._generate_config()
print("配置文件已重新生成,使用默认配置继续运行")
print(t("config.regenerated"))
self.app_config = AppConfig(
key=DEFAULT_CONFIG.get("KEY", ""),
port=DEFAULT_CONFIG.get("Port", 5000),
custom_steam_path=DEFAULT_CONFIG.get("Custom_Steam_Path", ""),
debug_mode=DEFAULT_CONFIG.get("Debug_Mode", False),
logging_files=DEFAULT_CONFIG.get("Logging_Files", True),
show_console=DEFAULT_CONFIG.get("Show_Console", True),
language=DEFAULT_CONFIG.get("Language", "zh"),
)
try:
self.steam_path = self._get_steam_path()
except:
except Exception:
self.steam_path = None
except Exception as e:
print(f"配置加载失败: {str(e)}")
print("使用默认配置继续运行")
print(t("config.load_failed", error=str(e)))
print(t("config.use_default"))
self.app_config = AppConfig(
key=DEFAULT_CONFIG.get("KEY", ""),
port=DEFAULT_CONFIG.get("Port", 5000),
custom_steam_path=DEFAULT_CONFIG.get("Custom_Steam_Path", ""),
debug_mode=DEFAULT_CONFIG.get("Debug_Mode", False),
logging_files=DEFAULT_CONFIG.get("Logging_Files", True),
show_console=DEFAULT_CONFIG.get("Show_Console", True),
language=DEFAULT_CONFIG.get("Language", "zh"),
)
try:
self.steam_path = self._get_steam_path()
except:
except Exception:
self.steam_path = None
def _get_steam_path(self) -> Optional[Path]:
@@ -99,6 +108,6 @@ class ConfigManager:
) as key:
return Path(winreg.QueryValueEx(key, "SteamPath")[0])
except Exception as e:
print(f"Steam路径获取失败: {str(e)}")
print("程序将继续运行,但部分功能可能不可用")
print(t("config.steam_path_failed", error=str(e)))
print(t("config.continue_partial"))
return None

View File

@@ -2,11 +2,15 @@
from pathlib import Path
LOG_DIR = Path("logs")
CONFIG_FILE = Path("config.json")
STEAM_API_BASE = "https://steam.ikunshare.com/api"
STEAM_CACHE_CDN_LIST = [
LOG_DIR: Path = Path("logs")
CONFIG_FILE: Path = Path("config.json")
STEAM_API_BASE: str = "https://steam.ikunshare.com/api"
STEAM_CACHE_CDN_LIST: list = [
"http://alibaba.cdn.steampipe.steamcontent.com",
"http://steampipe.steamcontent.tnkjmec.com",
"http://fastly.cdn.steampipe.steamcontent.com",
"http://akamai.cdn.steampipe.steamcontent.com",
"http://telus.cdn.steampipe.steamcontent.com",
"https://cache1-hkg1.steamcontent.com",
"https://cache2-hkg1.steamcontent.com",
"https://cache3-hkg1.steamcontent.com",

View File

@@ -1,9 +1,7 @@
"""日志模块"""
import logging
import colorama
import logzero
from logzero import setup_logger, LogFormatter
import sys
from loguru import logger
from .constants import LOG_DIR
@@ -15,53 +13,51 @@ class Logger:
self.name = name
self.debug_mode = debug_mode
self.log_file = log_file
self._logger = self._setup_logger()
self._setup_logger()
self._logger = logger.bind(name=name)
def _setup_logger(self) -> logging.Logger:
def _setup_logger(self):
"""设置日志器"""
level = logzero.DEBUG if self.debug_mode else logzero.INFO
# 移除默认的 handler
logger.remove()
colors = {
logzero.DEBUG: colorama.Fore.CYAN,
logzero.INFO: colorama.Fore.GREEN,
logzero.WARNING: colorama.Fore.YELLOW,
logzero.ERROR: colorama.Fore.RED,
logzero.CRITICAL: colorama.Fore.MAGENTA,
}
level = "DEBUG" if self.debug_mode else "INFO"
terminal_formatter = LogFormatter(
color=True,
fmt="%(color)s%(message)s%(end_color)s",
datefmt="%Y-%m-%d %H:%M:%S",
colors=colors,
# 控制台输出
logger.add(
sys.stderr, format="<level>{message}</level>", level=level, colorize=True
)
logger = setup_logger(self.name, level=level, formatter=terminal_formatter)
if self.log_file:
LOG_DIR.mkdir(exist_ok=True)
logfile = LOG_DIR / f"{self.name}.log"
file_handler = logging.FileHandler(logfile, encoding="utf-8")
file_formatter = logging.Formatter(
"[%(asctime)s] | [%(name)s:%(levelname)s] | [%(module)s.%(funcName)s:%(lineno)d] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
return logger
# 文件输出
file_format = (
"[{time:YYYY-MM-DD HH:mm:ss}] | "
"[{extra[name]}:{level}] | "
"[{module}.{function}:{line}] - {message}"
)
logger.add(
logfile,
format=file_format,
level=level,
encoding="utf-8",
filter=lambda record: record["extra"].get("name") == self.name,
)
def debug(self, msg: str):
self._logger.debug(msg)
self._logger.opt(depth=1).debug(msg)
def info(self, msg: str):
self._logger.info(msg)
self._logger.opt(depth=1).info(msg)
def warning(self, msg: str):
self._logger.warning(msg)
self._logger.opt(depth=1).warning(msg)
def error(self, msg: str):
self._logger.error(msg)
self._logger.opt(depth=1).error(msg)
def critical(self, msg: str):
self._logger.critical(msg)
self._logger.opt(depth=1).critical(msg)

View File

@@ -1,10 +1,13 @@
from typing import List, Dict, Tuple
import ujson
from .constants import STEAM_API_BASE
from .config import ConfigManager
from .logger import Logger
from .models import DepotInfo, ManifestInfo, SteamAppInfo, SteamAppManifestInfo
from .network.client import HttpClient
from .manifest_handler import ManifestHandler
from .utils.i18n import t
class OnekeyApp:
@@ -19,28 +22,27 @@ class OnekeyApp:
)
self.client = HttpClient()
async def fetch_key(self):
trans = {
"week": "周卡",
"month": "月卡",
"year": "年卡",
"permanent": "永久卡",
}
async def fetch_key(self) -> bool:
"""获取并验证卡密信息"""
try:
response = await self.client._client.post(
f"{STEAM_API_BASE}/getKeyInfo",
json={"key": self.config.app_config.key},
)
body = response.json()
body = ujson.loads(await response.content.read())
if not body["info"]:
self.logger.error("卡密不存在")
self.logger.error(t("api.key_not_exist"))
return False
self.logger.info(f"卡密类型: {trans[body['info']['type']]}")
if trans[body["info"]["type"]] != "永久卡":
self.logger.info(f"卡密过期时间: {body['info']['expiresAt']}")
key_type = body["info"]["type"]
self.logger.info(t("api.key_type", type=t(f"key_type.{key_type}")))
if key_type != "permanent":
self.logger.info(t("api.key_expires", time=body["info"]["expiresAt"]))
return True
except Exception as e:
self.logger.error(f"获取卡密信息失败: {str(e)}")
self.logger.error(t("api.key_info_failed", error=str(e)))
return True
async def fetch_app_data(
@@ -53,7 +55,7 @@ class OnekeyApp:
dlc_manifests = []
try:
self.logger.info(f"正在获取游戏 {app_id} 的信息...")
self.logger.info(t("api.fetching_game", app_id=app_id))
response = await self.client._client.post(
f"{STEAM_API_BASE}/getGame",
@@ -61,25 +63,27 @@ class OnekeyApp:
headers={"X-Api-Key": self.config.app_config.key},
)
if response.status_code == 401:
self.logger.error("API密钥无效")
return []
if response.status == 401:
self.logger.error(t("api.invalid_key"))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
if response.status_code != 200:
self.logger.error(f"API请求失败状态码: {response.status_code}")
return []
if response.status != 200:
self.logger.error(t("api.request_failed", code=response.status))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
data = response.json()
data = ujson.loads(await response.content.read())
if not data:
self.logger.error("未找到此游戏的清单信息")
return []
self.logger.error(t("api.no_manifest"))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
self.logger.info(f"游戏名称: {data.get('name', 'Unknown')}")
self.logger.info(f"Depot数量: {data.get('depotCount', 'Unknown')}")
self.logger.info(t("api.game_name", name=data.get("name", "Unknown")))
self.logger.info(
t("api.depot_count", count=data.get("depotCount", "Unknown"))
)
if and_dlc:
for item in data["gameManifests"]:
for item in data.get("gameManifests", []):
manifest = ManifestInfo(
app_id=item["app_id"],
depot_id=item["depot_id"],
@@ -89,10 +93,14 @@ class OnekeyApp:
)
main_app_manifests.append(manifest)
for item in data["dlcManifests"]:
self.logger.info(f"DLC名称: {item.get('dlcName', 'Unknown')}")
self.logger.info(f"DLC AppId: {item.get('dlcId', 'Unknown')}")
for manifests in item["manifests"]:
for item in data.get("dlcManifests", []):
self.logger.info(
t("api.dlc_name", name=item.get("dlcName", "Unknown"))
)
self.logger.info(
t("api.dlc_appid", id=item.get("dlcId", "Unknown"))
)
for manifests in item.get("manifests", []):
manifest = ManifestInfo(
app_id=manifests["app_id"],
depot_id=manifests["depot_id"],
@@ -102,7 +110,7 @@ class OnekeyApp:
)
dlc_manifests.append(manifest)
else:
for item in data["manifests"]:
for item in data.get("manifests", []):
manifest = ManifestInfo(
app_id=item["app_id"],
depot_id=item["depot_id"],
@@ -111,24 +119,26 @@ class OnekeyApp:
url=item["url"],
)
main_app_manifests.append(manifest)
except Exception as e:
self.logger.error(f"获取主游戏信息失败: {str(e)}")
return SteamAppManifestInfo(mainapp=[], dlcs=[])
self.logger.error(t("api.fetch_failed", error=str(e)))
return SteamAppInfo(), SteamAppManifestInfo(mainapp=[], dlcs=[])
return SteamAppInfo(
app_id,
data["name"],
data.get("name", ""),
data.get("totalDLCCount", data.get("dlcCount", 0)),
data["depotCount"],
data.get("depotCount", 0),
data.get("workshopDecryptionKey", "None"),
), SteamAppManifestInfo(mainapp=main_app_manifests, dlcs=dlc_manifests)
def prepare_depot_data(
self, manifests: List[ManifestInfo]
) -> tuple[List[DepotInfo], Dict[str, List[str]]]:
) -> Tuple[List[DepotInfo], Dict[str, List[str]]]:
"""准备仓库数据"""
depot_data = []
depot_dict = {}
for manifest in manifests:
if manifest.depot_id not in depot_dict:
depot_dict[manifest.depot_id] = {
@@ -147,34 +157,43 @@ class OnekeyApp:
return depot_data, depot_dict
async def run(self, app_id: str, tool_type: str, dlc: bool):
async def run(self, app_id: str, tool_type: str, dlc: bool) -> bool:
"""
为Web版本提供的运行方法。
Args:
app_id: Steam应用ID
tool_type: 解锁工具类型 (steamtools/greenluma)
dlc: 是否包含DLC
Returns:
是否成功执行
"""
try:
if not self.config.steam_path:
self.logger.error("Steam路径未配置或无效无法继续")
self.logger.error(t("task.no_steam_path"))
return False
await self.fetch_key()
manifests = []
app_info, manifests = await self.fetch_app_data(app_id, dlc)
if not manifests:
if not manifests.mainapp and not manifests.dlcs:
return False
manifest_handler = ManifestHandler(
self.client, self.logger, self.config.steam_path
)
processed_manifests = await manifest_handler.process_manifests(manifests)
if not processed_manifests:
self.logger.error("没有成功处理的清单")
self.logger.error(t("task.no_manifest_processed"))
return False
depot_data, _ = self.prepare_depot_data(processed_manifests)
self.logger.info(f"选择的解锁工具: {tool_type}")
self.logger.info(t("tool.selected", tool=tool_type))
if tool_type == "steamtools":
from .tools.steamtools import SteamTools
@@ -186,18 +205,19 @@ class OnekeyApp:
tool = GreenLuma(self.config.steam_path)
success = await tool.setup(depot_data, app_id)
else:
self.logger.error("无效的工具选择")
self.logger.error(t("tool.invalid_selection"))
return False
if success:
self.logger.info("游戏解锁配置成功!")
self.logger.info("重启Steam后生效")
self.logger.info(t("tool.config_success"))
self.logger.info(t("tool.restart_steam"))
return True
else:
self.logger.error("配置失败")
self.logger.error(t("tool.config_failed"))
return False
except Exception as e:
self.logger.error(f"运行错误: {e}")
self.logger.error(t("task.run_error", error=str(e)))
return False
finally:
await self.client.close()

View File

@@ -1,11 +1,13 @@
import vdf
import io
import struct
import zipfile
from pathlib import Path
from typing import List, Optional
from steam.client.cdn import DepotManifest
from .constants import STEAM_CACHE_CDN_LIST
from .models import ManifestInfo, SteamAppManifestInfo
from .logger import Logger
from .network.client import HttpClient
from .utils.i18n import t
class ManifestHandler:
@@ -25,33 +27,40 @@ class ManifestHandler:
url = cdn + manifest_info.url
try:
r = await self.client.get(url)
if r.status_code == 200:
return r.content
if r.status == 200:
return await r.content.read()
except Exception as e:
self.logger.debug(f"{url} 下载失败: {str(e)}")
self.logger.debug(t("manifest.download.failed", url=url, error=e))
@staticmethod
def _serialize_manifest_data(content: bytes) -> bytes:
magic_signature = struct.pack("<I", 0x71F617D0)
payload = content
if len(content) >= 4 and content[:4] == magic_signature:
payload = content[8:]
else:
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
payload = zf.read("z")
except (zipfile.BadZipFile, KeyError):
pass
return magic_signature + struct.pack("<I", len(payload)) + payload
def process_manifest(
self, manifest_data: bytes, manifest_info: ManifestInfo, remove_old: bool = True
) -> bool:
"""处理清单文件"""
try:
depot_id = manifest_info.depot_id
manifest_id = manifest_info.manifest_id
depot_key = bytes.fromhex(manifest_info.depot_key)
manifest = DepotManifest(manifest_data)
_ = bytes.fromhex(manifest_info.depot_key)
serialized_data = self._serialize_manifest_data(manifest_data)
manifest_path = self.depot_cache / f"{depot_id}_{manifest_id}.manifest"
config_path = self.depot_cache / "config.vdf"
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
d = vdf.load(f)
else:
d = {"depots": {}}
d["depots"][depot_id] = {"DecryptionKey": depot_key.hex()}
d = {"depots": dict(sorted(d["depots"].items()))}
if remove_old:
for file in self.depot_cache.iterdir():
if file.suffix == ".manifest":
@@ -62,19 +71,22 @@ class ManifestHandler:
and parts[1] != str(manifest_id)
):
file.unlink(missing_ok=True)
self.logger.info(f"删除旧清单: {file.name}")
self.logger.info(t("manifest.delete_old", name=file.name))
with open(manifest_path, "wb") as f:
f.write(manifest.serialize(compress=False))
f.write(serialized_data)
with open(config_path, "w", encoding="utf-8") as f:
vdf.dump(d, f, pretty=True)
self.logger.info(f"清单处理成功: {depot_id}_{manifest_id}.manifest")
self.logger.info(
t(
"manifest.process.success",
depot_id=depot_id,
manifest_id=manifest_id,
)
)
return True
except Exception as e:
self.logger.error(f"处理清单时出错: {str(e)}")
self.logger.error(t("manifest.process.failed", error=e))
return False
async def process_manifests(
@@ -82,23 +94,25 @@ class ManifestHandler:
) -> List[ManifestInfo]:
"""批量处理清单"""
processed = []
all_manifests = manifests.mainapp + manifests.dlcs
app_manifest = manifests.mainapp
dlc_manifest = manifests.dlcs
for manifest_info in app_manifest + dlc_manifest:
for manifest_info in all_manifests:
manifest_path = (
self.depot_cache
/ f"{manifest_info.depot_id}_{manifest_info.manifest_id}.manifest"
)
if manifest_path.exists():
self.logger.warning(f"清单已存在: {manifest_path.name}")
self.logger.warning(t("manifest.exists", name=manifest_path.name))
processed.append(manifest_info)
continue
self.logger.info(
f"正在下载清单: {manifest_info.depot_id}_{manifest_info.manifest_id}"
t(
"manifest.downloading",
depot_id=manifest_info.depot_id,
manifest_id=manifest_info.manifest_id,
)
)
manifest_data = await self.download_manifest(manifest_info)
@@ -107,11 +121,19 @@ class ManifestHandler:
processed.append(manifest_info)
else:
self.logger.error(
f"处理清单失败: {manifest_info.depot_id}_{manifest_info.manifest_id}"
t(
"manifest.downloading.failed",
depot_id=manifest_info.depot_id,
manifest_id=manifest_info.manifest_id,
)
)
else:
self.logger.error(
f"下载清单失败: {manifest_info.depot_id}_{manifest_info.manifest_id}"
t(
"manifest.process.failed2",
depot_id=manifest_info.depot_id,
manifest_id=manifest_info.manifest_id,
)
)
return processed

View File

@@ -46,7 +46,9 @@ class AppConfig:
"""应用配置"""
key: str = ""
port: int = 5000
custom_steam_path: str = ""
debug_mode: bool = False
logging_files: bool = True
show_console: bool = True
language: str = "zh"

View File

@@ -1,6 +1,6 @@
"""HTTP客户端模块"""
import httpx
import aiohttp
from typing import Optional, Dict
@@ -8,15 +8,15 @@ class HttpClient:
"""HTTP客户端封装"""
def __init__(self):
self._client = httpx.AsyncClient(verify=False, timeout=60, proxy=None)
self._client = aiohttp.ClientSession(conn_timeout=60.0)
async def get(self, url: str, headers: Optional[Dict] = None) -> httpx.Response:
async def get(self, url: str, headers: Optional[Dict] = None) -> aiohttp.ClientResponse:
"""GET请求"""
return await self._client.get(url, headers=headers)
async def close(self):
"""关闭客户端"""
await self._client.aclose()
await self._client.close()
async def __aenter__(self):
return self

View File

@@ -9,31 +9,66 @@ class GreenLuma(UnlockTool):
"""GreenLuma解锁工具实现"""
async def setup(self, depot_data: List[DepotInfo], app_id: str, **kwargs) -> bool:
"""设置GreenLuma解锁"""
applist_dir = self.steam_path / "AppList"
applist_dir.mkdir(exist_ok=True)
for f in applist_dir.glob("*.txt"):
f.unlink()
if applist_dir.is_file():
applist_dir.unlink(missing_ok=True)
if not applist_dir.is_dir():
applist_dir.mkdir(parents=True, exist_ok=True)
for idx, depot in enumerate(depot_data, 1):
(applist_dir / f"{idx}.txt").write_text(depot.depot_id)
depot_dict = {}
for i in applist_dir.iterdir():
if i.stem.isdecimal() and i.suffix == ".txt":
with i.open("r", encoding="utf-8") as f:
app_id_ = f.read().strip()
depot_dict[int(i.stem)] = (
int(app_id_) if app_id_.isdecimal() else None
)
def find_next_index():
if not depot_dict:
return 0
for i in range(max(depot_dict.keys())):
if i not in depot_dict:
return i
return max(depot_dict.keys()) + 1
if app_id and app_id.isdecimal():
app_id_int = int(app_id)
if app_id_int not in depot_dict.values():
index = find_next_index()
with (applist_dir / f"{index}.txt").open("w", encoding="utf-8") as f:
f.write(str(app_id))
depot_dict[index] = app_id_int
for depot in depot_data:
depot_id = int(depot.depot_id)
if depot_id not in depot_dict.values():
index = find_next_index()
with (applist_dir / f"{index}.txt").open("w", encoding="utf-8") as f:
f.write(str(depot_id))
depot_dict[index] = depot_id
config_path = self.steam_path / "config" / "config.vdf"
try:
with open(config_path, "r", encoding="utf-8") as f:
content = vdf.loads(f.read())
if config_path.is_file():
with open(config_path, "r", encoding="utf-8") as f:
content = vdf.loads(f.read())
else:
content = {}
config_path.parent.mkdir(parents=True, exist_ok=True)
content.setdefault("depots", {}).update(
{
depot.depot_id: {"DecryptionKey": depot.decryption_key}
for depot in depot_data
if "depots" not in content:
content["depots"] = {}
for depot in depot_data:
content["depots"][depot.depot_id] = {
"DecryptionKey": depot.decryption_key
}
)
with open(config_path, "w", encoding="utf-8") as f:
f.write(vdf.dumps(content))
return True
except Exception:
except Exception as e:
print(f"GreenLuma配置失败: {e}")
return False

241
src/utils/i18n.py Normal file
View File

@@ -0,0 +1,241 @@
from typing import Dict
class I18n:
"""国际化管理类"""
def __init__(self, default_lang: str = "zh"):
self.current_lang = default_lang
self.translations: Dict[str, Dict[str, str]] = {}
self._load_translations()
def _load_translations(self):
"""加载所有语言的翻译"""
# 中文翻译
self.translations["zh"] = {
# 系统托盘
"tray.show_window": "显示程序",
"tray.show_console": "显示控制台",
"tray.exit": "退出程序",
# 主程序
"main.starting": "正在启动Onekey...",
"main.tray_created": "系统托盘已创建",
"main.exit": "程序已退出",
"main.start_error": "启动错误: {error}",
"main.press_enter": "按回车键退出...",
"main.startup_failed": "启动失败: {error}",
"main.checkip_success": "已获取IP属地",
"main.checkip_failed": "获取IP属地失败: {error}, 默认您在中国大陆境内",
# 配置
"config.generated": "配置文件已生成",
"config.create_failed": "配置文件创建失败: {error}",
"config.corrupted": "配置文件损坏,正在重新生成...",
"config.regenerated": "配置文件已重新生成,使用默认配置继续运行",
"config.load_failed": "配置加载失败: {error}",
"config.use_default": "使用默认配置继续运行",
"config.steam_path_failed": "Steam路径获取失败: {error}",
"config.continue_partial": "程序将继续运行,但部分功能可能不可用",
# API相关
"api.key_not_exist": "卡密不存在",
"api.key_type": "卡密类型: {type}",
"api.key_expires": "卡密过期时间: {time}",
"api.key_info_failed": "获取卡密信息失败: {error}",
"api.fetching_game": "正在获取游戏 {app_id} 的信息...",
"api.invalid_key": "API密钥无效",
"api.request_failed": "API请求失败状态码: {code}",
"api.no_manifest": "未找到此游戏的清单信息",
"api.game_name": "游戏名称: {name}",
"api.depot_count": "Depot数量: {count}",
"api.dlc_name": "DLC名称: {name}",
"api.dlc_appid": "DLC AppId: {id}",
"api.fetch_failed": "获取主游戏信息失败: {error}",
# 工具相关
"tool.selected": "选择的解锁工具: {tool}",
"tool.invalid_selection": "无效的工具选择",
"tool.config_success": "游戏解锁配置成功!",
"tool.restart_steam": "重启Steam后生效",
"tool.config_failed": "配置失败",
# 任务相关
"task.no_steam_path": "Steam路径未配置或无效无法继续",
"task.no_manifest_processed": "没有成功处理的清单",
"task.run_error": "运行错误: {error}",
# 卡密类型
"key_type.week": "周卡",
"key_type.month": "月卡",
"key_type.year": "年卡",
"key_type.permanent": "永久卡",
# 错误消息
"error.load_icon": "加载图标失败: {error}",
"error.import": "导入错误: {error}",
"error.ensure_root": "请确保在项目根目录中运行此程序",
# Web相关
"web.starting": "启动Onekey Web GUI...",
"web.task_running": "已有任务正在运行",
"web.invalid_appid": "请输入有效的App ID",
"web.invalid_format": "App ID格式无效",
"web.task_started": "任务已开始",
"web.task_failed": "任务执行失败: {error}",
"web.config_saved": "配置已保存",
"web.config_save_failed": "保存配置失败: {error}",
"web.config_reset": "配置已重置为默认值",
"web.config_reset_failed": "重置配置失败: {error}",
"web.key_empty": "卡密不能为空",
"web.key_service_unavailable": "卡密验证服务不可用",
"web.verify_timeout": "验证超时,请检查网络连接",
"web.verify_failed": "验证失败: {error}",
"web.connected": "已连接到服务器",
"web.client_disconnected": "客户端断开连接",
"web.websocket_error": "WebSocket 错误: {error}",
"web.invalid_config_data": "无效的配置数据",
# 清单处理
"manifest.download.failed": "{url} 下载失败: {error}",
"manifest.delete_old": "删除旧清单: {name}",
"manifest.process.success": "清单处理成功: {depot_id}_{manifest_id}.manifest",
"manifest.process.failed": "处理清单时出错: {error}",
"manifest.process.failed2": "处理清单失败: {depot_id}_{manifest_id}",
"manifest.exists": "清单已存在: {name}",
"manifest.downloading": "正在下载清单: {depot_id}_{manifest_id}",
"manifest.downloading.failed": "下载清单失败: {depot_id}_{manifest_id}",
}
# 英文翻译
self.translations["en"] = {
# System tray
"tray.show_window": "Show Window",
"tray.show_console": "Show Console",
"tray.exit": "Exit",
# Main program
"main.starting": "Starting Onekey...",
"main.tray_created": "System tray created",
"main.exit": "Program exited",
"main.start_error": "Startup error: {error}",
"main.press_enter": "Press Enter to exit...",
"main.startup_failed": "Startup failed: {error}",
"main.checkip_success": "Obtained IP territory",
"main.checkip_failed": "Failed to obtain IP territory: {error}, by default you are in mainland China",
# Configuration
"config.generated": "Configuration file generated",
"config.create_failed": "Failed to create configuration file: {error}",
"config.corrupted": "Configuration file corrupted, regenerating...",
"config.regenerated": "Configuration file regenerated, continuing with default config",
"config.load_failed": "Failed to load configuration: {error}",
"config.use_default": "Continuing with default configuration",
"config.steam_path_failed": "Failed to get Steam path: {error}",
"config.continue_partial": "Program will continue but some features may be unavailable",
# API related
"api.key_not_exist": "Key does not exist",
"api.key_type": "Key type: {type}",
"api.key_expires": "Key expires at: {time}",
"api.key_info_failed": "Failed to get key info: {error}",
"api.fetching_game": "Fetching game {app_id} information...",
"api.invalid_key": "Invalid API key",
"api.request_failed": "API request failed with status code: {code}",
"api.no_manifest": "No manifest found for this game",
"api.game_name": "Game name: {name}",
"api.depot_count": "Depot count: {count}",
"api.dlc_name": "DLC name: {name}",
"api.dlc_appid": "DLC AppId: {id}",
"api.fetch_failed": "Failed to fetch main game info: {error}",
# Tool related
"tool.selected": "Selected unlock tool: {tool}",
"tool.invalid_selection": "Invalid tool selection",
"tool.config_success": "Game unlock configuration successful!",
"tool.restart_steam": "Restart Steam to take effect",
"tool.config_failed": "Configuration failed",
# Task related
"task.no_steam_path": "Steam path not configured or invalid, cannot continue",
"task.no_manifest_processed": "No manifests successfully processed",
"task.run_error": "Run error: {error}",
# Key types
"key_type.week": "Weekly",
"key_type.month": "Monthly",
"key_type.year": "Yearly",
"key_type.permanent": "Permanent",
# Error messages
"error.load_icon": "Failed to load icon: {error}",
"error.import": "Import error: {error}",
"error.ensure_root": "Please ensure running this program from project root",
# Web related
"web.starting": "Starting Onekey Web GUI...",
"web.task_running": "A task is already running",
"web.invalid_appid": "Please enter a valid App ID",
"web.invalid_format": "Invalid App ID format",
"web.task_started": "Task started",
"web.task_failed": "Task execution failed: {error}",
"web.config_saved": "Configuration saved",
"web.config_save_failed": "Failed to save configuration: {error}",
"web.config_reset": "Configuration reset to default",
"web.config_reset_failed": "Failed to reset configuration: {error}",
"web.key_empty": "Key cannot be empty",
"web.key_service_unavailable": "Key verification service unavailable",
"web.verify_timeout": "Verification timeout, please check network connection",
"web.verify_failed": "Verification failed: {error}",
"web.connected": "Connected to server",
"web.client_disconnected": "Client disconnected",
"web.websocket_error": "WebSocket error: {error}",
"web.invalid_config_data": "Invalid configuration data",
# List processing
"manifest.download.failed": "Downloading from {url} failed: {error}",
"manifest.delete_old": "Delete old manifest: {name}",
"manifest.process.success": "Manifest processing successful: {depot_id}_{manifest_id}.manifest",
"manifest.process.failed": "Error while processing manifest: {error}",
"manifest.process.failed2": "Manifest processing failed: {depot_id}_{manifest_id}",
"manifest.exists": "Manifest already exists: {name}",
"manifest.downloading": "Downloading manifest: {depot_id}_{manifest_id}",
"manifest.downloading.failed": "Manifest download failed: {depot_id}_{manifest_id}",
}
def set_language(self, lang: str):
"""设置当前语言"""
if lang in self.translations:
self.current_lang = lang
else:
raise ValueError(f"Unsupported language: {lang}")
def t(self, key: str, **kwargs) -> str:
"""
获取翻译文本
Args:
key: 翻译键
**kwargs: 格式化参数
Returns:
翻译后的文本
"""
lang_dict = self.translations.get(self.current_lang, {})
text = lang_dict.get(key, key)
# 格式化文本
if kwargs:
try:
text = text.format(**kwargs)
except KeyError:
pass
return text
def get_all_translations(self, lang: str = None) -> Dict[str, str]:
"""获取指定语言的所有翻译"""
lang = lang or self.current_lang
return self.translations.get(lang, {})
# 全局i18n实例
_i18n_instance = None
def get_i18n() -> I18n:
"""获取全局i18n实例"""
global _i18n_instance
if _i18n_instance is None:
from ..config import ConfigManager
config = ConfigManager()
_i18n_instance = I18n(config.app_config.language)
return _i18n_instance
def t(key: str, **kwargs) -> str:
"""便捷的翻译函数"""
return get_i18n().t(key, **kwargs)

View File

@@ -1,8 +1,8 @@
import os
import sys
import time
import json
import httpx
import ujson
import aiohttp
import asyncio
from pathlib import Path
@@ -14,15 +14,13 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from src.constants import STEAM_API_BASE
from src.utils.i18n import t
# 添加项目根目录到Python路径
project_root = Path(__file__)
sys.path.insert(0, str(project_root))
def get_base_path():
"""获取程序基础路径"""
if hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS)
elif getattr(sys, "frozen", False):
@@ -37,13 +35,12 @@ try:
from src.main import OnekeyApp
from src.config import ConfigManager
except ImportError as e:
print(f"导入错误: {e}")
print("请确保在项目根目录中运行此程序")
print(t("error.import", error=str(e)))
print(t("error.ensure_root"))
sys.exit(1)
class ConnectionManager:
"""WebSocket 连接管理器"""
def __init__(self):
self.active_connections: List[WebSocket] = []
@@ -62,24 +59,20 @@ class ConnectionManager:
for connection in self.active_connections:
try:
await connection.send_text(message)
except:
# 连接可能已关闭
except BaseException:
pass
class WebOnekeyApp:
"""Web版本的Onekey应用"""
def __init__(self, manager: ConnectionManager):
self.onekey_app = None
self.current_task = None
self.task_status = "idle" # idle, running, completed, error
self.task_status = "idle"
self.task_progress = []
self.task_result = None
self.manager = manager
def init_app(self):
"""初始化Onekey应用"""
try:
self.onekey_app = OnekeyApp()
return True
@@ -87,18 +80,14 @@ class WebOnekeyApp:
return False, str(e)
async def run_unlock_task(self, app_id: str, tool_type: str, dlc: bool):
"""运行解锁任务"""
try:
self.task_status = "running"
self.task_progress = []
# 重新初始化应用以确保新的任务状态
self.onekey_app = OnekeyApp()
# 添加自定义日志处理器来捕获进度
self._add_progress_handler()
# 执行解锁任务
result = await self.onekey_app.run(app_id, tool_type, dlc)
if result:
@@ -115,17 +104,15 @@ class WebOnekeyApp:
self.task_status = "error"
self.task_result = {"success": False, "message": f"配置失败: {str(e)}"}
finally:
# 确保应用资源被清理
if hasattr(self, "onekey_app") and self.onekey_app:
try:
if hasattr(self.onekey_app, "client"):
await self.onekey_app.client.close()
except:
except BaseException:
pass
self.onekey_app = None
def _add_progress_handler(self):
"""添加进度处理器"""
if self.onekey_app and self.onekey_app.logger:
original_info = self.onekey_app.logger.info
original_warning = self.onekey_app.logger.warning
@@ -135,10 +122,9 @@ class WebOnekeyApp:
self.task_progress.append(
{"type": "info", "message": str(msg), "timestamp": time.time()}
)
# 广播进度消息
asyncio.create_task(
self.manager.broadcast(
json.dumps(
ujson.dumps(
{
"type": "task_progress",
"data": {"type": "info", "message": str(msg)},
@@ -154,7 +140,7 @@ class WebOnekeyApp:
)
asyncio.create_task(
self.manager.broadcast(
json.dumps(
ujson.dumps(
{
"type": "task_progress",
"data": {"type": "warning", "message": str(msg)},
@@ -170,7 +156,7 @@ class WebOnekeyApp:
)
asyncio.create_task(
self.manager.broadcast(
json.dumps(
ujson.dumps(
{
"type": "task_progress",
"data": {"type": "error", "message": str(msg)},
@@ -185,7 +171,6 @@ class WebOnekeyApp:
self.onekey_app.logger.error = error_with_progress
# 创建FastAPI应用
app = FastAPI(title="Onekey")
app.add_middleware(
CORSMiddleware,
@@ -197,11 +182,16 @@ app.add_middleware(
manager = ConnectionManager()
# 修复为静态文件路由添加name参数
app.mount("/static", StaticFiles(directory=f"{base_path}/static"), name="static")
templates = Jinja2Templates(directory=f"{base_path}/templates")
config = ConfigManager()
app.mount(
"/static",
StaticFiles(directory=f"{base_path}/{config.app_config.language}/static"),
name="static",
)
templates = Jinja2Templates(
directory=f"{base_path}/{config.app_config.language}/templates"
)
# 创建Web应用实例
web_app = WebOnekeyApp(manager)
@@ -259,7 +249,6 @@ async def start_unlock(request: Request):
if not app_id:
return JSONResponse({"success": False, "message": "请输入有效的App ID"})
# 验证App ID格式
app_id_list = [id for id in app_id.split("-") if id.isdigit()]
if not app_id_list:
return JSONResponse({"success": False, "message": "App ID格式无效"})
@@ -285,16 +274,14 @@ async def get_task_status():
return JSONResponse(
{
"status": web_app.task_status,
"progress": (
web_app.task_progress[-10:] if web_app.task_progress else []
), # 只返回最近10条
"progress": (web_app.task_progress[-10:] if web_app.task_progress else []),
"result": web_app.task_result,
}
)
@app.get("/about")
async def settings_page(request: Request):
async def about_page(request: Request):
"""关于页面"""
return templates.TemplateResponse("about.html", {"request": request})
@@ -311,28 +298,26 @@ async def update_config(request: Request):
try:
data = await request.json()
# 验证必需的字段
if not isinstance(data, dict):
return {"success": False, "message": "无效的配置数据"}
# 加载当前配置
config_manager = ConfigManager()
# 准备新的配置数据
new_config = {
"KEY": data.get("key", ""),
"Port": config_manager.app_config.port,
"Custom_Steam_Path": data.get("steam_path", ""),
"Debug_Mode": data.get("debug_mode", False),
"Logging_Files": data.get("logging_files", True),
"Show_Console": data.get("show_console", True),
"Language": data.get("language", "zh"),
}
# 保存配置
import json
import ujson
config_path = config_manager.config_path
with open(config_path, "w", encoding="utf-8") as f:
json.dump(new_config, f, indent=2, ensure_ascii=False)
ujson.dump(new_config, f, indent=2, ensure_ascii=False)
return {"success": True, "message": "配置已保存"}
@@ -345,13 +330,13 @@ async def reset_config():
"""重置配置为默认值"""
try:
from src.config import DEFAULT_CONFIG
import json
import ujson
config_manager = ConfigManager()
config_path = config_manager.config_path
with open(config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, indent=2, ensure_ascii=False)
ujson.dump(DEFAULT_CONFIG, f, indent=2, ensure_ascii=False)
return {"success": True, "message": "配置已重置为默认值"}
@@ -375,6 +360,7 @@ async def get_detailed_config():
config.steam_path.exists() if config.steam_path else False
),
"key": getattr(config.app_config, "key", ""),
"language": config.app_config.language,
},
}
except Exception as e:
@@ -391,19 +377,19 @@ async def get_key_info(request: Request):
if not key:
return JSONResponse({"success": False, "message": "卡密不能为空"})
async with httpx.AsyncClient(timeout=10.0) as client:
async with aiohttp.ClientSession(conn_timeout=10.0) as client:
response = await client.post(
f"{STEAM_API_BASE}/getKeyInfo",
url=f"{STEAM_API_BASE}/getKeyInfo",
json={"key": key},
headers={"Content-Type": "application/json"},
)
if response.status_code == 200:
result = response.json()
if response.status == 200:
result = ujson.loads(await response.content.read())
return JSONResponse(result)
else:
return JSONResponse({"success": False, "message": "卡密验证服务不可用"})
except httpx.TimeoutException:
except aiohttp.ConnectionTimeoutError:
return JSONResponse({"success": False, "message": "验证超时,请检查网络连接"})
except Exception as e:
return JSONResponse({"success": False, "message": f"验证失败: {str(e)}"})
@@ -415,23 +401,22 @@ async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
await websocket.send_text(
json.dumps({"type": "connected", "data": {"message": "已连接到服务器"}})
ujson.dumps({"type": "connected", "data": {"message": "已连接到服务器"}})
)
while True:
data = await websocket.receive_text()
message = json.loads(data)
message = ujson.loads(data)
if message.get("type") == "ping":
await websocket.send_text(
json.dumps({"type": "pong", "data": {"timestamp": time.time()}})
ujson.dumps({"type": "pong", "data": {"timestamp": time.time()}})
)
except WebSocketDisconnect:
manager.disconnect(websocket)
print("客户端断开连接")
print(t("web.client_disconnected"))
except Exception as e:
print(f"WebSocket 错误: {e}")
print(t("web.websocket_error", error=str(e)))
manager.disconnect(websocket)
print("启动Onekey Web GUI...")
print("请在浏览器中访问: http://localhost:5000")
print(t("web.starting"))

685
web/en/static/js/app.js Normal file
View File

@@ -0,0 +1,685 @@
class OnekeyWebApp {
constructor() {
this.socket = null;
this.taskStatus = "idle";
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 2000;
this.initializeSocket();
this.initializeEventListeners();
this.checkConfig();
}
initializeSocket() {
this.connectWebSocket();
}
connectWebSocket() {
try {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log("Connected to server");
this.showSnackbar("Connected to server", "success");
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.socket.onclose = (event) => {
console.log("Disconnected from server", event);
this.showSnackbar("Disconnected from server", "error");
this.stopHeartbeat();
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempts++;
console.log(
`Trying reconnect... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
);
this.connectWebSocket();
}, this.reconnectDelay);
}
};
this.socket.onerror = (error) => {
console.error("WebSocket error:", error);
};
this.socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (e) {
console.error("Failed to parse message:", e);
}
};
} catch (error) {
console.error("Failed to connect WebSocket:", error);
this.showSnackbar("Failed to connect WebSocket", "error");
}
}
handleMessage(message) {
switch (message.type) {
case "connected":
console.log(message.data.message);
break;
case "task_progress":
this.addLogEntry(message.data.type, message.data.message);
break;
case "pong":
break;
default:
console.log("Unknown message type:", message.type);
}
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat();
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
initializeEventListeners() {
const unlockForm = document.getElementById("unlockForm");
unlockForm.addEventListener("submit", (e) => {
e.preventDefault();
this.startUnlockTask();
});
const resetBtn = document.getElementById("resetBtn");
resetBtn.addEventListener("click", () => {
this.resetForm();
});
const clearLogBtn = document.getElementById("clearLogBtn");
clearLogBtn.addEventListener("click", () => {
this.clearLogs();
});
const snackbarClose = document.getElementById("snackbarClose");
snackbarClose.addEventListener("click", () => {
this.hideSnackbar();
});
window.addEventListener("beforeunload", () => {
this.disconnect();
});
}
async checkConfig() {
const configStatus = document.getElementById("configStatus");
try {
const response = await fetch("/api/config");
const data = await response.json();
if (data.success) {
configStatus.innerHTML = this.generateConfigStatusHTML(data.config);
} else {
configStatus.innerHTML = `
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">Load config failed: ${data.message}</span>
</div>
`;
}
} catch (error) {
configStatus.innerHTML = `
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">Failed to connect WebSocket</span>
</div>
`;
}
}
generateConfigStatusHTML(config) {
const items = [];
if (config.steam_path) {
items.push(`
<div class="status-item">
<span class="material-icons status-icon success">check_circle</span>
<span class="status-text">Steam Path: ${config.steam_path}</span>
</div>
`);
} else {
items.push(`
<div class="status-item">
<span class="material-icons status-icon error">error</span>
<span class="status-text">Steam path not found</span>
</div>
`);
}
if (config.debug_mode) {
items.push(`
<div class="status-item">
<span class="material-icons status-icon warning">bug_report</span>
<span class="status-text">Debug mode is enable</span>
</div>
`);
}
return items.join("");
}
toggleAndDLC() {
document.getElementById("+DLC").checked = true;
}
async startUnlockTask() {
if (this.taskStatus === "running") {
this.showSnackbar("There is already a task running", "warning");
return;
}
const formData = new FormData(document.getElementById("unlockForm"));
const appId = formData.get("appId").trim();
const toolType = formData.get("toolType");
const ADLC = formData.get("+DLC") === "on";
if (!appId) {
this.showSnackbar("Please enter App ID", "error");
return;
}
const appIdPattern = /^[\d-]+$/;
if (!appIdPattern.test(appId)) {
this.showSnackbar(
"Invalid App ID format, should be a number or numbers separated by -",
"error",
);
return;
}
this.taskStatus = "running";
this.updateUIForRunningTask();
this.clearLogs();
this.addLogEntry("info", `Start working on the game ${appId}...`);
try {
const response = await fetch("/api/start_unlock", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
app_id: appId,
tool_type: toolType,
dlc: ADLC,
}),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("Mission has started", "success");
this.startStatusPolling();
} else {
this.taskStatus = "idle";
this.updateUIForIdleTask();
this.showSnackbar(data.message, "error");
this.addLogEntry("error", data.message);
}
} catch (error) {
this.taskStatus = "idle";
this.updateUIForIdleTask();
this.showSnackbar("Failed to start task", "error");
this.addLogEntry("error", `Failed to start task: ${error.message}`);
}
}
startStatusPolling() {
const pollInterval = setInterval(async () => {
try {
const response = await fetch("/api/task_status");
const data = await response.json();
if (data.status === "completed") {
clearInterval(pollInterval);
this.taskStatus = "completed";
this.updateUIForIdleTask();
if (data.result && data.result.success) {
this.showSnackbar(data.result.message, "success");
this.addLogEntry("info", data.result.message);
} else if (data.result) {
this.showSnackbar(data.result.message, "error");
this.addLogEntry("error", data.result.message);
}
} else if (data.status === "error") {
clearInterval(pollInterval);
this.taskStatus = "error";
this.updateUIForIdleTask();
if (data.result) {
this.showSnackbar(data.result.message, "error");
this.addLogEntry("error", data.result.message);
}
}
} catch (error) {
console.error("Status polling error:", error);
}
}, 1000);
}
updateUIForRunningTask() {
const unlockBtn = document.getElementById("unlockBtn");
const resetBtn = document.getElementById("resetBtn");
const appIdInput = document.getElementById("appId");
const toolTypeRadios = document.querySelectorAll('input[name="toolType"]');
unlockBtn.disabled = true;
unlockBtn.innerHTML = `
<span class="material-icons">hourglass_empty</span>
Executing...
`;
resetBtn.disabled = true;
appIdInput.disabled = true;
toolTypeRadios.forEach((radio) => (radio.disabled = true));
}
updateUIForIdleTask() {
const unlockBtn = document.getElementById("unlockBtn");
const resetBtn = document.getElementById("resetBtn");
const appIdInput = document.getElementById("appId");
const toolTypeRadios = document.querySelectorAll('input[name="toolType"]');
unlockBtn.disabled = false;
unlockBtn.innerHTML = `
<span class="material-icons">play_arrow</span>
Start unlocking
`;
resetBtn.disabled = false;
appIdInput.disabled = false;
toolTypeRadios.forEach((radio) => (radio.disabled = false));
}
resetForm() {
if (this.taskStatus === "running") {
this.showSnackbar("The task is running and cannot be reset.", "warning");
return;
}
document.getElementById("unlockForm").reset();
document.querySelector(
'input[name="toolType"][value="steamtools"]',
).checked = true;
this.clearLogs();
this.showSnackbar("Form has been reset", "success");
}
addLogEntry(type, message) {
const progressContainer = document.getElementById("progressContainer");
const placeholder = progressContainer.querySelector(
".progress-placeholder",
);
if (placeholder) {
placeholder.remove();
}
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement("div");
logEntry.className = `log-entry ${type}`;
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-message">${this.escapeHtml(message)}</span>
`;
progressContainer.appendChild(logEntry);
progressContainer.scrollTop = progressContainer.scrollHeight;
}
clearLogs() {
const progressContainer = document.getElementById("progressContainer");
progressContainer.innerHTML = `
<div class="progress-placeholder">
<span class="material-icons">info</span>
<p>Wait for task to start...</p>
</div>
`;
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type}`;
snackbar.offsetHeight;
snackbar.classList.add("show");
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
}
const style = document.createElement("style");
style.textContent = `
@keyframes iconRotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
if (target) {
target.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
});
});
document.addEventListener("DOMContentLoaded", () => {
const cards = document.querySelectorAll(".card");
cards.forEach((card) => {
card.addEventListener("mousemove", (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const percentX = (x - centerX) / centerX;
const percentY = (y - centerY) / centerY;
const rotateX = percentY * 5;
const rotateY = percentX * 5;
card.style.transform = `perspective(1000px) rotateX(${-rotateX}deg) rotateY(${rotateY}deg) translateZ(10px)`;
});
card.addEventListener("mouseleave", () => {
card.style.transform = "";
});
});
});
function typeWriter(element, text, speed = 50) {
let i = 0;
element.textContent = "";
function type() {
if (i < text.length) {
element.textContent += text.charAt(i);
i++;
setTimeout(type, speed);
}
}
type();
}
function animateValue(element, start, end, duration) {
const range = end - start;
const increment = range / (duration / 16);
let current = start;
const timer = setInterval(() => {
current += increment;
if (
(increment > 0 && current >= end) ||
(increment < 0 && current <= end)
) {
current = end;
clearInterval(timer);
}
element.textContent = Math.round(current);
}, 16);
}
document.querySelectorAll(".btn").forEach((button) => {
button.addEventListener("mousemove", (e) => {
const rect = button.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
button.style.transform = `translate(${x * 0.1}px, ${y * 0.1}px)`;
});
button.addEventListener("mouseleave", () => {
button.style.transform = "";
});
});
function createParticles() {
const particlesContainer = document.createElement("div");
particlesContainer.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
`;
document.body.appendChild(particlesContainer);
for (let i = 0; i < 50; i++) {
const particle = document.createElement("div");
particle.style.cssText = `
position: absolute;
width: 4px;
height: 4px;
background: rgba(94, 53, 177, 0.3);
border-radius: 50%;
top: ${Math.random() * 100}%;
left: ${Math.random() * 100}%;
animation: floatParticle ${
10 + Math.random() * 20
}s linear infinite;
`;
particlesContainer.appendChild(particle);
}
const style = document.createElement("style");
style.textContent = `
@keyframes floatParticle {
0% {
transform: translateY(100vh) rotate(0deg);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100vh) rotate(720deg);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -50px 0px",
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.style.opacity = "1";
entry.target.style.transform = "translateY(0)";
observer.unobserve(entry.target);
}
});
}, observerOptions);
document.querySelectorAll(".card").forEach((card) => {
card.style.opacity = "0";
card.style.transform = "translateY(20px)";
card.style.transition = "opacity 0.6s ease, transform 0.6s ease";
observer.observe(card);
});
document.addEventListener("mousemove", (e) => {
const light = document.createElement("div");
light.style.cssText = `
position: fixed;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(94, 53, 177, 0.1) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition: opacity 0.3s ease;
`;
light.style.left = e.clientX + "px";
light.style.top = e.clientY + "px";
document.body.appendChild(light);
setTimeout(() => {
light.style.opacity = "0";
setTimeout(() => light.remove(), 300);
}, 100);
});
document.querySelectorAll(".status-icon").forEach((icon) => {
if (icon.classList.contains("success")) {
icon.style.animation = "pulse-icon 2s ease-in-out infinite";
}
});
const pulseStyle = document.createElement("style");
pulseStyle.textContent = `
@keyframes pulse-icon {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
`;
document.head.appendChild(pulseStyle);
const originalShowSnackbar = window.showSnackbar;
if (typeof originalShowSnackbar === "function") {
window.showSnackbar = function (message, type = "info") {
originalShowSnackbar(message, type);
if ("vibrate" in navigator) {
if (type === "error") {
navigator.vibrate([100, 50, 100]);
} else {
navigator.vibrate(50);
}
}
const audio = new Audio(
`data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIG2m98OScTgwOUarm7blmFgU7k9n1unEiBC13yO/eizEIHWq+8+OWT` +
`BEFS6Xj67xqGAU+lNr1unIiBCx0xvDdiTYIHWu+8+OWT`,
);
if (type === "success") {
audio.volume = 0.1;
audio.play().catch(() => {});
}
};
}
document.querySelectorAll(".text-field").forEach((input) => {
input.addEventListener("focus", (e) => {
const ripple = document.createElement("div");
ripple.style.cssText = `
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border: 2px solid var(--md-sys-color-primary);
border-radius: var(--md-sys-shape-corner-medium);
opacity: 0;
pointer-events: none;
animation: inputRipple 0.6s ease-out;
`;
const wrapper = input.parentElement;
wrapper.style.position = "relative";
wrapper.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
});
const inputRippleStyle = document.createElement("style");
inputRippleStyle.textContent = `
@keyframes inputRipple {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
`;
document.head.appendChild(inputRippleStyle);
document.addEventListener("DOMContentLoaded", () => {
createParticles();
document.body.classList.add("loaded");
console.log("UI enhancements loaded ✨");
new OnekeyWebApp();
});

View File

@@ -0,0 +1,180 @@
class ProjectInfoEnhancer {
constructor() {
this.initializeProjectInfo();
}
initializeProjectInfo() {
this.addProjectLinkTracking();
this.addVersionClickEaster();
this.addLogoClickEffect();
}
addProjectLinkTracking() {
const projectLinks = document.querySelectorAll(".project-link");
projectLinks.forEach((link) => {
link.addEventListener("click", (e) => {
const linkType = link.classList.contains("github")
? "GitHub repository"
: link.classList.contains("releases")
? "Download release version"
: link.classList.contains("docs")
? "Use documentation"
: link.classList.contains("issues")
? "Problem feedback"
: "unknown link";
console.log(`User clicked on ${linkType} link`);
link.style.transform = "scale(0.95)";
setTimeout(() => {
link.style.transform = "";
}, 150);
});
});
}
addVersionClickEaster() {
const versionLabels = document.querySelectorAll(".version-label");
let clickCount = 0;
versionLabels.forEach((label) => {
label.addEventListener("click", () => {
clickCount++;
if (clickCount === 5) {
this.showEasterEgg();
clickCount = 0;
}
label.style.animation = "pulse 0.3s ease";
setTimeout(() => {
label.style.animation = "";
}, 300);
});
});
}
addLogoClickEffect() {
const logos = document.querySelectorAll(".project-logo");
logos.forEach((logo) => {
logo.addEventListener("click", () => {
logo.style.transform = "rotate(360deg)";
logo.style.transition = "transform 0.6s ease";
setTimeout(() => {
logo.style.transform = "";
logo.style.transition = "";
}, 600);
this.showTooltip(logo, "🎮 Onekey - Steam unlocking made easy!");
});
});
}
showEasterEgg() {
const messages = [
"🎉 You found a hidden easter egg!",
"🚀 Thank you for using Onekey Tools!",
"⭐ Dont forget to give the project a star!",
"🎮 Happy gaming!",
"🔓 Unlock it with one click and enjoy the game!",
];
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
const easterEgg = document.createElement("div");
easterEgg.className = "easter-egg";
easterEgg.textContent = randomMessage;
easterEgg.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(45deg, #6750a4, #7d5260);
color: white;
padding: 20px 30px;
border-radius: 15px;
font-size: 18px;
font-weight: 500;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
z-index: 9999;
animation: easterEggBounce 0.6s ease-out;
`;
if (!document.getElementById("easter-egg-styles")) {
const style = document.createElement("style");
style.id = "easter-egg-styles";
style.textContent = `
@keyframes easterEggBounce {
0% { transform: translate(-50%, -50%) scale(0); opacity: 0; }
50% { transform: translate(-50%, -50%) scale(1.1); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(easterEgg);
setTimeout(() => {
easterEgg.style.animation = "easterEggBounce 0.3s ease-in reverse";
setTimeout(() => {
if (easterEgg.parentNode) {
easterEgg.parentNode.removeChild(easterEgg);
}
}, 300);
}, 3000);
}
showTooltip(element, message) {
const tooltip = document.createElement("div");
tooltip.className = "custom-tooltip";
tooltip.textContent = message;
tooltip.style.cssText = `
position: absolute;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
`;
const rect = element.getBoundingClientRect();
tooltip.style.left = rect.left + rect.width / 2 + "px";
tooltip.style.top = rect.bottom + 10 + "px";
tooltip.style.transform = "translateX(-50%)";
document.body.appendChild(tooltip);
setTimeout(() => {
tooltip.style.opacity = "1";
}, 10);
setTimeout(() => {
tooltip.style.opacity = "0";
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 300);
}, 2000);
}
}
document.addEventListener("DOMContentLoaded", () => {
new ProjectInfoEnhancer();
});

View File

@@ -0,0 +1,677 @@
class SettingsManager {
constructor() {
this.currentConfig = {};
this.currentKeyInfo = null;
this.newKeyData = null;
this.initializeEventListeners();
this.loadConfig();
this.loadKeyInfo();
}
initializeEventListeners() {
document.getElementById("saveConfig").addEventListener("click", () => {
this.saveConfig();
});
document.getElementById("resetConfig").addEventListener("click", () => {
this.showConfirmDialog(
"Reset configuration",
"Are you sure you want to reset all configurations to default? This operation is irreversible.",
() => this.resetConfig(),
);
});
document.getElementById("testConfig").addEventListener("click", () => {
this.testConfig();
});
document.getElementById("detectSteamPath").addEventListener("click", () => {
this.detectSteamPath();
});
document.getElementById("steamPath").addEventListener("input", () => {
this.validateSteamPath();
});
document.getElementById("verifyNewKey").addEventListener("click", () => {
this.verifyNewKey();
});
document.getElementById("changeKey").addEventListener("click", () => {
this.showConfirmDialog(
"Change card password",
"Are you sure you want to change to a new card password? Re-verification is required after replacement.",
() => this.changeKey(),
);
});
document.getElementById("newKey").addEventListener("input", () => {
this.resetNewKeyStatus();
});
document.getElementById("newKey").addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.verifyNewKey();
}
});
document.getElementById("dialogCancel").addEventListener("click", () => {
this.hideConfirmDialog();
});
document.getElementById("dialogConfirm").addEventListener("click", () => {
this.executeConfirmAction();
});
document.getElementById("snackbarClose").addEventListener("click", () => {
this.hideSnackbar();
});
}
async loadConfig() {
try {
const response = await fetch("/api/config/detailed");
const data = await response.json();
if (data.success) {
this.currentConfig = data.config;
this.populateForm();
this.updateConfigStatus();
} else {
this.showSnackbar(
"Failed to load configuration: " + data.message,
"error",
);
}
} catch (error) {
this.showSnackbar("Unable to connect to server", "error");
console.error("Load config error:", error);
}
}
async loadKeyInfo() {
const keyInfoSection = document.getElementById("keyInfoSection");
try {
const configResponse = await fetch("/api/config/detailed");
const configData = await configResponse.json();
if (!configData.success || !configData.config.key) {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">warning</span>
<div>
<strong>No card password set</strong><br>
Please enter your authorization card password below
</div>
</div>
`;
return;
}
const keyResponse = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: configData.config.key }),
});
const keyData = await keyResponse.json();
if (keyData.key && keyData.info) {
this.currentKeyInfo = keyData.info;
this.displayKeyInfo(keyData.info);
} else {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">error</span>
<div>
<strong>Card password verification failed</strong><br>
The current card password is invalid or expired, please replace it with a new one
</div>
</div>
`;
}
} catch (error) {
keyInfoSection.innerHTML = `
<div class="expiry-warning">
<span class="material-icons">error</span>
<div>
<strong>Failed to obtain card password information</strong><br>
Please check the network connection or contact customer service
</div>
</div>
`;
console.error("Load key info error:", error);
}
}
displayKeyInfo(keyInfo) {
const keyInfoSection = document.getElementById("keyInfoSection");
const expiresAt = new Date(keyInfo.expiresAt);
const createdAt = new Date(keyInfo.createdAt);
const firstUsedAt = keyInfo.firstUsedAt
? new Date(keyInfo.firstUsedAt)
: null;
const now = new Date();
const isExpired = expiresAt < now;
const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
const isExpiringSoon = daysLeft <= 7 && daysLeft > 0;
const typeNames = {
day: "day card",
week: "Weekly card",
month: "monthly card",
year: "Annual Pass",
permanent: "permanent card",
};
let statusBadge = "";
if (isExpired && keyInfo.type != "permanent") {
statusBadge =
'<span class="key-status-badge expired"><span class="material-icons" style="font-size: 14px;">cancel</span>Expired</span>';
} else if (!keyInfo.isActive) {
statusBadge =
'<span class="key-status-badge inactive"><span class="material-icons" style="font-size: 14px;">pause</span>Not activated</span>';
} else {
statusBadge =
'<span class="key-status-badge active"><span class="material-icons" style="font-size: 14px;">check_circle</span>normal</span>';
}
let warningSection = "";
if (isExpiringSoon) {
warningSection = `
<div class="expiry-warning">
<span class="material-icons">schedule</span>
<div>
<strong>upcoming expiry reminder</strong><br>
Your card password will expire in ${daysLeft} days, please renew in time
</div>
</div>
`;
}
keyInfoSection.innerHTML = `
<div class="key-info-grid">
<div class="key-info-card">
<span class="material-icons key-info-icon">fingerprint</span>
<div class="key-info-content">
<div class="key-info-label">Cardamom</div>
<div class="key-info-value">${keyInfo.key.substring(
0,
8,
)}...${keyInfo.key.substring(
keyInfo.key.length - 8,
)}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">label</span>
<div class="key-info-content">
<div class="key-info-label">Type</div>
<div class="key-info-value">${
typeNames[keyInfo.type] || keyInfo.type
}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">toggle_on</span>
<div class="key-info-content">
<div class="key-info-label">State</div>
<div class="key-info-value">${statusBadge}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">event</span>
<div class="key-info-content">
<div class="key-info-label">Expiration time</div>
<div class="key-info-value">${expiresAt.toLocaleDateString()} ${expiresAt
.toLocaleTimeString()
.substring(0, 5)}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">analytics</span>
<div class="key-info-content">
<div class="key-info-label">Number of uses</div>
<div class="key-info-value">${keyInfo.usageCount} / ${
keyInfo.totalUsage || "∞"
}</div>
</div>
</div>
<div class="key-info-card">
<span class="material-icons key-info-icon">schedule</span>
<div class="key-info-content">
<div class="key-info-label">Creation time</div>
<div class="key-info-value">${createdAt.toLocaleDateString()}</div>
</div>
</div>
</div>
${warningSection}
`;
}
async verifyNewKey() {
const newKeyInput = document.getElementById("newKey");
const key = newKeyInput.value.trim();
if (!key) {
this.showSnackbar("Please enter new card password", "error");
return;
}
if (!key.match(/^[A-Z0-9_-]+$/)) {
this.showSnackbar("The card password format is incorrect", "error");
return;
}
const verifyBtn = document.getElementById("verifyNewKey");
const changeBtn = document.getElementById("changeKey");
verifyBtn.disabled = true;
verifyBtn.innerHTML =
'<span class="material-icons">hourglass_empty</span>Verifying...';
try {
const response = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: key }),
});
const data = await response.json();
if (data.key && data.info) {
this.newKeyData = data.info;
this.showSnackbar(
"New card password verification successful!",
"success",
);
changeBtn.style.display = "flex";
verifyBtn.style.display = "none";
const typeNames = {
day: "day card",
week: "Weekly card",
month: "Monthly card",
year: "Annual Pass",
permanent: "permanent card",
};
const expiresAt = new Date(data.info.expiresAt);
this.showSnackbar(
`Verification successful! New card password type:
${typeNames[data.info.type]}
}, valid until:${expiresAt.toLocaleDateString()}`,
"success",
);
} else {
this.showSnackbar(
"The new card password is invalid or expired",
"error",
);
this.newKeyData = null;
}
} catch (error) {
this.showSnackbar(
"Verification failed, please check network connection",
"error",
);
console.error("New key verification error:", error);
} finally {
verifyBtn.disabled = false;
verifyBtn.innerHTML = '<span class="material-icons">check</span>Verify';
}
}
async changeKey() {
if (!this.newKeyData) {
this.showSnackbar("Please verify the new card password first", "error");
return;
}
try {
const newKey = document.getElementById("newKey").value.trim();
const updateData = {
key: newKey,
steam_path: this.currentConfig.steam_path || "",
debug_mode: this.currentConfig.debug_mode || false,
logging_files: this.currentConfig.logging_files !== false,
show_console: this.currentConfig.show_console !== false,
};
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updateData),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("Card secret changed successfully!", "success");
await this.loadKeyInfo();
this.resetNewKeyStatus();
document.getElementById("newKey").value = "";
} else {
this.showSnackbar("Replacement failed: " + data.message, "error");
}
} catch (error) {
this.showSnackbar(
"An error occurred while changing the card password",
"error",
);
console.error("Change key error:", error);
}
this.hideConfirmDialog();
}
resetNewKeyStatus() {
const verifyBtn = document.getElementById("verifyNewKey");
const changeBtn = document.getElementById("changeKey");
verifyBtn.style.display = "flex";
changeBtn.style.display = "none";
this.newKeyData = null;
}
populateForm() {
document.getElementById("steamPath").value =
this.currentConfig.steam_path || "";
document.getElementById("debugMode").checked =
this.currentConfig.debug_mode || false;
document.getElementById("loggingFiles").checked =
this.currentConfig.logging_files !== false;
document.getElementById("showConsole").checked =
this.currentConfig.show_console !== false;
this.validateSteamPath();
}
async saveConfig() {
try {
const config = {
key: this.currentConfig.key || "",
steam_path: document.getElementById("steamPath").value.trim(),
debug_mode: document.getElementById("debugMode").checked,
logging_files: document.getElementById("loggingFiles").checked,
show_console: document.getElementById("showConsole").checked,
};
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
const data = await response.json();
if (data.success) {
this.showSnackbar("Configuration saved", "success");
await this.loadConfig();
} else {
this.showSnackbar("Save failed: " + data.message, "error");
}
} catch (error) {
this.showSnackbar(
"An error occurred while saving the configuration",
"error",
);
console.error("Save config error:", error);
}
}
async resetConfig() {
try {
const response = await fetch("/api/config/reset", {
method: "POST",
});
const data = await response.json();
if (data.success) {
this.showSnackbar(
"The configuration has been reset (the card password remains unchanged)",
"success",
);
await this.loadConfig();
} else {
this.showSnackbar("Reset failed: " + data.message, "error");
}
} catch (error) {
this.showSnackbar(
"An error occurred while resetting the configuration",
"error",
);
console.error("Reset config error:", error);
}
this.hideConfirmDialog();
}
async testConfig() {
this.showSnackbar("Testing configuration...", "info");
try {
const response = await fetch("/api/config");
const data = await response.json();
if (data.success) {
let messages = [];
if (data.config.steam_path) {
messages.push("✓ Steam Path configuration is normal");
} else {
messages.push("✗ Steam Abnormal path configuration");
}
if (this.currentKeyInfo) {
const expiresAt = new Date(this.currentKeyInfo.expiresAt);
let isExpired = expiresAt < new Date();
if ((this.currentKeyInfo.type = "permanent")) {
isExpired = false;
}
if (this.currentKeyInfo.isActive && !isExpired) {
messages.push("✓ Card secret status is normal");
} else {
messages.push("✗ Abnormal card secret status");
}
}
this.showSnackbar(
`Configuration test completed: ${messages.join(", ")}`,
"success",
);
} else {
this.showSnackbar(
"Configuration test failed: " + data.message,
"error",
);
}
} catch (error) {
this.showSnackbar(
"An error occurred while configuring the test",
"error",
);
console.error("Test config error:", error);
}
}
detectSteamPath() {
const commonPaths = [
"C:\\Program Files (x86)\\Steam",
"C:\\Program Files\\Steam",
"D:\\Steam",
"E:\\Steam",
];
const suggestedPath = commonPaths[0];
document.getElementById("steamPath").value = suggestedPath;
this.validateSteamPath();
this.showSnackbar(
"It has been set as a common path, please confirm whether it is correct",
"info",
);
}
validateSteamPath() {
const steamPath = document.getElementById("steamPath").value.trim();
const statusElement = document.getElementById("steamPathStatus");
if (!steamPath) {
statusElement.className = "status-indicator";
statusElement.innerHTML = `
<span class="material-icons status-icon">info</span>
<span class="status-text">The automatically detected path will be used</span>
`;
} else {
if (steamPath.toLowerCase().includes("steam")) {
statusElement.className = "status-indicator success";
statusElement.innerHTML = `
<span class="material-icons status-icon">check_circle</span>
<span class="status-text">The path format looks correct</span>
`;
} else {
statusElement.className = "status-indicator warning";
statusElement.innerHTML = `
<span class="material-icons status-icon">warning</span>
<span class="status-text">The path may be incorrect, please confirm</span>
`;
}
}
}
updateConfigStatus() {
const statusGrid = document.getElementById("configStatusGrid");
const config = this.currentConfig;
const statusCards = [];
if (config.steam_path && config.steam_path_exists) {
statusCards.push({
type: "success",
icon: "folder",
title: "Steam path",
description: `Path is valid: ${config.steam_path}`,
});
} else if (config.steam_path) {
statusCards.push({
type: "warning",
icon: "folder_off",
title: "Steam path",
description: "Path is set but may be invalid",
});
} else {
statusCards.push({
type: "error",
icon: "error",
title: "Steam path",
description: "Not set or auto-detection failed",
});
}
if (config.debug_mode) {
statusCards.push({
type: "warning",
icon: "bug_report",
title: "Debug mode",
description: "Enabled, detailed logs will be output",
});
}
if (config.logging_files) {
statusCards.push({
type: "success",
icon: "description",
title: "Log file",
description: "Enabled, logs will be saved to file",
});
}
statusGrid.innerHTML = statusCards
.map(
(card) => `
<div class="status-card ${card.type}">
<span class="material-icons status-card-icon">${card.icon}</span>
<div class="status-card-content">
<div class="status-card-title">${card.title}</div>
<div class="status-card-description">${card.description}</div>
</div>
</div>
`,
)
.join("");
}
showConfirmDialog(title, message, confirmAction) {
document.getElementById("dialogTitle").textContent = title;
document.getElementById("dialogMessage").textContent = message;
this.confirmAction = confirmAction;
const dialog = document.getElementById("confirmDialog");
dialog.classList.add("show");
}
hideConfirmDialog() {
const dialog = document.getElementById("confirmDialog");
dialog.classList.remove("show");
this.confirmAction = null;
}
executeConfirmAction() {
if (this.confirmAction) {
this.confirmAction();
}
this.hideConfirmDialog();
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type}`;
snackbar.offsetHeight;
snackbar.classList.add("show");
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
}
function goBack() {
window.location.href = "/";
}
document.addEventListener("DOMContentLoaded", () => {
new SettingsManager();
});

178
web/en/static/js/theme.js Normal file
View File

@@ -0,0 +1,178 @@
class ThemeManager {
constructor() {
this.themeToggle = document.getElementById("themeToggle");
this.currentTheme = this.getStoredTheme() || this.getPreferredTheme();
this.isTransitioning = false;
this.createThemeIndicator();
this.applyTheme(this.currentTheme, false);
this.initializeEventListeners();
}
getStoredTheme() {
return localStorage.getItem("theme");
}
getPreferredTheme() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
createThemeIndicator() {
const indicator = document.createElement("div");
indicator.className = "theme-indicator";
indicator.id = "themeIndicator";
document.body.appendChild(indicator);
this.themeIndicator = indicator;
}
showThemeIndicator(message) {
this.themeIndicator.textContent = message;
this.themeIndicator.classList.add("show");
setTimeout(() => {
this.themeIndicator.classList.remove("show");
}, 2000);
}
applyTheme(theme, animate = true) {
if (this.isTransitioning) return;
this.isTransitioning = true;
if (animate) {
document.body.classList.add("theme-transitioning");
}
document.documentElement.setAttribute("data-theme", theme);
this.updateToggleButton(theme);
localStorage.setItem("theme", theme);
this.currentTheme = theme;
window.dispatchEvent(
new CustomEvent("themechange", {
detail: { theme, animated: animate },
}),
);
setTimeout(() => {
document.body.classList.remove("theme-transitioning");
this.isTransitioning = false;
}, 600);
}
updateToggleButton(theme) {
if (this.themeToggle) {
const icon = this.themeToggle.querySelector(".material-icons");
icon.textContent = theme === "dark" ? "dark_mode" : "light_mode";
this.themeToggle.title =
theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
icon.style.animation = "none";
icon.offsetHeight;
icon.style.animation = "iconRotate 300ms ease";
}
}
toggleTheme(event) {
const newTheme = this.currentTheme === "dark" ? "light" : "dark";
if (event && event.currentTarget) {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((rect.left + rect.width / 2) / window.innerWidth) * 100;
const y = ((rect.top + rect.height / 2) / window.innerHeight) * 100;
document.documentElement.style.setProperty("--x", `${x}%`);
document.documentElement.style.setProperty("--y", `${y}%`);
}
if ("vibrate" in navigator) {
navigator.vibrate(50);
}
this.applyTheme(newTheme);
this.logThemeSwitch(newTheme);
}
logThemeSwitch(theme) {
console.log(`Topic switch to: ${theme}`);
}
initializeEventListeners() {
if (this.themeToggle) {
this.themeToggle.addEventListener("click", (e) => this.toggleTheme(e));
this.themeToggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.toggleTheme(e);
}
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", (e) => {
if (!this.getStoredTheme()) {
this.applyTheme(e.matches ? "dark" : "light");
}
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "T") {
e.preventDefault();
this.toggleTheme();
}
});
window.addEventListener("storage", (e) => {
if (e.key === "theme" && e.newValue) {
this.applyTheme(e.newValue, false);
}
});
}
getThemePalette() {
const computedStyle = getComputedStyle(document.documentElement);
return {
primary: computedStyle.getPropertyValue("--md-sys-color-primary").trim(),
secondary: computedStyle
.getPropertyValue("--md-sys-color-secondary")
.trim(),
surface: computedStyle.getPropertyValue("--md-sys-color-surface").trim(),
background: computedStyle
.getPropertyValue("--md-sys-color-background")
.trim(),
onBackground: computedStyle
.getPropertyValue("--md-sys-color-on-background")
.trim(),
};
}
shouldUseDarkMode() {
const hour = new Date().getHours();
return hour >= 18 || hour < 6;
}
enableAutoThemeSwitch() {
const checkTime = () => {
if (!this.getStoredTheme()) {
const shouldBeDark = this.shouldUseDarkMode();
const currentTheme = this.currentTheme;
if (
(shouldBeDark && currentTheme === "light") ||
(!shouldBeDark && currentTheme === "dark")
) {
this.applyTheme(shouldBeDark ? "dark" : "light");
}
}
};
setInterval(checkTime, 60000);
checkTime();
}
}
window.ThemeManager = new ThemeManager();

172
web/en/templates/about.html Normal file
View File

@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - About</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<div class="footer-content">
<!-- Project Info Card -->
<div class="project-info-card">
<div class="project-header">
<div class="project-logo">
<span class="material-icons">extension</span>
</div>
<div class="project-details">
<h3 class="project-name">Onekey</h3>
<p class="project-subtitle">
Intuitive, elegant game unlock solution
</p>
</div>
<div class="project-version">
<span class="version-label">v2.1.2</span>
<span class="version-type">Web UI</span>
</div>
</div>
<div class="project-links">
<a
href="https://github.com/ikunshare/Onekey"
target="_blank"
class="project-link github"
>
<span class="material-icons">code</span>
<div class="link-content">
<span class="link-title">GitHub Repository</span>
<span class="link-url">github.com/ikunshare/Onekey</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/releases"
target="_blank"
class="project-link releases"
>
<span class="material-icons">file_download</span>
<div class="link-content">
<span class="link-title">Download Release</span>
<span class="link-url">Get Latest Version</span>
</div>
</a>
<a
href="https://shop.ikunshare.com"
target="_blank"
class="project-link buy_cdk"
>
<span class="material-icons">shopping_cart</span>
<div class="link-content">
<span class="link-title">Purchase Link</span>
<span class="link-url">Buy Activation Key</span>
</div>
</a>
<a
href="https://github.com/qwq-xinkeng"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">Author Page</span>
<span class="link-url">github.com/qwq-xinkeng</span>
</div>
</a>
<a
href="https://github.com/ikun0014"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">Author Page</span>
<span class="link-url">github.com/ikun0014</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/issues"
target="_blank"
class="project-link issues"
>
<span class="material-icons">bug_report</span>
<div class="link-content">
<span class="link-title">Issue Feedback</span>
<span class="link-url">Report Bugs or Suggestions</span>
</div>
</a>
</div>
</div>
<!-- Technical Information -->
<div class="tech-info">
<div class="tech-header">
<span class="material-icons">code</span>
<h4>Technical Information</h4>
</div>
<div class="tech-content">
<div class="tech-item">
<strong>🐍 Backend Technology</strong>
<span>Python 3.8+ • FastAPI • AsyncIO • AIOHTTP</span>
</div>
<div class="tech-item">
<strong>🌐 Frontend Technology</strong>
<span>HTML5 • CSS3 • JavaScript ES6+ • Material Design 3.0</span>
</div>
<div class="tech-item">
<strong>🔧 Supported Tools</strong>
<span>SteamTools • GreenLuma</span>
</div>
</div>
</div>
<!-- Usage Notice -->
<div class="usage-notice">
<div class="notice-header">
<span class="material-icons">info</span>
<h4>Usage Notice</h4>
</div>
<div class="notice-content">
<p>
<strong>🖥️ System Requirements</strong> - Please ensure Windows 10/11
is installed and Steam client is properly configured
</p>
<p>
<strong>🛠️ Tool Preparation</strong> - Please install SteamTools or
GreenLuma unlock tool before use
</p>
<p>
<strong>🔒 Disclaimer</strong> - This tool is for educational and
communication purposes only, users assume all related risks
</p>
<p>
<strong>⭐ Support Project</strong> - If this tool helps you, feel
free to star the project on GitHub
</p>
</div>
</div>
<!-- Copyright Information -->
<div class="copyright">
<p>© 2025 Onekey Steam Unlock Tool • Authors: qwq-xinkeng && ikun0014</p>
<p>
Project URL:
<a href="https://github.com/ikunshare/Onekey" target="_blank"
>https://github.com/ikunshare/Onekey</a
>
</p>
</div>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</div>
</html>

180
web/en/templates/index.html Normal file
View File

@@ -0,0 +1,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - Home</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="app-container">
<!-- Top App Bar -->
<header class="app-bar">
<div class="app-bar-content">
<span class="material-icons app-icon">games</span>
<h1 class="app-title">Onekey</h1>
<button
type="button"
class="theme-toggle"
id="themeToggle"
title="Toggle Theme"
>
<span class="material-icons">light_mode</span>
</button>
<a href="/settings" class="btn btn-text settings-link">
<span class="material-icons">settings</span>
<span class="settings-text">Settings</span>
</a>
<a href="/about" class="btn btn-text about-link">
<span class="material-icons">info</span>
<span class="about-text">About</span>
</a>
</div>
</header>
<!-- Main Content Area -->
<main class="main-content">
<!-- Configuration Status Card -->
<div class="card config-card">
<div class="card-header">
<span class="material-icons">settings</span>
<h2>Configuration Status</h2>
</div>
<div class="card-content">
<div class="config-status" id="configStatus">
<div class="loading">Checking configuration...</div>
</div>
</div>
</div>
<!-- Game Unlock Card -->
<div class="card unlock-card">
<div class="card-header">
<span class="material-icons">lock_open</span>
<h2>Game Unlock</h2>
</div>
<div class="card-content">
<form id="unlockForm" class="unlock-form">
<div class="input-group">
<label for="appId" class="input-label">Steam App ID</label>
<input
type="text"
id="appId"
name="appId"
class="text-field"
placeholder="Enter game App ID"
inputmode="numeric"
autocomplete="off"
autofocus
required
/>
<div class="input-feedback" id="appIdFeedback"></div>
<div class="input-helper">Example: 730 (CS2), 570 (Dota 2)</div>
</div>
<div class="input-group">
<label class="input-label">Unlock Tool</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
name="toolType"
value="steamtools"
checked
/>
<span class="radio-button"></span>
<span class="radio-label"
>SteamTools (actively updated, recommended)</span
>
</label>
<label class="radio-item">
<input type="radio" name="toolType" value="greenluma" />
<span class="radio-button"></span>
<span class="radio-label"
>GreenLuma (updated yearly, no GUI)</span
>
</label>
</div>
</div>
<div class="input-group" id="+DLCGroup">
<label class="checkbox-item">
<input type="checkbox" id="+DLC" name="+DLC" />
<span class="checkbox-button"></span>
<span class="checkbox-label"
>Retrieve and include all DLCs</span
>
</label>
<div class="input-helper">
Note: Some DLC depots are bundled with the base game and not
separated
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary" id="unlockBtn">
<span class="material-icons">play_arrow</span>
Start Unlock
</button>
<button type="button" class="btn btn-secondary" id="resetBtn">
<span class="material-icons">refresh</span>
Reset
</button>
</div>
</form>
</div>
</div>
<!-- Progress Log Card -->
<div class="card progress-card">
<div class="card-header">
<span class="material-icons">timeline</span>
<h2>Execution Log</h2>
<div class="card-actions">
<button class="btn btn-text" id="clearLogBtn">
<span class="material-icons">clear_all</span>
Clear
</button>
</div>
</div>
<div class="card-content">
<div class="progress-container" id="progressContainer">
<div class="progress-placeholder">
<span class="material-icons">info</span>
<p>Waiting for task to start...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Snackbar -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', path='js/app.js') }}"></script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
<script src="{{ url_for('static', path='js/project-info.js') }}"></script>
</body>
</html>

501
web/en/templates/oobe.html Normal file
View File

@@ -0,0 +1,501 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - First Time Setup Wizard</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="oobe-container">
<!-- Top App Bar -->
<div class="oobe-card">
<div class="oobe-header">
<button
type="button"
class="theme-toggle"
id="themeToggle"
title="Toggle Theme"
>
<span class="material-icons">light_mode</span>
</button>
<div class="oobe-logo">
<span class="material-icons" style="font-size: inherit"
>extension</span
>
</div>
<h1 class="oobe-title">Welcome to Onekey</h1>
<p class="oobe-subtitle">One-click unlock, enjoy gaming experience</p>
</div>
<div class="oobe-content">
<div class="step-indicator">
<div class="step-dot active" data-step="0"></div>
<div class="step-dot" data-step="1"></div>
<div class="step-dot" data-step="2"></div>
</div>
<!-- Step 1: Welcome -->
<div class="oobe-step active" data-step="0">
<div class="welcome-text">
<h3>🎮 Welcome to the Onekey World</h3>
<p>
Onekey is a powerful Steam game unlock tool that helps you
easily manage and unlock games.
</p>
<p>
Before getting started, we need to verify your activation key.
</p>
<p><strong>Features:</strong></p>
<p>• Supports both SteamTools and GreenLuma unlock methods</p>
<p>• Intuitive web interface, simple operation</p>
<p>• Real-time log display, transparent process</p>
<p>
• Frontend code completely open source, absolutely no account
theft/mining
</p>
<a href="https://shop.ikunshare.com" target="_blank"
>• Click here to purchase activation key</a
>
</div>
</div>
<!-- Step 2: Key Verification -->
<div class="oobe-step" data-step="1">
<div class="welcome-text">
<h3>🔑 Activate Your Key</h3>
<p>
Please enter your activation key to activate the Onekey tool.
</p>
</div>
<div class="key-input-section">
<div class="input-group">
<label for="activationKey" class="input-label"
>Activation Key</label
>
<input
type="text"
id="activationKey"
class="text-field"
placeholder="Please enter your key"
autocomplete="off"
/>
<div class="input-helper">
Key format: [PREFIX]_XXXXXXXX-XXXXXXXXXXXXXXXX
</div>
</div>
<div class="key-status" id="keyStatus">
<div class="status-header">
<span class="material-icons" id="statusIcon">info</span>
<span id="statusMessage">Verifying...</span>
</div>
<div class="key-info" id="keyInfo"></div>
</div>
</div>
</div>
<!-- Step 4: Complete -->
<div class="oobe-step" data-step="3">
<div class="welcome-text">
<h3>🎉 Setup Complete</h3>
<p>
Congratulations! You have successfully activated the Onekey
tool.
</p>
<p>Now you can start using all features.</p>
<div
class="key-info"
id="finalKeyInfo"
style="margin-top: 24px"
></div>
</div>
</div>
<div class="oobe-actions">
<button
type="button"
id="prevBtn"
class="btn btn-text"
style="display: none"
>
<span class="material-icons">arrow_back</span>
Previous
</button>
<button
type="button"
id="nextBtn"
class="btn btn-primary btn-large"
>
<span class="material-icons">arrow_forward</span>
Next
</button>
<button
type="button"
id="verifyBtn"
class="btn btn-primary btn-large"
style="display: none"
>
<span class="material-icons">verified</span>
Verify Key
</button>
<button
type="button"
id="finishBtn"
class="btn btn-primary btn-large"
style="display: none"
>
<span class="material-icons">check</span>
Start Using
</button>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<!-- Snackbar -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<script>
class OOBEManager {
constructor() {
this.currentStep = 0;
this.totalSteps = 3;
this.keyData = null;
this.initializeEventListeners();
this.updateStepDisplay();
}
initializeEventListeners() {
document.getElementById("nextBtn").addEventListener("click", () => {
this.nextStep();
});
document.getElementById("prevBtn").addEventListener("click", () => {
this.prevStep();
});
document.getElementById("verifyBtn").addEventListener("click", () => {
this.verifyKey();
});
document.getElementById("finishBtn").addEventListener("click", () => {
this.finishSetup();
});
document
.getElementById("activationKey")
.addEventListener("input", () => {
this.resetKeyStatus();
});
document
.getElementById("activationKey")
.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
this.verifyKey();
}
});
document
.getElementById("snackbarClose")
.addEventListener("click", () => {
this.hideSnackbar();
});
}
nextStep() {
if (this.currentStep < this.totalSteps - 1) {
this.currentStep++;
this.updateStepDisplay();
}
}
prevStep() {
if (this.currentStep > 0) {
this.currentStep--;
this.updateStepDisplay();
}
}
updateStepDisplay() {
document.querySelectorAll(".step-dot").forEach((dot, index) => {
dot.classList.remove("active", "completed");
if (index < this.currentStep) {
dot.classList.add("completed");
} else if (index === this.currentStep) {
dot.classList.add("active");
}
});
document.querySelectorAll(".oobe-step").forEach((step, index) => {
step.classList.toggle("active", index === this.currentStep);
});
this.updateButtons();
}
updateButtons() {
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const verifyBtn = document.getElementById("verifyBtn");
const finishBtn = document.getElementById("finishBtn");
[prevBtn, nextBtn, verifyBtn, finishBtn].forEach((btn) => {
btn.style.display = "none";
});
if (this.currentStep > 0) {
prevBtn.style.display = "flex";
}
switch (this.currentStep) {
case 0:
nextBtn.style.display = "flex";
break;
case 1:
verifyBtn.style.display = "flex";
break;
case 2:
finishBtn.style.display = "flex";
break;
}
}
resetKeyStatus() {
const keyStatus = document.getElementById("keyStatus");
keyStatus.classList.remove("show", "success", "error");
}
async verifyKey() {
const keyInput = document.getElementById("activationKey");
const key = keyInput.value.trim();
if (!key) {
this.showSnackbar("Please enter the key", "error");
return;
}
if (!key.match(/^[A-Z0-9_-]+$/)) {
this.showKeyStatus("error", "Invalid key format", "error");
return;
}
this.showLoading(true);
try {
const response = await fetch("/api/getKeyInfo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: key }),
});
const data = await response.json();
if (data.key && data.info) {
this.keyData = data.info;
this.showKeyStatus(
"success",
"Key verified successfully!",
"check_circle",
);
this.displayKeyInfo(data.info);
setTimeout(() => {
this.nextStep();
this.showFinalKeyInfo(data.info);
}, 2000);
} else {
this.showKeyStatus(
"error",
data.message || "Key does not exist or has expired",
"error",
);
}
} catch (error) {
this.showKeyStatus(
"error",
"Verification failed, please check network connection",
"error",
);
console.error("Key verification error:", error);
} finally {
this.showLoading(false);
}
}
showKeyStatus(type, message, icon) {
const keyStatus = document.getElementById("keyStatus");
const statusIcon = document.getElementById("statusIcon");
const statusMessage = document.getElementById("statusMessage");
statusIcon.textContent = icon;
statusMessage.textContent = message;
keyStatus.className = `key-status show ${type}`;
}
displayKeyInfo(keyInfo) {
const keyInfoContainer = document.getElementById("keyInfo");
const expiresAt = new Date(keyInfo.expiresAt);
const isExpired = expiresAt < new Date();
const typeNames = {
day: "Daily",
week: "Weekly",
month: "Monthly",
year: "Yearly",
permanent: "Permanent",
};
keyInfoContainer.innerHTML = `
<div class="key-info-item">
<span class="material-icons">label</span>
<span>Type: ${typeNames[keyInfo.type] || keyInfo.type}</span>
</div>
<div class="key-info-item">
<span class="material-icons">schedule</span>
<span>Expires: ${expiresAt.toLocaleDateString()}</span>
</div>
<div class="key-info-item">
<span class="material-icons">analytics</span>
<span>Usage Count: ${keyInfo.usageCount}</span>
</div>
<div class="key-info-item">
<span class="material-icons">${keyInfo.isActive && !isExpired ? "check_circle" : "cancel"}</span>
<span>Status: ${keyInfo.isActive && !isExpired ? "Valid" : "Invalid"}</span>
</div>
`;
}
showFinalKeyInfo(keyInfo) {
const finalKeyInfo = document.getElementById("finalKeyInfo");
const expiresAt = new Date(keyInfo.expiresAt);
const typeNames = {
day: "Daily",
week: "Weekly",
month: "Monthly",
year: "Yearly",
permanent: "Permanent",
};
finalKeyInfo.innerHTML = `
<div class="key-info-item">
<span class="material-icons">verified_user</span>
<span><strong>Key Type: </strong>${typeNames[keyInfo.type] || keyInfo.type}</span>
</div>
<div class="key-info-item">
<span class="material-icons">event</span>
<span><strong>Valid Until: </strong>${expiresAt.toLocaleDateString()} ${expiresAt.toLocaleTimeString()}</span>
</div>
`;
}
async finishSetup() {
if (!this.keyData) {
this.showSnackbar("Key data lost, please verify again", "error");
this.currentStep = 1;
this.updateStepDisplay();
return;
}
this.showLoading(true);
try {
const response = await fetch("/api/config/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: document.getElementById("activationKey").value.trim(),
steam_path: "",
debug_mode: false,
logging_files: true,
show_console: false,
}),
});
const data = await response.json();
if (data.success) {
this.showSnackbar(
"Configuration saved successfully, redirecting...",
"success",
);
setTimeout(() => {
window.location.href = "/";
}, 1500);
} else {
throw new Error(data.message || "Failed to save configuration");
}
} catch (error) {
this.showSnackbar(
"Failed to save configuration: " + error.message,
"error",
);
console.error("Save config error:", error);
} finally {
this.showLoading(false);
}
}
showLoading(show) {
const overlay = document.getElementById("loadingOverlay");
overlay.classList.toggle("show", show);
}
showSnackbar(message, type = "info") {
const snackbar = document.getElementById("snackbar");
const snackbarMessage = document.getElementById("snackbarMessage");
snackbarMessage.textContent = message;
snackbar.className = `snackbar ${type} show`;
setTimeout(() => {
this.hideSnackbar();
}, 4000);
}
hideSnackbar() {
const snackbar = document.getElementById("snackbar");
snackbar.classList.remove("show");
}
}
document.addEventListener("DOMContentLoaded", () => {
new OOBEManager();
});
</script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,292 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - Settings</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- Custom Styles -->
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="stylesheet" href="/static/css/settings.css" />
</head>
<body>
<div class="app-container">
<!-- Top App Bar -->
<header class="app-bar">
<div class="app-bar-content">
<button class="btn btn-text" onclick="goBack()">
<span class="material-icons">arrow_back</span>
</button>
<span class="material-icons app-icon">settings</span>
<h1 class="app-title">Settings</h1>
</div>
</header>
<!-- Main Content Area -->
<main class="main-content settings-main">
<!-- Key Management Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">verified</span>
<h2>Key Management</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div id="keyInfoSection">
<div class="loading">Loading key information...</div>
</div>
<div class="key-change-section">
<h4
style="
margin: 0 0 16px 0;
color: var(--md-sys-color-on-surface);
"
>
<span
class="material-icons"
style="vertical-align: middle; margin-right: 8px"
>swap_horiz</span
>
Change Key
</h4>
<div class="key-input-group">
<div class="input-group" style="flex: 1; margin: 0">
<label for="newKey" class="input-label">New Key</label>
<input
type="text"
id="newKey"
class="text-field"
placeholder="Please enter new key"
autocomplete="off"
/>
<div class="input-helper">
Format: [PREFIX]_XXXXXXXX-XXXXXXXXXXXXXXXX
</div>
</div>
<button
type="button"
id="verifyNewKey"
class="btn btn-secondary"
>
<span class="material-icons">check</span>
Verify
</button>
<button
type="button"
id="changeKey"
class="btn btn-primary"
style="display: none"
>
<span class="material-icons">save</span>
Save
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Steam Configuration Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">games</span>
<h2>Steam Configuration</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div class="input-group">
<label for="steamPath" class="input-label"
>Steam Installation Path</label
>
<div class="path-input-group">
<input
type="text"
id="steamPath"
class="text-field"
placeholder="Leave blank for auto-detect, or manually enter Steam installation path"
/>
<button
type="button"
id="detectSteamPath"
class="btn btn-secondary"
>
<span class="material-icons">search</span>
Auto Detect
</button>
</div>
<div class="input-helper">
The program will attempt to automatically detect the Steam
installation path. If detection fails, please enter it
manually. Usually located at: C:\Program Files (x86)\Steam
</div>
</div>
<div class="status-indicator" id="steamPathStatus">
<span class="material-icons status-icon">info</span>
<span class="status-text">Waiting for detection...</span>
</div>
</div>
</div>
</div>
<!-- Application Configuration Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">tune</span>
<h2>Application Configuration</h2>
</div>
<div class="card-content">
<div class="settings-section">
<div class="setting-item">
<label class="setting-label">Language Selection</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
name="language"
value="zh"
id="language-zh"
/>
<span class="radio-button"></span>
<span class="radio-label">Simplified Chinese</span>
</label>
<label class="radio-item">
<input
type="radio"
name="language"
value="en"
id="language-en"
checked
/>
<span class="radio-button"></span>
<span class="radio-label">English</span>
</label>
</div>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="debugMode" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">Debug Mode</span>
<span class="checkbox-description"
>Enable detailed debug log output</span
>
</div>
</label>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="loggingFiles" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">Save Log Files</span>
<span class="checkbox-description"
>Save logs to files for troubleshooting</span
>
</div>
</label>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="showConsole" />
<span class="checkbox-button"></span>
<div class="checkbox-content">
<span class="checkbox-label">Show Console Window</span>
<span class="checkbox-description"
>Display console window and log output on startup</span
>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Action Buttons Card -->
<div class="card">
<div class="card-content">
<div class="action-buttons">
<button type="button" id="saveConfig" class="btn btn-primary">
<span class="material-icons">save</span>
Save Configuration
</button>
<button type="button" id="resetConfig" class="btn btn-secondary">
<span class="material-icons">restore</span>
Reset to Default
</button>
<button type="button" id="testConfig" class="btn btn-secondary">
<span class="material-icons">check_circle</span>
Test Configuration
</button>
</div>
</div>
</div>
<!-- Configuration Information Display Card -->
<div class="card">
<div class="card-header">
<span class="material-icons">info</span>
<h2>Configuration Status</h2>
</div>
<div class="card-content">
<div class="config-status-grid" id="configStatusGrid">
<div class="loading">Loading configuration status...</div>
</div>
</div>
</div>
</main>
</div>
<!-- Confirmation Dialog -->
<div id="confirmDialog" class="dialog-overlay">
<div class="dialog">
<div class="dialog-header">
<h3 id="dialogTitle">Confirm Action</h3>
</div>
<div class="dialog-content">
<p id="dialogMessage">
Are you sure you want to perform this action?
</p>
</div>
<div class="dialog-actions">
<button type="button" id="dialogCancel" class="btn btn-text">
Cancel
</button>
<button type="button" id="dialogConfirm" class="btn btn-primary">
Confirm
</button>
</div>
</div>
</div>
<!-- Snackbar -->
<div id="snackbar" class="snackbar">
<div class="snackbar-content">
<span id="snackbarMessage"></span>
<button id="snackbarClose" class="snackbar-action">
<span class="material-icons">close</span>
</button>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', path='js/settings.js') }}"></script>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
<script src="{{ url_for('static', path='js/project-info.js') }}"></script>
</body>
</html>

View File

@@ -1,143 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - About</title>
<!-- Material Design 3 -->
<link href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/style.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<!-- 自定义样式 -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<div class="footer-content">
<!-- 项目介绍卡片 -->
<div class="project-info-card">
<div class="project-header">
<div class="project-logo">
<span class="material-icons">extension</span>
</div>
<div class="project-details">
<h3 class="project-name">Onekey</h3>
<p class="project-subtitle">直观,优雅的游戏解锁解决方案</p>
</div>
<div class="project-version">
<span class="version-label">v2.0.5</span>
<span class="version-type">Web UI</span>
</div>
</div>
<div class="project-links">
<a href="https://github.com/ikunshare/Onekey" target="_blank" class="project-link github">
<span class="material-icons">code</span>
<div class="link-content">
<span class="link-title">GitHub 仓库</span>
<span class="link-url">github.com/ikunshare/Onekey</span>
</div>
</a>
<a href="https://github.com/ikunshare/Onekey/releases" target="_blank" class="project-link releases">
<span class="material-icons">file_download</span>
<div class="link-content">
<span class="link-title">下载发布版</span>
<span class="link-url">获取最新版本</span>
</div>
</a>
<a href="http://192.140.166.230:911" target="_blank" class="project-link buy_cdk">
<span class="material-icons">buy</span>
<div class="link-content">
<span class="link-title">购卡链接</span>
<span class="link-url">购买卡密</span>
</div>
</a>
<a href="https://github.com/qwq-xinkeng" target="_blank" class="project-link author">
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">作者主页</span>
<span class="link-url">github.com/qwq-xinkeng</span>
</div>
</a>
<a href="https://github.com/ikun0014" target="_blank" class="project-link author">
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">作者主页</span>
<span class="link-url">github.com/ikun0014</span>
</div>
</a>
<a href="https://github.com/ikunshare/Onekey/issues" target="_blank" class="project-link issues">
<span class="material-icons">bug_report</span>
<div class="link-content">
<span class="link-title">问题反馈</span>
<span class="link-url">报告Bug或建议</span>
</div>
</a>
</div>
</div>
<!-- 技术信息 -->
<div class="tech-info">
<div class="tech-header">
<span class="material-icons">code</span>
<h4>技术信息</h4>
</div>
<div class="tech-content">
<div class="tech-item">
<strong>🐍 后端技术</strong>
<span>Python 3.8+ • FastAPI • AsyncIO • HTTPX</span>
</div>
<div class="tech-item">
<strong>🌐 前端技术</strong>
<span>HTML5 • CSS3 • JavaScript ES6+ • Material Design 3.0</span>
</div>
<div class="tech-item">
<strong>🔧 支持工具</strong>
<span>SteamTools • GreenLuma</span>
</div>
</div>
</div>
<!-- 使用须知 -->
<div class="usage-notice">
<div class="notice-header">
<span class="material-icons">info</span>
<h4>使用须知</h4>
</div>
<div class="notice-content">
<p>
<strong>⚠️ 重要提醒</strong> -
本工具完全免费,严禁任何形式的商业化使用或倒卖
</p>
<p>
<strong>🖥️ 系统要求</strong> - 请确保已安装Windows
10/11并正确配置Steam客户端
</p>
<p>
<strong>🛠️ 工具准备</strong> -
使用前请先安装SteamTools或GreenLuma解锁工具
</p>
<p>
<strong>🔒 免责声明</strong> -
本工具仅供学习交流使用,使用者需自行承担相关风险
</p>
<p>
<strong>⭐ 支持项目</strong> -
如果此工具对您有帮助欢迎在GitHub上给项目点Star支持
</p>
</div>
</div>
<!-- 版权信息 -->
<div class="copyright">
<p>© 2025 Onekey Steam解锁工具 • 作者qwq-xinkeng && ikun0014</p>
<p>
项目地址:<a href="https://github.com/ikunshare/Onekey" target="_blank">https://github.com/ikunshare/Onekey</a>
</p>
</div>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</div>
</html>

View File

@@ -0,0 +1,277 @@
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideInUp {
from {
transform: translateY(60px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes subtle-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.05);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes shimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-5px);
}
20%,
40%,
60%,
80% {
transform: translateX(5px);
}
}
@keyframes success-pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
}
}
@keyframes loading-bounce {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.loading {
display: flex;
align-items: center;
gap: 12px;
color: var(--md-sys-color-on-surface-variant);
}
.loading::before {
content: "";
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-primary-container);
border-top: 2px solid var(--md-sys-color-primary);
border-radius: 50%;
animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
}
.loading-dots {
display: inline-flex;
gap: 4px;
}
.loading-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--md-sys-color-primary);
animation: loading-bounce 1.4s ease-in-out infinite;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.skeleton-loader {
background: linear-gradient(
90deg,
var(--md-sys-color-surface-container) 25%,
var(--md-sys-color-surface-container-high) 50%,
var(--md-sys-color-surface-container) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.error-state {
animation: shake 0.5s ease-in-out;
}
.success-state {
animation: success-pulse 0.5s ease-out;
}
.ripple {
position: relative;
overflow: hidden;
}
.ripple::before {
content: "";
position: absolute;
top: var(--ripple-y, 50%);
left: var(--ripple-x, 50%);
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition:
width 0.6s ease,
height 0.6s ease,
opacity 0.6s ease;
opacity: 0;
}
.ripple:active::before {
width: 300px;
height: 300px;
opacity: 0.3;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.animate-fadeIn {
animation: fadeIn var(--transition-medium) ease;
}
.animate-slideUp {
animation: slideUp var(--transition-medium) ease-out;
}
.animate-slideIn {
animation: slideIn var(--transition-medium) ease-out;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}

165
web/zh/static/css/base.css Normal file
View File

@@ -0,0 +1,165 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
transition:
background-color var(--transition-medium) ease,
color var(--transition-medium) ease,
border-color var(--transition-medium) ease;
}
:root {
transition:
background-color 0.3s ease,
color 0.3s ease;
}
body {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
line-height: 1.5;
min-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
body::before {
content: "";
position: fixed;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background:
radial-gradient(
circle at 30% 80%,
rgba(103, 80, 164, 0.05) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(0, 188, 212, 0.05) 0%,
transparent 50%
);
pointer-events: none;
z-index: 0;
}
[data-theme="dark"] body::before {
background:
radial-gradient(
circle at 30% 80%,
rgba(208, 188, 255, 0.03) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(77, 208, 225, 0.03) 0%,
transparent 50%
);
}
[data-theme="dark"] {
color-scheme: dark;
}
:focus-visible {
outline: 2px solid var(--md-sys-color-primary);
outline-offset: 2px;
}
::selection {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
[data-theme="dark"] ::selection {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
border-radius: 6px;
}
::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
border-radius: 6px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--gradient-primary);
}
[data-theme="dark"]::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
}
[data-theme="dark"]::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
}
[data-theme="dark"]::-webkit-scrollbar-thumb:hover {
background: var(--gradient-primary);
}
input::placeholder {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-left: 4px;
}
[data-theme="dark"] input:-webkit-autofill,
[data-theme="dark"] input:-webkit-autofill:hover,
[data-theme="dark"] input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--md-sys-color-on-surface);
-webkit-box-shadow: 0 0 0px 1000px var(--md-sys-color-surface-container) inset;
transition: background-color 5000s ease-in-out 0s;
}
.btn,
.card,
.theme-toggle {
will-change: transform;
}
.btn:not(:hover),
.card:not(:hover),
.theme-toggle:not(:hover) {
will-change: auto;
}
button:active,
.btn:active,
.card:active {
transform: scale(0.98);
}
* {
transition:
background-color var(--transition-medium) ease,
color var(--transition-medium) ease,
border-color var(--transition-medium) ease,
box-shadow var(--transition-medium) ease;
}

View File

@@ -0,0 +1,459 @@
.card {
background: var(--md-sys-color-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--md-sys-shape-corner-large);
box-shadow: var(--md-sys-elevation-level1);
border: 1px solid var(--md-sys-color-outline-variant);
overflow: hidden;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
--hover-scale: 1.02;
--hover-shadow: var(--md-sys-elevation-level3);
animation: slideUp 0.6s ease-out backwards;
}
.card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gradient-primary);
opacity: 0;
transition: opacity var(--transition-medium) ease;
}
.card:hover {
transform: translateY(-2px) scale(var(--hover-scale, 1));
box-shadow: var(--hover-shadow);
}
.card:hover::before {
opacity: 1;
}
.card:nth-child(1) {
animation-delay: 0.1s;
}
.card:nth-child(2) {
animation-delay: 0.2s;
}
.card:nth-child(3) {
animation-delay: 0.3s;
}
.card-header {
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
background: var(--md-sys-color-surface-container-low);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.card-header .material-icons {
font-size: 28px;
color: var(--md-sys-color-primary);
}
.card-header h2 {
font-size: 20px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
flex: 1;
}
.card-content {
padding: 24px;
}
.btn {
padding: 14px 28px;
border-radius: var(--md-sys-shape-corner-full);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
position: relative;
overflow: hidden;
}
.btn::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition:
width var(--transition-slow) ease,
height var(--transition-slow) ease;
}
.btn:active::before {
width: 300px;
height: 300px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
box-shadow: var(--md-sys-elevation-level1);
}
.btn-primary:hover:not(:disabled) {
background: var(--gradient-primary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level3);
}
.btn-secondary {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
border: 1px solid var(--md-sys-color-outline-variant);
}
.btn-secondary:hover:not(:disabled) {
background: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level2);
}
.btn-text {
background-color: transparent;
color: var(--md-sys-color-primary);
padding: 8px 16px;
}
.btn-text:hover:not(:disabled) {
background-color: var(--md-sys-color-primary-container);
}
.button-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-label {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
margin-left: 4px;
}
.text-field {
padding: 16px;
border: 2px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-medium);
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
font-size: 16px;
transition: all var(--transition-fast) ease;
position: relative;
}
.text-field:hover {
border-color: var(--md-sys-color-outline);
background-color: var(--md-sys-color-surface-container);
}
.text-field:focus {
outline: none;
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container);
box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.15);
}
[data-theme="dark"] .text-field:focus {
box-shadow: 0 0 0 3px rgba(208, 188, 255, 0.15);
}
.input-helper {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-left: 4px;
}
.radio-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.radio-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-medium);
transition: background-color var(--transition-fast) ease;
}
.radio-item:hover {
background-color: var(--md-sys-color-primary-container);
}
.radio-item input[type="radio"] {
display: none;
}
.radio-button {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-outline);
border-radius: 50%;
position: relative;
transition: all var(--transition-fast) ease;
}
.radio-button::after {
content: "";
width: 12px;
height: 12px;
background: var(--md-sys-color-primary);
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform var(--transition-fast)
cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.radio-item input[type="radio"]:checked + .radio-button {
border-color: var(--md-sys-color-primary);
}
.radio-item input[type="radio"]:checked + .radio-button::after {
transform: translate(-50%, -50%) scale(1);
}
.checkbox-item {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-medium);
transition: background-color var(--transition-fast) ease;
}
.checkbox-item:hover {
background-color: var(--md-sys-color-primary-container);
}
.checkbox-item input[type="checkbox"] {
display: none;
}
.checkbox-button {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-extra-small);
position: relative;
transition: all var(--transition-fast) ease;
}
.checkbox-button::after {
content: "✓";
color: var(--md-sys-color-on-primary);
font-size: 14px;
font-weight: bold;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform var(--transition-fast)
cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-button {
background: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.checkbox-item input[type="checkbox"]:checked + .checkbox-button::after {
transform: translate(-50%, -50%) scale(1);
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--md-sys-color-surface-container);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast) ease;
color: var(--md-sys-color-on-surface);
}
.theme-toggle:hover {
background: var(--md-sys-color-primary-container);
transform: scale(1.1);
}
.theme-toggle .material-icons {
font-size: 20px;
transition: transform var(--transition-medium) ease;
}
.theme-toggle:active .material-icons {
transform: rotate(180deg);
}
.snackbar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
border-radius: var(--md-sys-shape-corner-medium);
box-shadow: var(--md-sys-elevation-level4);
z-index: 2000;
max-width: 560px;
min-width: 344px;
opacity: 0;
visibility: hidden;
transition: all var(--transition-medium) cubic-bezier(0.4, 0, 0.2, 1);
}
.snackbar.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
visibility: visible;
}
.snackbar.success {
background: var(--md-sys-color-success);
color: var(--md-sys-color-on-success);
}
.snackbar.error {
background: var(--md-sys-color-error);
color: var(--md-sys-color-on-error);
}
.snackbar.warning {
background: var(--md-sys-color-warning);
color: var(--md-sys-color-on-warning);
}
.snackbar.info {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.snackbar-content {
display: flex;
align-items: center;
padding: 16px 20px;
gap: 16px;
}
.snackbar-content span {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.snackbar-action {
background: rgba(255, 255, 255, 0.2);
border: none;
color: inherit;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: all var(--transition-fast) ease;
}
.snackbar-action:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: var(--md-sys-shape-corner-medium);
background: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-fast) ease;
}
.status-item:hover {
transform: translateX(4px);
border-color: var(--md-sys-color-primary);
}
.status-icon {
font-size: 20px;
transition: transform var(--transition-fast) ease;
}
.status-item:hover .status-icon {
transform: scale(1.1);
}
.status-icon.success {
color: var(--md-sys-color-success);
}
.status-icon.error {
color: var(--md-sys-color-error);
}
.status-icon.warning {
color: var(--md-sys-color-warning);
}
.status-text {
flex: 1;
font-size: 14px;
color: var(--md-sys-color-on-surface);
}
@media (prefers-contrast: high) {
.card {
border-width: 2px;
}
.btn {
border: 2px solid currentColor;
}
.text-field {
border-width: 3px;
}
}

View File

@@ -0,0 +1,218 @@
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
padding-bottom: 24px;
position: relative;
z-index: 1;
}
.app-bar {
background: var(--md-sys-color-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--md-sys-elevation-level1);
position: sticky;
top: 0;
z-index: 1000;
transition: all var(--transition-medium) ease;
}
.app-bar:hover {
box-shadow: var(--md-sys-elevation-level2);
}
.app-bar-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.app-icon {
background: var(--gradient-primary);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 32px;
animation: subtle-rotate 20s linear infinite;
}
.app-title {
font-size: 24px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
flex: 1;
}
.app-version {
font-size: 12px;
font-weight: 500;
color: var(--md-sys-color-primary);
background: var(--md-sys-color-primary-container);
padding: 6px 16px;
border-radius: var(--md-sys-shape-corner-full);
border: 1px solid var(--md-sys-color-primary);
}
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
display: grid;
grid-template-columns: 1fr;
gap: 24px;
}
.app-footer {
background: var(--md-sys-color-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
margin-top: 32px;
border-top: 1px solid var(--md-sys-color-outline-variant);
padding-bottom: 24px;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
.copyright {
text-align: center;
padding: 20px 0;
border-top: 1px solid var(--md-sys-color-outline-variant);
margin-top: 32px;
}
.copyright p {
font-size: 13px;
color: var(--md-sys-color-on-surface-variant);
margin: 4px 0;
line-height: 1.5;
}
.copyright a {
color: var(--md-sys-color-primary);
text-decoration: none;
font-weight: 600;
position: relative;
}
.copyright a::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: var(--md-sys-color-primary);
transition: width var(--transition-medium) ease;
}
.copyright a:hover::after {
width: 100%;
}
@media (min-width: 768px) {
.main-content {
grid-template-columns: 1fr 1fr;
}
.progress-card {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
.app-bar-content {
padding: 12px 16px;
}
.app-title {
font-size: 20px;
}
.app-icon {
font-size: 28px;
}
.project-header {
flex-direction: column;
text-align: center;
gap: 16px;
}
.project-version {
align-items: center;
}
.project-links {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.button-group .btn {
width: 100%;
}
.settings-text,
.about-text {
display: none;
}
}
@media (max-width: 480px) {
.project-info-card {
padding: 24px 16px;
}
.tech-info,
.usage-notice {
padding: 16px;
}
.footer-content {
padding: 24px 16px;
}
}
@media print {
.app-bar,
.theme-toggle,
.settings-link,
.about-link,
.btn,
.snackbar {
display: none !important;
}
body {
background: white;
color: black;
}
.card {
box-shadow: none;
border: 1px solid #ccc;
page-break-inside: avoid;
}
:root {
--md-sys-color-background: #ffffff !important;
--md-sys-color-on-background: #000000 !important;
--md-sys-color-surface: #ffffff !important;
--md-sys-color-on-surface: #000000 !important;
}
}

230
web/zh/static/css/oobe.css Normal file
View File

@@ -0,0 +1,230 @@
.oobe-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--md-sys-color-primary-container) 0%,
var(--md-sys-color-secondary-container) 100%
);
padding: 20px;
}
.oobe-card {
max-width: 500px;
width: 100%;
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-extra-large);
box-shadow: var(--md-sys-elevation-level3);
overflow: hidden;
animation: slideInUp 0.6s ease-out;
}
.oobe-header {
background: linear-gradient(
45deg,
var(--md-sys-color-primary),
var(--md-sys-color-tertiary)
);
color: var(--md-sys-color-on-primary);
padding: 40px 32px;
text-align: center;
}
.oobe-logo {
font-size: 64px;
margin-bottom: 16px;
animation: float 3s ease-in-out infinite;
}
.oobe-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
letter-spacing: -0.5px;
}
.oobe-subtitle {
font-size: 16px;
opacity: 0.9;
margin: 0;
}
.oobe-content {
padding: 32px;
}
.oobe-step {
display: none;
animation: fadeIn 0.4s ease-out;
}
.oobe-step.active {
display: block;
}
.step-indicator {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.step-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--md-sys-color-outline);
margin: 0 6px;
transition: all 0.3s ease;
}
.step-dot.active {
background: var(--md-sys-color-primary);
transform: scale(1.2);
}
.step-dot.completed {
background: var(--md-sys-color-tertiary);
}
.welcome-text {
text-align: center;
margin-bottom: 32px;
text-decoration: none;
}
.welcome-text h3 {
color: var(--md-sys-color-on-surface);
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 500;
text-decoration: none;
}
.welcome-text p {
color: var(--md-sys-color-on-surface-variant);
margin: 0 0 12px 0;
line-height: 1.5;
text-decoration: none;
}
.welcome-text a {
color: var(--md-sys-color-on-surface-variant);
margin: 0 0 12px 0;
line-height: 1.5;
text-decoration: none;
}
.key-input-section {
margin-bottom: 24px;
}
.key-status {
margin-top: 16px;
padding: 16px;
border-radius: var(--md-sys-shape-corner-medium);
background: var(--md-sys-color-surface-variant);
display: none;
}
.key-status.show {
display: block;
animation: slideInDown 0.3s ease-out;
}
.key-status.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
color: #2e7d32;
}
.key-status.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #c62828;
}
.key-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.key-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.key-info-item .material-icons {
font-size: 18px;
opacity: 0.7;
}
.oobe-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 32px;
}
.btn-large {
padding: 16px 32px;
font-size: 16px;
font-weight: 500;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-extra-large);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.loading-overlay.show {
opacity: 1;
visibility: visible;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--md-sys-color-outline);
border-top: 4px solid var(--md-sys-color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@media (max-width: 600px) {
.oobe-container {
padding: 12px;
}
.oobe-header {
padding: 32px 24px;
}
.oobe-content {
padding: 24px;
}
.key-info {
grid-template-columns: 1fr;
}
.oobe-actions {
flex-direction: column;
}
}

View File

@@ -0,0 +1,314 @@
.project-info-card {
background: var(--gradient-primary);
border-radius: var(--md-sys-shape-corner-extra-large);
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level3);
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
}
[data-theme="dark"] .project-info-card {
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(
135deg,
var(--md-sys-color-primary-container) 0%,
var(--md-sys-color-secondary-container) 100%
);
color: var(--md-sys-color-on-primary-container);
}
.project-info-card::before {
content: "";
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.1) 0%,
transparent 70%
);
animation: rotate 30s linear infinite;
}
.project-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 20px;
position: relative;
z-index: 1;
}
.project-logo {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: var(--md-sys-shape-corner-large);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
transition: transform var(--transition-medium) ease;
}
.project-logo:hover {
transform: scale(1.1) rotate(5deg);
}
.project-logo .material-icons {
color: white;
font-size: 36px;
}
.project-details {
flex: 1;
}
.project-name {
font-size: 24px;
font-weight: 600;
color: white;
margin: 0 0 4px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .project-info-card .project-name {
color: var(--md-sys-color-on-primary-container);
}
.project-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
[data-theme="dark"] .project-info-card .project-subtitle {
color: var(--md-sys-color-on-primary-container);
}
.project-version {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.version-label {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
color: white;
padding: 6px 16px;
border-radius: var(--md-sys-shape-corner-full);
font-size: 14px;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] .project-info-card .version-label {
color: var(--md-sys-color-on-primary-container);
}
.version-type {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.project-description {
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.project-description p {
font-size: 15px;
color: rgba(255, 255, 255, 0.95);
line-height: 1.6;
margin: 0;
}
.project-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
position: relative;
z-index: 1;
}
.project-link {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
color: white;
text-decoration: none;
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all var(--transition-medium) ease;
}
[data-theme="dark"] .project-link {
background: rgba(255, 255, 255, 0.1);
color: var(--md-sys-color-on-primary-container);
}
.project-link:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
[data-theme="dark"] .project-link:hover {
background: rgba(255, 255, 255, 0.2);
}
.project-link .material-icons {
font-size: 24px;
transition: transform var(--transition-fast) ease;
}
.project-link:hover .material-icons {
transform: scale(1.1);
}
.link-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.link-title {
font-size: 14px;
font-weight: 600;
}
.link-url {
font-size: 12px;
opacity: 0.8;
}
.tech-info {
background: var(--md-sys-color-surface-container);
backdrop-filter: blur(10px);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-large);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level1);
transition: all var(--transition-medium) ease;
}
.tech-info:hover {
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level3);
}
.tech-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.tech-header .material-icons {
font-size: 28px;
color: var(--md-sys-color-primary);
}
.tech-header h4 {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-surface);
margin: 0;
}
.tech-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.tech-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-small);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-fast) ease;
}
.tech-item:hover {
transform: translateX(4px);
border-color: var(--md-sys-color-primary);
}
.tech-item strong {
color: var(--md-sys-color-on-surface);
font-weight: 600;
font-size: 14px;
}
.tech-item span {
color: var(--md-sys-color-on-surface-variant);
font-size: 13px;
line-height: 1.5;
}
.usage-notice {
background: var(--md-sys-color-warning-container);
border: 1px solid var(--md-sys-color-warning);
border-radius: var(--md-sys-shape-corner-large);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--md-sys-elevation-level1);
}
.notice-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.notice-header .material-icons {
color: var(--md-sys-color-warning);
font-size: 28px;
}
.notice-header h4 {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-warning-container);
margin: 0;
}
.notice-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.notice-content p {
font-size: 14px;
color: var(--md-sys-color-on-warning-container);
line-height: 1.5;
margin: 0;
padding: 8px 12px;
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-small);
border-left: 3px solid var(--md-sys-color-warning);
}
.notice-content strong {
color: var(--md-sys-color-on-warning-container);
font-weight: 600;
}

View File

@@ -0,0 +1,299 @@
.settings-main {
display: flex;
flex-direction: column;
gap: 24px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.checkbox-label {
font-size: 16px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.checkbox-description {
font-size: 13px;
color: var(--md-sys-color-on-surface-variant);
line-height: 1.4;
}
#keyInfoSection {
padding: 20px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
margin-bottom: 24px;
}
.key-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-top: 16px;
}
.key-info-card {
background: var(--md-sys-color-surface-variant);
border-radius: var(--md-sys-shape-corner-medium);
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s ease;
}
.key-info-card:hover {
background: var(--md-sys-color-primary-container);
transform: translateY(-2px);
}
.key-info-icon {
color: var(--md-sys-color-primary);
font-size: 24px;
}
.key-info-content {
flex: 1;
}
.key-info-label {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
}
.key-info-value {
font-size: 16px;
color: var(--md-sys-color-on-surface);
font-weight: 500;
margin-top: 4px;
}
.key-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--md-sys-shape-corner-small);
font-size: 12px;
font-weight: 500;
}
.key-status-badge.active {
background: rgba(76, 175, 80, 0.1);
color: #2e7d32;
}
.key-status-badge.expired {
background: rgba(244, 67, 54, 0.1);
color: #c62828;
}
.key-status-badge.inactive {
background: rgba(158, 158, 158, 0.1);
color: #616161;
}
.key-change-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--md-sys-color-outline);
}
.key-input-group {
display: flex;
gap: 12px;
align-items: end;
}
.key-input-group .text-field {
flex: 1;
font-family: monospace;
letter-spacing: 1px;
}
.path-input-group {
display: flex;
gap: 12px;
align-items: stretch;
}
.path-input-group .text-field {
flex: 1;
}
.status-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid var(--md-sys-color-outline-variant);
margin-top: 16px;
}
.status-indicator .status-icon {
font-size: 20px;
}
.status-indicator .status-text {
flex: 1;
font-size: 14px;
color: var(--md-sys-color-on-surface);
}
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.config-status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.config-status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
border: 1px solid var(--md-sys-color-outline-variant);
}
.config-status-item .material-icons {
font-size: 24px;
color: var(--md-sys-color-primary);
}
.config-status-content {
flex: 1;
}
.config-status-label {
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
margin-bottom: 4px;
}
.config-status-value {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
opacity: 0;
visibility: hidden;
transition: all var(--transition-medium) ease;
}
.dialog-overlay.show {
opacity: 1;
visibility: visible;
}
.dialog {
background: var(--md-sys-color-surface);
border-radius: var(--md-sys-shape-corner-extra-large);
box-shadow: var(--md-sys-elevation-level5);
max-width: 400px;
width: 90%;
transform: scale(0.9);
transition: transform var(--transition-medium) ease;
}
.dialog-overlay.show .dialog {
transform: scale(1);
}
.dialog-header {
padding: 24px 24px 16px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.dialog-header h3 {
margin: 0;
font-size: 20px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.dialog-content {
padding: 24px;
}
.dialog-content p {
margin: 0;
font-size: 14px;
color: var(--md-sys-color-on-surface-variant);
line-height: 1.5;
}
.dialog-actions {
padding: 16px 24px;
display: flex;
gap: 12px;
justify-content: flex-end;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
@media (max-width: 768px) {
.key-input-group {
flex-direction: column;
align-items: stretch;
}
.path-input-group {
flex-direction: column;
}
.action-buttons {
flex-direction: column;
}
.action-buttons .btn {
width: 100%;
}
.config-status-grid {
grid-template-columns: 1fr;
}
}
.btn.btn-text:first-child {
margin-right: 8px;
}

View File

@@ -0,0 +1,8 @@
@import url("./variables.css");
@import url("./base.css");
@import url("./layout.css");
@import url("./components.css");
@import url("./animations.css");
@import url("./oobe.css");
@import url("./project-info.css");
@import url("./utilities.css");

View File

@@ -0,0 +1,222 @@
.progress-container {
background: var(--md-sys-color-surface-container-low);
border-radius: var(--md-sys-shape-corner-medium);
min-height: 200px;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--md-sys-color-outline-variant);
}
.progress-container::-webkit-scrollbar {
width: 8px;
}
.progress-container::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
border-radius: 4px;
}
.progress-container::-webkit-scrollbar-thumb {
background: var(--md-sys-color-primary);
border-radius: 4px;
}
.progress-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--md-sys-color-on-surface-variant);
gap: 12px;
}
.progress-placeholder .material-icons {
font-size: 48px;
opacity: 0.3;
animation: pulse 2s ease-in-out infinite;
}
.log-entry {
padding: 12px 16px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
font-family: "Courier New", monospace;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 12px;
transition: background-color var(--transition-fast) ease;
animation: slideIn var(--transition-medium) ease-out;
position: relative;
}
.log-entry::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--md-sys-color-primary);
opacity: 0;
transition: opacity var(--transition-fast) ease;
}
.log-entry:hover::before {
opacity: 1;
}
.log-entry:hover {
background-color: var(--md-sys-color-primary-container);
}
[data-theme="dark"] .log-entry {
background: var(--md-sys-color-surface-container);
}
[data-theme="dark"] .log-entry:hover {
background: var(--md-sys-color-surface-container-high);
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.warning {
background-color: var(--md-sys-color-warning-container);
border-left: 3px solid var(--md-sys-color-warning);
}
.log-entry.error {
background-color: var(--md-sys-color-error-container);
border-left: 3px solid var(--md-sys-color-error);
}
.log-timestamp {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: var(--md-sys-color-on-surface-variant);
font-size: 11px;
min-width: 60px;
opacity: 0.7;
}
.log-message {
font-family:
"LXGW Wenkai Mono",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
flex: 1;
word-break: break-word;
color: var(--md-sys-color-on-surface);
}
.config-status {
display: flex;
flex-direction: column;
gap: 12px;
}
.settings-link,
.about-link {
color: var(--md-sys-color-on-surface) !important;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--md-sys-shape-corner-full);
background: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
transition: all var(--transition-medium) ease;
}
.settings-link:hover,
.about-link:hover {
background: var(--md-sys-color-primary-container);
border-color: var(--md-sys-color-primary);
transform: translateY(-2px);
box-shadow: var(--md-sys-elevation-level2);
}
.settings-link:hover .material-icons,
.about-link:hover .material-icons {
animation: rotate 1s ease-in-out;
}
.settings-text,
.about-text {
font-size: 14px;
font-weight: 600;
}
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
background: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
padding: 8px 12px;
border-radius: var(--md-sys-shape-corner-small);
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all var(--transition-fast) ease;
pointer-events: none;
z-index: 1000;
}
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.unlock-form {
display: flex;
flex-direction: column;
gap: 24px;
}
@media (prefers-contrast: high) {
.card {
border-width: 2px;
}
.btn {
border: 2px solid currentColor;
}
.text-field {
border-width: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (max-width: 768px) and (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .card {
box-shadow: var(--md-sys-elevation-level2);
}
}

View File

@@ -0,0 +1,206 @@
:root {
--transition-fast: 200ms;
--transition-medium: 300ms;
--transition-slow: 400ms;
--md-sys-color-primary: #6750a4;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #e9ddff;
--md-sys-color-on-primary-container: #22005d;
--md-sys-color-secondary: #00bcd4;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #b2ebf2;
--md-sys-color-on-secondary-container: #00363d;
--md-sys-color-tertiary: #ff6f00;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #ffe0b2;
--md-sys-color-on-tertiary-container: #4a1c00;
--md-sys-color-error: #dc2626;
--md-sys-color-on-error: #ffffff;
--md-sys-color-error-container: #fee2e2;
--md-sys-color-on-error-container: #7f1d1d;
--md-sys-color-background: #fdfcff;
--md-sys-color-on-background: #1a1c1e;
--md-sys-color-surface: #fdfcff;
--md-sys-color-on-surface: #1a1c1e;
--md-sys-color-surface-variant: #e7e0ec;
--md-sys-color-on-surface-variant: #49454e;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f7f2fa;
--md-sys-color-surface-container: #f1ecf4;
--md-sys-color-surface-container-high: #ebe6ee;
--md-sys-color-surface-container-highest: #e6e0e9;
--md-sys-color-outline: #79747e;
--md-sys-color-outline-variant: #cac4cf;
--md-sys-color-success: #16a34a;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #dcfce7;
--md-sys-color-warning: #f59e0b;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #fef3c7;
--md-sys-color-surface-tint: #6750a4;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #313033;
--md-sys-color-inverse-on-surface: #f4eff4;
--md-sys-color-inverse-primary: #d0bcff;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level2:
0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level3:
0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level4:
0px 6px 10px 4px rgba(0, 0, 0, 0.15), 0px 2px 3px 0px rgba(0, 0, 0, 0.3);
--md-sys-elevation-level5:
0px 8px 12px 6px rgba(0, 0, 0, 0.15), 0px 4px 4px 0px rgba(0, 0, 0, 0.3);
--md-sys-shape-corner-none: 0px;
--md-sys-shape-corner-extra-small: 4px;
--md-sys-shape-corner-small: 8px;
--md-sys-shape-corner-medium: 12px;
--md-sys-shape-corner-large: 16px;
--md-sys-shape-corner-extra-large: 28px;
--md-sys-shape-corner-full: 999px;
--gradient-primary: linear-gradient(135deg, #6750a4 0%, #8b7cc4 100%);
--gradient-secondary: linear-gradient(135deg, #00bcd4 0%, #4dd0e1 100%);
--gradient-surface: linear-gradient(135deg, #fdfcff 0%, #f7f2fa 100%);
}
[data-theme="dark"] {
--md-sys-color-primary: #d0bcff;
--md-sys-color-on-primary: #381e72;
--md-sys-color-primary-container: #4f378a;
--md-sys-color-on-primary-container: #e9ddff;
--md-sys-color-secondary: #4dd0e1;
--md-sys-color-on-secondary: #00363d;
--md-sys-color-secondary-container: #005662;
--md-sys-color-on-secondary-container: #b2ebf2;
--md-sys-color-tertiary: #ffb74d;
--md-sys-color-on-tertiary: #4a1c00;
--md-sys-color-tertiary-container: #6a2c00;
--md-sys-color-on-tertiary-container: #ffe0b2;
--md-sys-color-error: #f87171;
--md-sys-color-on-error: #7f1d1d;
--md-sys-color-error-container: #991b1b;
--md-sys-color-on-error-container: #fee2e2;
--md-sys-color-background: #1a1c1e;
--md-sys-color-on-background: #e3e2e6;
--md-sys-color-surface: #1a1c1e;
--md-sys-color-on-surface: #e3e2e6;
--md-sys-color-surface-variant: #49454e;
--md-sys-color-on-surface-variant: #cac4cf;
--md-sys-color-surface-container-lowest: #0e0f11;
--md-sys-color-surface-container-low: #1a1c1e;
--md-sys-color-surface-container: #1e2022;
--md-sys-color-surface-container-high: #282a2d;
--md-sys-color-surface-container-highest: #333538;
--md-sys-color-outline: #938f99;
--md-sys-color-outline-variant: #49454e;
--md-sys-color-success: #4ade80;
--md-sys-color-on-success: #14532d;
--md-sys-color-success-container: #166534;
--md-sys-color-warning: #fbbf24;
--md-sys-color-on-warning: #451a03;
--md-sys-color-warning-container: #78350f;
--md-sys-color-surface-tint: #d0bcff;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #e6e0e9;
--md-sys-color-inverse-on-surface: #313033;
--md-sys-color-inverse-primary: #6750a4;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level2:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level3:
0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level4:
0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level5:
0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15);
--gradient-primary: linear-gradient(135deg, #4f378a 0%, #6750a4 100%);
--gradient-secondary: linear-gradient(135deg, #005662 0%, #00838f 100%);
--gradient-surface: linear-gradient(135deg, #1a1c1e 0%, #1e2022 100%);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--md-sys-color-primary: #d0bcff;
--md-sys-color-on-primary: #381e72;
--md-sys-color-primary-container: #4f378a;
--md-sys-color-on-primary-container: #e9ddff;
--md-sys-color-secondary: #4dd0e1;
--md-sys-color-on-secondary: #00363d;
--md-sys-color-secondary-container: #005662;
--md-sys-color-on-secondary-container: #b2ebf2;
--md-sys-color-tertiary: #ffb74d;
--md-sys-color-on-tertiary: #4a1c00;
--md-sys-color-tertiary-container: #6a2c00;
--md-sys-color-on-tertiary-container: #ffe0b2;
--md-sys-color-error: #f87171;
--md-sys-color-on-error: #7f1d1d;
--md-sys-color-error-container: #991b1b;
--md-sys-color-on-error-container: #fee2e2;
--md-sys-color-background: #1a1c1e;
--md-sys-color-on-background: #e3e2e6;
--md-sys-color-surface: #1a1c1e;
--md-sys-color-on-surface: #e3e2e6;
--md-sys-color-surface-variant: #49454e;
--md-sys-color-on-surface-variant: #cac4cf;
--md-sys-color-surface-container-lowest: #0e0f11;
--md-sys-color-surface-container-low: #1a1c1e;
--md-sys-color-surface-container: #1e2022;
--md-sys-color-surface-container-high: #282a2d;
--md-sys-color-surface-container-highest: #333538;
--md-sys-color-outline: #938f99;
--md-sys-color-outline-variant: #49454e;
--md-sys-color-success: #4ade80;
--md-sys-color-on-success: #14532d;
--md-sys-color-success-container: #166534;
--md-sys-color-warning: #fbbf24;
--md-sys-color-on-warning: #451a03;
--md-sys-color-warning-container: #78350f;
--md-sys-color-surface-tint: #d0bcff;
--md-sys-color-scrim: #000000;
--md-sys-color-inverse-surface: #e6e0e9;
--md-sys-color-inverse-on-surface: #313033;
--md-sys-color-inverse-primary: #6750a4;
--md-sys-elevation-level0: none;
--md-sys-elevation-level1:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level2:
0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level3:
0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level4:
0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15);
--md-sys-elevation-level5:
0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15);
--gradient-primary: linear-gradient(135deg, #4f378a 0%, #6750a4 100%);
--gradient-secondary: linear-gradient(135deg, #005662 0%, #00838f 100%);
--gradient-surface: linear-gradient(135deg, #1a1c1e 0%, #1e2022 100%);
}
}

View File

@@ -17,7 +17,7 @@ class SettingsManager {
this.showConfirmDialog(
"重置配置",
"确定要重置所有配置为默认值吗?此操作不可恢复。",
() => this.resetConfig()
() => this.resetConfig(),
);
});
@@ -41,7 +41,7 @@ class SettingsManager {
this.showConfirmDialog(
"更换卡密",
"确定要更换为新的卡密吗?更换后需要重新验证。",
() => this.changeKey()
() => this.changeKey(),
);
});
@@ -198,10 +198,10 @@ class SettingsManager {
<div class="key-info-label">卡密</div>
<div class="key-info-value">${keyInfo.key.substring(
0,
8
8,
)}...${keyInfo.key.substring(
keyInfo.key.length - 8
)}</div>
keyInfo.key.length - 8,
)}</div>
</div>
</div>
@@ -228,8 +228,8 @@ class SettingsManager {
<div class="key-info-content">
<div class="key-info-label">到期时间</div>
<div class="key-info-value">${expiresAt.toLocaleDateString()} ${expiresAt
.toLocaleTimeString()
.substring(0, 5)}</div>
.toLocaleTimeString()
.substring(0, 5)}</div>
</div>
</div>
@@ -238,8 +238,8 @@ class SettingsManager {
<div class="key-info-content">
<div class="key-info-label">使用次数</div>
<div class="key-info-value">${keyInfo.usageCount} / ${
keyInfo.totalUsage || "∞"
}</div>
keyInfo.totalUsage || "∞"
}</div>
</div>
</div>
@@ -307,7 +307,7 @@ class SettingsManager {
`验证成功!新卡密类型:${
typeNames[data.info.type]
}有效期至${expiresAt.toLocaleDateString()}`,
"success"
"success",
);
} else {
this.showSnackbar("新卡密无效或已过期", "error");
@@ -583,7 +583,7 @@ class SettingsManager {
<div class="status-card-description">${card.description}</div>
</div>
</div>
`
`,
)
.join("");
}

169
web/zh/templates/about.html Normal file
View File

@@ -0,0 +1,169 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Onekey - About</title>
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<!-- 自定义样式 -->
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<div class="footer-content">
<!-- 项目介绍卡片 -->
<div class="project-info-card">
<div class="project-header">
<div class="project-logo">
<span class="material-icons">extension</span>
</div>
<div class="project-details">
<h3 class="project-name">Onekey</h3>
<p class="project-subtitle">直观,优雅的游戏解锁解决方案</p>
</div>
<div class="project-version">
<span class="version-label">v2.1.2</span>
<span class="version-type">Web UI</span>
</div>
</div>
<div class="project-links">
<a
href="https://github.com/ikunshare/Onekey"
target="_blank"
class="project-link github"
>
<span class="material-icons">code</span>
<div class="link-content">
<span class="link-title">GitHub 仓库</span>
<span class="link-url">github.com/ikunshare/Onekey</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/releases"
target="_blank"
class="project-link releases"
>
<span class="material-icons">file_download</span>
<div class="link-content">
<span class="link-title">下载发布版</span>
<span class="link-url">获取最新版本</span>
</div>
</a>
<a
href="https://shop.ikunshare.com"
target="_blank"
class="project-link buy_cdk"
>
<span class="material-icons">shopping_cart</span>
<div class="link-content">
<span class="link-title">购卡链接</span>
<span class="link-url">购买卡密</span>
</div>
</a>
<a
href="https://github.com/qwq-xinkeng"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">作者主页</span>
<span class="link-url">github.com/qwq-xinkeng</span>
</div>
</a>
<a
href="https://github.com/ikun0014"
target="_blank"
class="project-link author"
>
<span class="material-icons">person</span>
<div class="link-content">
<span class="link-title">作者主页</span>
<span class="link-url">github.com/ikun0014</span>
</div>
</a>
<a
href="https://github.com/ikunshare/Onekey/issues"
target="_blank"
class="project-link issues"
>
<span class="material-icons">bug_report</span>
<div class="link-content">
<span class="link-title">问题反馈</span>
<span class="link-url">报告Bug或建议</span>
</div>
</a>
</div>
</div>
<!-- 技术信息 -->
<div class="tech-info">
<div class="tech-header">
<span class="material-icons">code</span>
<h4>技术信息</h4>
</div>
<div class="tech-content">
<div class="tech-item">
<strong>🐍 后端技术</strong>
<span>Python 3.8+ • FastAPI • AsyncIO • AIOHTTP</span>
</div>
<div class="tech-item">
<strong>🌐 前端技术</strong>
<span>HTML5 • CSS3 • JavaScript ES6+ • Material Design 3.0</span>
</div>
<div class="tech-item">
<strong>🔧 支持工具</strong>
<span>SteamTools • GreenLuma</span>
</div>
</div>
</div>
<!-- 使用须知 -->
<div class="usage-notice">
<div class="notice-header">
<span class="material-icons">info</span>
<h4>使用须知</h4>
</div>
<div class="notice-content">
<p>
<strong>🖥️ 系统要求</strong> - 请确保已安装Windows
10/11并正确配置Steam客户端
</p>
<p>
<strong>🛠️ 工具准备</strong> -
使用前请先安装SteamTools或GreenLuma解锁工具
</p>
<p>
<strong>🔒 免责声明</strong> -
本工具仅供学习交流使用,使用者需自行承担相关风险
</p>
<p>
<strong>⭐ 支持项目</strong> -
如果此工具对您有帮助欢迎在GitHub上给项目点Star支持
</p>
</div>
</div>
<!-- 版权信息 -->
<div class="copyright">
<p>© 2025 Onekey Steam解锁工具 • 作者qwq-xinkeng && ikun0014</p>
<p>
项目地址:<a href="https://github.com/ikunshare/Onekey" target="_blank"
>https://github.com/ikunshare/Onekey</a
>
</p>
</div>
<script src="{{ url_for('static', path='js/theme.js') }}"></script>
</div>
</html>

View File

@@ -7,7 +7,7 @@
<!-- Material Design 3 -->
<link
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/style.css"
href="https://cdn.jsdmirror.com/gh/ikun0014/font@main/LXGWWenKaiMono-Regular/result.css"
rel="stylesheet"
/>
<link

View File

@@ -62,7 +62,9 @@
<p>• 直观的 Web 界面,操作简单</p>
<p>• 实时日志显示,过程透明</p>
<p>• 前端代码完全开源, 绝对不盗号/挖矿</p>
<a href="http://103.217.184.26:911" target="_blank">• 点我购买卡密</a>
<a href="https://shop.ikunshare.com" target="_blank"
>• 点我购买卡密</a
>
</div>
</div>

View File

@@ -147,6 +147,33 @@
</div>
<div class="card-content">
<div class="settings-section">
<div class="setting-item">
<label class="setting-label">语言选择</label>
<div class="radio-group">
<label class="radio-item">
<input
type="radio"
name="language"
value="zh"
id="language-zh"
checked
/>
<span class="radio-button"></span>
<span class="radio-label">简体中文</span>
</label>
<label class="radio-item">
<input
type="radio"
name="language"
value="en"
id="language-en"
/>
<span class="radio-button"></span>
<span class="radio-label">English</span>
</label>
</div>
</div>
<div class="setting-item">
<label class="checkbox-item">
<input type="checkbox" id="debugMode" />