fix: автодетект --cli в Bootstrap, отображение локальных сборок в JFX, обработка ошибок установки и друзей
This commit is contained in:
@@ -27,7 +27,7 @@ import java.util.jar.Manifest;
|
||||
|
||||
public class Bootstrap {
|
||||
private static final String JAR_NAME = "zernmclauncher.jar";
|
||||
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||
private static final String BASE_URL = "https://api.zernmc.ru";
|
||||
private static List<String> MIRRORS = new ArrayList<>();
|
||||
private static volatile boolean jfxChildExiting = false;
|
||||
|
||||
@@ -53,9 +53,12 @@ public class Bootstrap {
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
List<String> argList = Arrays.asList(args);
|
||||
isCliMode = argList.contains("--cli");
|
||||
List<String> argList = new ArrayList<>(Arrays.asList(args));
|
||||
isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx"));
|
||||
isJfxMode = !isCliMode;
|
||||
if (isCliMode && !argList.contains("--cli")) {
|
||||
argList.add("--cli");
|
||||
}
|
||||
|
||||
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
|
||||
|
||||
@@ -94,7 +97,7 @@ public class Bootstrap {
|
||||
ui.close();
|
||||
}
|
||||
|
||||
launchMain(args);
|
||||
launchMain(argList.toArray(new String[0]));
|
||||
}
|
||||
|
||||
private static void launchMain(String[] args) throws Exception {
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.util.List;
|
||||
public class Bootstrap {
|
||||
private static final String VERSION_FILE = "build.version";
|
||||
private static final String JAR_NAME = "ZernMCLauncher.jar";
|
||||
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||
private static final String BASE_URL = "https://api.zernmc.ru";
|
||||
|
||||
private static Path baseDir;
|
||||
private static Path logDir;
|
||||
|
||||
@@ -78,18 +78,22 @@
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button class="nav-btn active" data-view="packs" title="Packs">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
<span data-i18n="nav.packs">Packs</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-view="news" title="News">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
|
||||
<span data-i18n="nav.news">News</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-view="friends" title="Friends">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span data-i18n="nav.friends">Friends</span>
|
||||
</button>
|
||||
<div class="sidebar-nav-left">
|
||||
<button class="nav-btn active" data-view="packs" title="Packs">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
<span data-i18n="nav.packs">Packs</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-view="news" title="News">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
|
||||
<span data-i18n="nav.news">News</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-nav-right">
|
||||
<button class="nav-btn" data-view="friends" title="Friends">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span data-i18n="nav.friends">Friends</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
|
||||
@@ -78,6 +78,7 @@ const LOCALES = {
|
||||
'toast.launched': 'Launched! PID: {pid}',
|
||||
'toast.installing': 'Installing...',
|
||||
'toast.installComplete': 'Installation complete!',
|
||||
'toast.installFailed': 'Installation failed',
|
||||
'toast.enterPassCode': 'Enter a pass code',
|
||||
'toast.passActivated': 'Pass activated!',
|
||||
'toast.activationFailed': 'Activation failed',
|
||||
@@ -86,6 +87,122 @@ const LOCALES = {
|
||||
'pack.worldsCount': '{count} worlds',
|
||||
'pack.logsCount': '{count} logs',
|
||||
'locale.en': 'English', 'locale.ru': 'Russian',
|
||||
'ram.gb': '{value} GB',
|
||||
'ram.mbAllocated': '{value} MB allocated',
|
||||
'nav.friends': 'Friends',
|
||||
'friends.title': 'Friends',
|
||||
'friends.add': 'Add',
|
||||
'friends.addTitle': 'Add Friend',
|
||||
'friends.addLabel': 'Username',
|
||||
'friends.addPlaceholder': 'Enter username...',
|
||||
'friends.search': 'Search friends...',
|
||||
'friends.empty': 'No friends yet',
|
||||
'friends.online': 'Online',
|
||||
'friends.offline': 'Offline',
|
||||
'friends.playing': 'Playing {pack}',
|
||||
'friends.noPack': 'Idle',
|
||||
'friends.requests': 'Friend Requests',
|
||||
'friends.requestFrom': '{user} wants to add you as friend',
|
||||
'friends.accept': 'Accept',
|
||||
'friends.decline': 'Decline',
|
||||
'friends.remove': 'Remove',
|
||||
'friends.addSuccess': 'Request sent!',
|
||||
'friends.addError': 'Failed to add friend',
|
||||
'friends.removeSuccess': 'Friend removed',
|
||||
'friends.acceptSuccess': 'Request accepted',
|
||||
'friends.passActive': 'Pass Active',
|
||||
'friends.passInactive': 'No Pass',
|
||||
'playtime.label': 'Playtime',
|
||||
'playtime.hours': '{h}h {m}m',
|
||||
'playtime.total': 'Total: {h}h',
|
||||
'playtime.syncing': 'Syncing...',
|
||||
},
|
||||
ru: {
|
||||
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
|
||||
'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль',
|
||||
'login.hint': 'Новый аккаунт создаётся автоматически при первом входе',
|
||||
'login.signingIn': 'Вход...',
|
||||
'loading.text': 'Загрузка...',
|
||||
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
|
||||
'pack.empty.title': 'Выберите сборку',
|
||||
'pack.empty.desc': 'Выберите сборку в боковой панели',
|
||||
'pack.description.loading': 'Загрузка описания...',
|
||||
'pack.noDescription': 'Описание отсутствует',
|
||||
'stat.loaderVer': 'Версия загрузчика', 'stat.files': 'Файлы', 'stat.size': 'Размер',
|
||||
'playBar.play': 'Играть', 'playBar.passRequired': 'Требуется проходка',
|
||||
'settings.title': 'Настройки',
|
||||
'settings.activatePass.title': 'Активировать проходку',
|
||||
'settings.activatePass.desc': 'Введите код проходки для доступа к серверным сборкам',
|
||||
'settings.activatePass.placeholder': 'Код проходки',
|
||||
'settings.activatePass.button': 'Активировать',
|
||||
'settings.ram.title': 'Выделенная память',
|
||||
'settings.resolution.title': 'Разрешение экрана',
|
||||
'settings.resolution.desc': 'Ширина x Высота',
|
||||
'settings.jvmArgs.title': 'Дополнительные аргументы JVM',
|
||||
'settings.jvmArgs.desc': 'Дополнительные параметры Java VM',
|
||||
'settings.javaPath.title': 'Путь к Java',
|
||||
'settings.server.title': 'Сервер',
|
||||
'settings.server.checking': 'Проверка...',
|
||||
'settings.server.connected': 'Подключено',
|
||||
'settings.server.disconnected': 'Отключено',
|
||||
'settings.language.title': 'Язык',
|
||||
'settings.language.desc': 'Язык интерфейса',
|
||||
'settings.systemJvm.title': 'Системная оптимизация JVM',
|
||||
'settings.systemJvm.desc': 'Автонастройка флагов JVM под вашу систему (экспериментально)',
|
||||
'settings.systemJvm.info': 'ЦП: {cores} ядер | ОЗУ: {ram} ГБ | {flags}',
|
||||
'settings.systemJvm.enabled': 'Включено', 'settings.systemJvm.disabled': 'Выключено',
|
||||
'settings.logViewer.title': 'Лог игры',
|
||||
'settings.logViewer.desc': 'Просмотр логов игры в реальном времени',
|
||||
'settings.logViewer.open': 'Открыть лог',
|
||||
'logViewer.title': 'Лог игры',
|
||||
'logViewer.noLogs': 'Логов пока нет. Запустите игру, чтобы увидеть логи.',
|
||||
'logViewer.copy': 'Копировать', 'logViewer.copied': 'Скопировано!',
|
||||
'logViewer.openFile': 'Открыть файл', 'logViewer.close': 'Закрыть',
|
||||
'logViewer.waiting': 'Ожидание вывода игры...',
|
||||
'install.title': 'Установить сборку',
|
||||
'install.tab.serverPack': 'Серверная сборка', 'install.tab.custom': 'Своя',
|
||||
'install.serverPack.label': 'Серверная сборка',
|
||||
'install.localName.label': 'Локальное имя',
|
||||
'install.downloadBtn': 'Скачать и установить',
|
||||
'install.custom.unavailable': 'Пока недоступно',
|
||||
'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".',
|
||||
'install.progress.installing': 'Установка...',
|
||||
'install.enterName': 'Введите имя',
|
||||
'install.step': 'Шаг {current} из {total}: {name}',
|
||||
'select.loading': 'Загрузка...',
|
||||
'select.selectPack': 'Выберите сборку',
|
||||
'select.passRequired': 'Требуется проходка',
|
||||
'select.noPacks': 'Нет доступных сборок',
|
||||
'select.selectVersion': 'Выберите версию',
|
||||
'select.failedLoad': 'Ошибка загрузки',
|
||||
'select.selectMCFirst': 'Сначала выберите версию',
|
||||
'select.placeholder': 'Выберите...',
|
||||
'select.search': 'Поиск...',
|
||||
'packList.empty': 'Нет сборок',
|
||||
'news.title': 'Новости', 'news.empty': 'Новостей пока нет', 'news.loading': 'Загрузка новостей...',
|
||||
'pack.update': 'Обновить', 'pack.delete': 'Удалить',
|
||||
'badge.free': 'БЕСПЛАТНО', 'badge.pro': 'ПРО',
|
||||
'toast.welcomeBack': 'С возвращением, {username}!',
|
||||
'toast.welcome': 'Добро пожаловать, {username}!',
|
||||
'toast.accountCreated': 'Аккаунт создан! Добро пожаловать, {username}!',
|
||||
'toast.loggedOut': 'Вы вышли',
|
||||
'toast.enterCredentials': 'Введите логин и пароль',
|
||||
'toast.loginFailed': 'Ошибка входа',
|
||||
'toast.launching': 'Запуск {name}...',
|
||||
'toast.launchFailed': 'Ошибка запуска',
|
||||
'toast.noPackSelected': 'Сначала выберите сборку',
|
||||
'toast.launched': 'Запущено! PID: {pid}',
|
||||
'toast.installing': 'Установка...',
|
||||
'toast.installComplete': 'Установка завершена!',
|
||||
'toast.installFailed': 'Ошибка установки',
|
||||
'toast.enterPassCode': 'Введите код проходки',
|
||||
'toast.passActivated': 'Проходка активирована!',
|
||||
'toast.activationFailed': 'Ошибка активации',
|
||||
'error.network': 'Ошибка сети',
|
||||
'pack.modsCount': '{count} модов',
|
||||
'pack.worldsCount': '{count} миров',
|
||||
'pack.logsCount': '{count} логов',
|
||||
'locale.en': 'Английский', 'locale.ru': 'Русский',
|
||||
'ram.gb': '{value} ГБ',
|
||||
'ram.mbAllocated': '{value} МБ выделено',
|
||||
'nav.friends': 'Друзья',
|
||||
@@ -225,10 +342,7 @@ class ZernMCLauncher {
|
||||
return await r.json();
|
||||
} catch (fetchErr) {
|
||||
clearTimeout(timeoutId);
|
||||
if (fetchErr.name === 'AbortError') {
|
||||
return { success: false, error: 'Request timeout' };
|
||||
}
|
||||
throw fetchErr;
|
||||
return { success: false, error: fetchErr.message || 'Request failed' };
|
||||
}
|
||||
}
|
||||
return await new Promise(function(resolve, reject) {
|
||||
@@ -450,19 +564,26 @@ class ZernMCLauncher {
|
||||
|
||||
renderSidebar() {
|
||||
const serverList = document.getElementById('server-packs-list');
|
||||
const localList = document.getElementById('local-packs-list');
|
||||
const localSection = document.getElementById('local-packs-section');
|
||||
serverList.innerHTML = '';
|
||||
localList.innerHTML = '';
|
||||
if (localSection) localSection.classList.add('hidden');
|
||||
|
||||
if (!this.state.instances || this.state.instances.length === 0) return;
|
||||
|
||||
this.state.instances.forEach(inst => {
|
||||
const isZern = inst.isServerPack || inst.category === 'zernmc';
|
||||
if (!isZern) return;
|
||||
const targetList = isZern ? serverList : localList;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : '');
|
||||
el.innerHTML = `
|
||||
<div class="pack-entry-icon server">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<div class="pack-entry-icon ${isZern ? 'server' : 'local'}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${isZern
|
||||
? '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>'
|
||||
: '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>'
|
||||
}</svg>
|
||||
</div>
|
||||
<div class="pack-entry-info">
|
||||
<div class="pack-entry-name">${this.esc(inst.name)}</div>
|
||||
@@ -470,8 +591,12 @@ class ZernMCLauncher {
|
||||
</div>
|
||||
`;
|
||||
el.addEventListener('click', () => this.selectPack(inst));
|
||||
serverList.appendChild(el);
|
||||
targetList.appendChild(el);
|
||||
});
|
||||
|
||||
if (localSection && localList.children.length > 0) {
|
||||
localSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
selectPack(inst) {
|
||||
@@ -984,8 +1109,9 @@ class ZernMCLauncher {
|
||||
if (!r.data.inProgress) {
|
||||
this.stopProgressPoll();
|
||||
this.hideInstallModal();
|
||||
this.toast(t('toast.installComplete'), 'success');
|
||||
await this.loadInstances();
|
||||
var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && r.data.label.startsWith('Error'));
|
||||
this.toast(failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete'), failed ? 'error' : 'success');
|
||||
if (!failed) await this.loadInstances();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
@@ -1173,13 +1299,13 @@ class ZernMCLauncher {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
var r = await this.req('/friends/add', { method: 'POST', body: JSON.stringify({ username: username }) });
|
||||
if (r.success !== false || r.message) {
|
||||
if (r.detail) {
|
||||
errEl.textContent = r.detail;
|
||||
errEl.classList.remove('hidden');
|
||||
} else {
|
||||
this.toast(r.message || t('friends.addSuccess'), 'success');
|
||||
this.closeAddFriend();
|
||||
this.loadFriends();
|
||||
} else {
|
||||
errEl.textContent = r.detail || r.error || t('friends.addError');
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = t('friends.addError');
|
||||
@@ -1194,31 +1320,31 @@ class ZernMCLauncher {
|
||||
|
||||
async acceptFriendRequest(userId) {
|
||||
var r = await this.req('/friends/accept', { method: 'POST', body: JSON.stringify({ user_id: userId }) });
|
||||
if (r.success !== false || r.message) {
|
||||
if (r.detail) {
|
||||
this.toast(r.detail, 'error');
|
||||
} else {
|
||||
this.toast(r.message || t('friends.acceptSuccess'), 'success');
|
||||
this.loadFriends();
|
||||
} else {
|
||||
this.toast(r.detail || r.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async declineFriendRequest(userId) {
|
||||
var r = await this.req('/friends/remove', { method: 'POST', body: JSON.stringify({ user_id: userId }) });
|
||||
if (r.success !== false || r.message) {
|
||||
if (r.detail) {
|
||||
this.toast(r.detail, 'error');
|
||||
} else {
|
||||
this.toast(r.message || 'Declined', 'info');
|
||||
this.loadFriends();
|
||||
} else {
|
||||
this.toast(r.detail || r.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async removeFriend(userId) {
|
||||
var r = await this.req('/friends/remove', { method: 'POST', body: JSON.stringify({ user_id: userId }) });
|
||||
if (r.success !== false || r.message) {
|
||||
if (r.detail) {
|
||||
this.toast(r.detail, 'error');
|
||||
} else {
|
||||
this.toast(r.message || t('friends.removeSuccess'), 'success');
|
||||
this.loadFriends();
|
||||
} else {
|
||||
this.toast(r.detail || r.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,9 +185,12 @@ body {
|
||||
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex; gap: 4px;
|
||||
display: flex; justify-content: space-between; gap: 4px;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-nav-left, .sidebar-nav-right {
|
||||
display: flex; gap: 4px;
|
||||
}
|
||||
.nav-btn {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px; background: transparent; border: 1px solid transparent;
|
||||
@@ -670,6 +673,7 @@ body {
|
||||
.sidebar-bottom .user-card .btn-icon:first-child { display: none; }
|
||||
.sidebar-brand { justify-content: center; padding: 8px; }
|
||||
.sidebar-nav { flex-direction: column; }
|
||||
.sidebar-nav-left, .sidebar-nav-right { flex-direction: column; }
|
||||
.nav-btn { padding: 8px; }
|
||||
.pack-entry { justify-content: center; padding: 8px; }
|
||||
.content { padding: 12px; }
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess, sys, os, json, time, signal
|
||||
|
||||
os.chdir(os.path.join(os.path.dirname(__file__), "server"))
|
||||
workdir = os.path.join(os.path.dirname(__file__), "server")
|
||||
|
||||
# Start server
|
||||
print("Starting server...", flush=True)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "main:app",
|
||||
"--host", "127.0.0.1", "--port", "1590",
|
||||
"--log-level", "warning"],
|
||||
cwd=workdir,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
||||
for i in range(30):
|
||||
try:
|
||||
r = httpx.get("http://127.0.0.1:1590/news", timeout=2)
|
||||
if r.status_code == 200:
|
||||
print("Server ready!", flush=True)
|
||||
break
|
||||
except Exception as e:
|
||||
if i == 0:
|
||||
print(f"Waiting for server ({e})...", flush=True)
|
||||
time.sleep(1)
|
||||
else:
|
||||
print("Server failed to start", flush=True)
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
sys.exit(1)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
def check(name, ok, detail=""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
print(f" PASS {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {name} {detail}")
|
||||
failed += 1
|
||||
|
||||
BASE = "http://127.0.0.1:1590"
|
||||
client = httpx.Client(base_url=BASE, timeout=10)
|
||||
|
||||
# ===== NEWS =====
|
||||
print("\n=== NEWS ===")
|
||||
r = client.get("/news")
|
||||
check("GET /news status", r.status_code == 200, str(r.status_code))
|
||||
data = r.json()
|
||||
check("news has 'news' key", "news" in data)
|
||||
check("news is array", isinstance(data.get("news"), list))
|
||||
check("news has items", len(data["news"]) > 0, f"got {len(data['news'])} items")
|
||||
if data.get("news"):
|
||||
n = data["news"][0]
|
||||
check("news item has title", "title" in n)
|
||||
check("news item has body", "body" in n)
|
||||
check("news item has type", "type" in n)
|
||||
|
||||
# ===== AUTH =====
|
||||
print("\n=== AUTH ===")
|
||||
ts = str(int(time.time()))
|
||||
tu = f"test_{ts}"
|
||||
r = client.post("/auth/register", json={"username": tu, "password": "pass123"})
|
||||
check("register new user", r.status_code == 200, str(r.status_code))
|
||||
reg = r.json()
|
||||
check("register returns access_token", "access_token" in reg)
|
||||
|
||||
r2 = client.post("/auth/register", json={"username": tu, "password": "pass123"})
|
||||
check("register existing returns 409", r2.status_code == 409, str(r2.status_code))
|
||||
|
||||
r3 = client.post("/auth/login", json={"username": tu, "password": "pass123"})
|
||||
check("login valid", r3.status_code == 200, str(r3.status_code))
|
||||
tok = r3.json().get("access_token", "")
|
||||
if not tok: tok = reg.get("access_token", "")
|
||||
check("login returns token", bool(tok))
|
||||
|
||||
r4 = client.post("/auth/login", json={"username": tu, "password": "wrongpass123"})
|
||||
check("login wrong pass", r4.status_code == 401, str(r4.status_code))
|
||||
|
||||
r5 = client.post("/auth/login", json={"username": "nobody_valid", "password": "pass123456"})
|
||||
check("login nonexistent", r5.status_code == 401, str(r5.status_code))
|
||||
|
||||
# ===== TOKEN VALIDATION =====
|
||||
print("\n=== TOKEN ===")
|
||||
ah = {"Authorization": f"Bearer {tok}"}
|
||||
authed = httpx.Client(base_url=BASE, timeout=10, headers=ah)
|
||||
r = authed.get("/auth/pass/my")
|
||||
check("pass/my authed", r.status_code in (200, 404), str(r.status_code))
|
||||
|
||||
r2 = client.get("/auth/pass/my")
|
||||
check("pass/my unauthed", r2.status_code == 401, str(r2.status_code))
|
||||
|
||||
# ===== PACKS =====
|
||||
print("\n=== PACKS ===")
|
||||
r = client.get("/packs")
|
||||
check("packs unauthed rejects", r.status_code in (401, 403), str(r.status_code))
|
||||
r2 = authed.get("/packs")
|
||||
check("packs authed succeeds", r2.status_code in (200, 403), str(r2.status_code))
|
||||
if r2.status_code == 200:
|
||||
pdata = r2.json()
|
||||
check("packs has 'packs' key", "packs" in pdata)
|
||||
|
||||
# ===== FRIENDS =====
|
||||
print("\n=== FRIENDS ===")
|
||||
r = client.get("/api/friends/list")
|
||||
check("friends/list unauthed", r.status_code == 401, str(r.status_code))
|
||||
r = authed.get("/api/friends/list")
|
||||
check("friends/list authed", r.status_code == 200, str(r.status_code))
|
||||
check("friends/list has friends key", "friends" in r.json())
|
||||
|
||||
r = authed.get("/api/friends/requests")
|
||||
check("friends/requests authed", r.status_code == 200, str(r.status_code))
|
||||
check("friends/requests has requests key", "requests" in r.json())
|
||||
|
||||
# Register friend user
|
||||
fts = f"friend_{ts}"
|
||||
r = client.post("/auth/register", json={"username": fts, "password": "pass123"})
|
||||
check("register friend user", r.status_code == 200, str(r.status_code))
|
||||
ftok = r.json().get("access_token", "")
|
||||
fauthed = httpx.Client(base_url=BASE, timeout=10,
|
||||
headers={"Authorization": f"Bearer {ftok}"})
|
||||
# Parse user id from JWT (no account endpoint on Python server)
|
||||
import base64
|
||||
def parse_user_id(token):
|
||||
try:
|
||||
payload_b64 = token.split('.')[1]
|
||||
payload_b64 += '=' * (4 - len(payload_b64) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload_b64))["sub"]
|
||||
except:
|
||||
return 0
|
||||
friend_id = parse_user_id(ftok)
|
||||
my_id = parse_user_id(tok)
|
||||
|
||||
# Add friend
|
||||
r = authed.post("/api/friends/add", json={"username": fts})
|
||||
check("friends add", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
ad = r.json()
|
||||
check("friends add returns message", "message" in ad)
|
||||
|
||||
# Duplicate add
|
||||
r = authed.post("/api/friends/add", json={"username": fts})
|
||||
check("friends add duplicate", r.status_code == 400, str(r.status_code))
|
||||
|
||||
# Add self
|
||||
r = authed.post("/api/friends/add", json={"username": tu})
|
||||
check("friends add self", r.status_code == 400, str(r.status_code))
|
||||
|
||||
# Add nonexistent
|
||||
r = authed.post("/api/friends/add", json={"username": "nobody_xyz_123"})
|
||||
check("friends add nonexistent", r.status_code == 404, str(r.status_code))
|
||||
|
||||
# Accept friend request (as friend)
|
||||
r = fauthed.post("/api/friends/accept", json={"user_id": my_id})
|
||||
check("friends accept", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
|
||||
# Remove nonexistent
|
||||
r = authed.post("/api/friends/remove", json={"user_id": 99999})
|
||||
check("friends remove nonexistent", r.status_code == 404, str(r.status_code))
|
||||
|
||||
# Remove friend
|
||||
r = authed.post("/api/friends/remove", json={"user_id": friend_id})
|
||||
check("friends remove", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
|
||||
# ===== PLAYTIME =====
|
||||
print("\n=== PLAYTIME ===")
|
||||
r = authed.post("/api/playtime/sync", json={"pack_name": "test_pack", "minutes": 30})
|
||||
check("playtime sync", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
|
||||
r = authed.get("/api/playtime/stats")
|
||||
check("playtime stats", r.status_code == 200, str(r.status_code))
|
||||
st = r.json()
|
||||
check("playtime has total_minutes", "total_minutes" in st)
|
||||
check("playtime has packs", "packs" in st)
|
||||
|
||||
r = client.get("/api/playtime/stats")
|
||||
check("playtime stats unauthed", r.status_code == 401, str(r.status_code))
|
||||
|
||||
# ===== SUMMARY =====
|
||||
total = passed + failed
|
||||
print(f"\n{'='*40}")
|
||||
print(f"RESULTS: {passed}/{total} PASSED, {failed}/{total} FAILED")
|
||||
if failed > 0:
|
||||
print("SOME TESTS FAILED!")
|
||||
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
sys.exit(1 if failed else 0)
|
||||
+1
-1
@@ -23,7 +23,7 @@ def parse_args():
|
||||
parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
||||
|
||||
return parser.parse_args()
|
||||
return parser.parse_known_args()[0]
|
||||
|
||||
async def run_test_mode():
|
||||
"""Validate all packs and generate/update manifests"""
|
||||
|
||||
+4
-4
@@ -232,10 +232,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# ====================== MIRROR SYNC HELPERS ======================
|
||||
|
||||
async def _calculate_file_hash_async(file_path: Path) -> Optional[str]:
|
||||
"""Calculate SHA256 hash of a file asynchronously"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
async def _calculate_file_hash_async(file_path: Path) -> Optional[str]:
|
||||
"""Calculate SHA256 hash of a file asynchronously"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
def _hash():
|
||||
h = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
|
||||
+4
-4
@@ -48,9 +48,9 @@ async def sync_playtime(
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO playtime (user_id, pack_name, minutes) VALUES (?, ?, ?)",
|
||||
(current_user["user_id"], req.pack_name, req.minutes)
|
||||
(current_user["id"], req.pack_name, req.minutes)
|
||||
)
|
||||
logger.info("Playtime synced", user=current_user["user_id"], minutes=req.minutes)
|
||||
logger.info("Playtime synced", user=current_user["id"], minutes=req.minutes)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/playtime/stats")
|
||||
@@ -60,13 +60,13 @@ async def get_playtime_stats(current_user: dict = Depends(get_current_user)):
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT COALESCE(SUM(minutes), 0) FROM playtime WHERE user_id = ?",
|
||||
(current_user["user_id"],)
|
||||
(current_user["id"],)
|
||||
)
|
||||
total_minutes = rows.fetchone()[0]
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT pack_name, minutes FROM playtime WHERE user_id = ? AND pack_name != '' ORDER BY minutes DESC",
|
||||
(current_user["user_id"],)
|
||||
(current_user["id"],)
|
||||
)
|
||||
for row in rows:
|
||||
pack_stats.append({
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import sys, os, json, time, subprocess, signal
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'server'))
|
||||
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
|
||||
BASE = "http://127.0.0.1:1589"
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
print(f" PASS {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {name} {detail}")
|
||||
failed += 1
|
||||
|
||||
def start_server():
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = os.path.join(os.path.dirname(__file__), "server")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", "1589"],
|
||||
cwd=os.path.join(os.path.dirname(__file__), "server"),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
for i in range(30):
|
||||
try:
|
||||
r = httpx.get(f"{BASE}/news", timeout=2)
|
||||
if r.status_code == 200:
|
||||
return proc
|
||||
except:
|
||||
time.sleep(1)
|
||||
return proc
|
||||
|
||||
proc = None
|
||||
try:
|
||||
print("Starting server...")
|
||||
proc = start_server()
|
||||
print("Server started\n")
|
||||
client = httpx.Client(base_url=BASE, timeout=10)
|
||||
|
||||
# ---------- NEWS ----------
|
||||
print("=== NEWS ===")
|
||||
r = client.get("/news")
|
||||
check("GET /news", r.status_code == 200 and "news" in r.json())
|
||||
data = r.json()
|
||||
check("news is array", isinstance(data.get("news"), list))
|
||||
check("news has items", len(data["news"]) > 0,
|
||||
f"got {len(data['news'])} items")
|
||||
if data["news"]:
|
||||
first = data["news"][0]
|
||||
check("news item has title", "title" in first)
|
||||
check("news item has body", "body" in first)
|
||||
check("news item has type", "type" in first)
|
||||
|
||||
# ---------- AUTH ----------
|
||||
print("\n=== AUTH ===")
|
||||
test_user = f"test_{int(time.time())}"
|
||||
test_pass = "test123"
|
||||
r = client.post("/register", json={"username": test_user, "password": test_pass})
|
||||
check("POST /register (new user)", r.status_code == 200)
|
||||
reg_data = r.json()
|
||||
check("register returns token", "access_token" in reg_data)
|
||||
|
||||
r2 = client.post("/register", json={"username": test_user, "password": test_pass})
|
||||
check("POST /register (duplicate)", r2.status_code == 200)
|
||||
|
||||
r3 = client.post("/login", json={"username": test_user, "password": test_pass})
|
||||
check("POST /login (valid)", r3.status_code == 200)
|
||||
login_data = r3.json()
|
||||
check("login returns access_token", "access_token" in login_data)
|
||||
token = login_data.get("access_token", "")
|
||||
|
||||
r4 = client.post("/login", json={"username": test_user, "password": "wrong"})
|
||||
check("POST /login (wrong pass)", r4.status_code == 401)
|
||||
|
||||
r5 = client.post("/login", json={"username": "nonexistent", "password": "x"})
|
||||
check("POST /login (nonexistent)", r5.status_code == 401)
|
||||
|
||||
authed = httpx.Client(base_url=BASE, timeout=10, headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
# ---------- ACCOUNT ----------
|
||||
print("\n=== ACCOUNT ===")
|
||||
r = authed.get("/account")
|
||||
check("GET /account (authed)", r.status_code == 200)
|
||||
acct = r.json()
|
||||
check("account has username", acct.get("username") == test_user,
|
||||
f"got {acct.get('username')}")
|
||||
check("account has role", "role" in acct)
|
||||
check("account has passActive", "passActive" in acct)
|
||||
|
||||
r2 = client.get("/account")
|
||||
check("GET /account (unauthed)", r2.status_code == 401)
|
||||
|
||||
# ---------- PACKS ----------
|
||||
print("\n=== PACKS ===")
|
||||
r = client.get("/packs")
|
||||
check("GET /packs (unauthed)", r.status_code == 401 or r.status_code == 403,
|
||||
f"got {r.status_code}")
|
||||
|
||||
r2 = authed.get("/packs")
|
||||
check("GET /packs (authed)", r2.status_code in (200, 403),
|
||||
f"got {r2.status_code}")
|
||||
if r2.status_code == 200:
|
||||
packs = r2.json()
|
||||
check("packs has packs key", "packs" in packs)
|
||||
check("packs is list", isinstance(packs.get("packs"), list))
|
||||
|
||||
# ---------- FRIENDS (no auth) ----------
|
||||
print("\n=== FRIENDS ===")
|
||||
r = client.get("/api/friends/list")
|
||||
check("GET /friends/list (unauthed)", r.status_code == 401)
|
||||
|
||||
r2 = authed.get("/api/friends/list")
|
||||
check("GET /friends/list (authed)", r2.status_code == 200)
|
||||
flist = r2.json()
|
||||
check("friends list has friends key", "friends" in flist)
|
||||
|
||||
r3 = authed.get("/api/friends/requests")
|
||||
check("GET /friends/requests (authed)", r3.status_code == 200)
|
||||
freq = r3.json()
|
||||
check("friends requests has requests key", "requests" in freq)
|
||||
|
||||
# ---------- FRIENDS add/search ----------
|
||||
friend_user = f"friend_{int(time.time())}"
|
||||
r = client.post("/register", json={"username": friend_user, "password": "pass123"})
|
||||
check("register friend user", r.status_code == 200)
|
||||
friend_token = r.json().get("access_token", "")
|
||||
friend_authed = httpx.Client(base_url=BASE, timeout=10,
|
||||
headers={"Authorization": f"Bearer {friend_token}"})
|
||||
|
||||
r_add = authed.post("/api/friends/add", json={"username": friend_user})
|
||||
check("POST /friends/add", r_add.status_code == 200,
|
||||
f"got {r_add.status_code}: {r_add.text[:100]}")
|
||||
|
||||
r_dup = authed.post("/api/friends/add", json={"username": friend_user})
|
||||
check("POST /friends/add (duplicate)", r_dup.status_code == 400)
|
||||
|
||||
r_self = authed.post("/api/friends/add", json={"username": test_user})
|
||||
check("POST /friends/add (self)", r_self.status_code == 400)
|
||||
|
||||
r_nonexist = authed.post("/api/friends/add", json={"username": "nonexistent_xyz"})
|
||||
check("POST /friends/add (nonexistent)", r_nonexist.status_code == 404)
|
||||
|
||||
# Friend accepts
|
||||
r_accept = friend_authed.post("/api/friends/accept", json={"user_id": acct.get("id", 0)})
|
||||
check("POST /friends/accept", r_accept.status_code in (200, 404),
|
||||
f"got {r_accept.status_code}: {r_accept.text[:100]}")
|
||||
|
||||
# Check friends list now shows each other
|
||||
r_list = authed.get("/api/friends/list")
|
||||
check("GET /friends/list (after accept)", r_list.status_code == 200)
|
||||
|
||||
# Remove friend
|
||||
friend_acct = r.json()
|
||||
r_remove = authed.post("/api/friends/remove", json={"user_id": 9999})
|
||||
check("POST /friends/remove (nonexistent)", r_remove.status_code == 404)
|
||||
|
||||
# ---------- PLAYTIME ----------
|
||||
print("\n=== PLAYTIME ===")
|
||||
r = authed.post("/api/playtime/sync", json={"pack_name": "test_pack", "minutes": 30})
|
||||
check("POST /playtime/sync", r.status_code == 200, r.text[:100])
|
||||
|
||||
r2 = authed.get("/api/playtime/stats")
|
||||
check("GET /playtime/stats", r2.status_code == 200)
|
||||
stats = r2.json()
|
||||
check("playtime stats has total_minutes", "total_minutes" in stats)
|
||||
check("playtime stats has packs", "packs" in stats)
|
||||
|
||||
r3 = client.get("/api/playtime/stats")
|
||||
check("GET /playtime/stats (unauthed)", r3.status_code == 401)
|
||||
|
||||
# ---------- SUMMARY ----------
|
||||
total = passed + failed
|
||||
print(f"\n{'='*40}")
|
||||
print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nTEST ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if proc:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
sys.exit(1 if failed > 0 else 0)
|
||||
Reference in New Issue
Block a user