mirror of
https://git.sanhost.net/sanasol/hytale-f2p.git
synced 2026-02-27 15:21:47 -03:00
Compare commits
4 Commits
macos-nota
...
fix/uuid-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14dcf3dac3 | ||
|
|
34a3e40bd2 | ||
|
|
d15f2e2ceb | ||
|
|
74f99d0aaf |
@@ -1,2 +0,0 @@
|
|||||||
HF2P_SECRET_KEY=YOUR_KEY_HERE
|
|
||||||
HF2P_PROXY_URL=YOUR_PROXY
|
|
||||||
2
.github/CODE_OF_CONDUCT.md
vendored
2
.github/CODE_OF_CONDUCT.md
vendored
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
|||||||
|
|
||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [Discord Server, message Founders/Devs](https://discord.gg/hf2pdc). All complaints will be reviewed and investigated promptly and fairly.
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||||
|
|
||||||
|
|||||||
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Create a report to help us improve
|
description: Create a report to help us improve
|
||||||
title: "[BUG] <Insert Bug Title Here>"
|
title: "[BUG] "
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
@@ -51,7 +51,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Version
|
label: Version
|
||||||
description: What version of the launcher are you running?
|
description: What version of the launcher are you running?
|
||||||
placeholder: "e.g. \"v2.2.1\""
|
placeholder: "e.g. \"v2.2.0 stable\""
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -59,9 +59,7 @@ body:
|
|||||||
id: hardwarespec
|
id: hardwarespec
|
||||||
attributes:
|
attributes:
|
||||||
label: Hardware Specification
|
label: Hardware Specification
|
||||||
description: |
|
description: Tell us your CPU, iGPU, dGPU, VRAM, and RAM information.
|
||||||
Tell us your CPU, iGPU, dGPU, VRAM, and RAM information.
|
|
||||||
(Use N/A if you think this is not correlated with the bug)
|
|
||||||
placeholder: "CPU: Intel i9-14900K 6.0 GHz | GPU: NVIDIA RTX 4090 24 GB VRAM | RAM: 32 GB"
|
placeholder: "CPU: Intel i9-14900K 6.0 GHz | GPU: NVIDIA RTX 4090 24 GB VRAM | RAM: 32 GB"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -74,9 +72,9 @@ body:
|
|||||||
options:
|
options:
|
||||||
- Windows 11/10
|
- Windows 11/10
|
||||||
- macOS (Apple Silicon, M1/M2/M3)
|
- macOS (Apple Silicon, M1/M2/M3)
|
||||||
- Linux Ubuntu/Debian-based (Linux Mint, Pop!_OS, Zorin OS, etc.)
|
- Linux Ubuntu/Debian-based (Linux Mint, Pop!_OS, etc.)
|
||||||
- Linux Fedora/RHEL-based (Fedora, Bazzite, CentOS, etc.)
|
- Linux Fedora/RHEL-based (Fedora, CentOS, etc.)
|
||||||
- Linux Arch-based (Steamdeck, CachyOS, ArchLinux, etc.)
|
- Linux Arch-based (Steamdeck, CachyOS, etc.)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -63,7 +63,6 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What launcher version are you using?
|
description: What launcher version are you using?
|
||||||
options:
|
options:
|
||||||
- v2.2.1
|
|
||||||
- v2.2.0
|
- v2.2.0
|
||||||
- v2.1.1
|
- v2.1.1
|
||||||
- v2.1.0
|
- v2.1.0
|
||||||
|
|||||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -38,14 +38,6 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- name: Build macOS Packages
|
- name: Build macOS Packages
|
||||||
env:
|
|
||||||
# Code signing
|
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
|
||||||
# Notarization
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
run: npx electron-builder --mac --publish never
|
run: npx electron-builder --mac --publish never
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -53,7 +45,6 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
dist/*.dmg
|
dist/*.dmg
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/*.blockmap
|
|
||||||
dist/latest-mac.yml
|
dist/latest-mac.yml
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
@@ -168,6 +159,7 @@ jobs:
|
|||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
artifacts/arch-package/*.pkg.tar.zst
|
artifacts/arch-package/*.pkg.tar.zst
|
||||||
|
artifacts/arch-package/*.src.tar.zst
|
||||||
artifacts/arch-package/.SRCINFO
|
artifacts/arch-package/.SRCINFO
|
||||||
artifacts/linux-builds/**/*
|
artifacts/linux-builds/**/*
|
||||||
artifacts/windows-builds/**/*
|
artifacts/windows-builds/**/*
|
||||||
|
|||||||
@@ -217,7 +217,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="featured-page" class="page">
|
<div id="featured-page" class="page">
|
||||||
<div class="featured-container">
|
<div class="featured-layout">
|
||||||
|
<div class="featured-left">
|
||||||
<div class="featured-header">
|
<div class="featured-header">
|
||||||
<h2 class="featured-title">
|
<h2 class="featured-title">
|
||||||
<i class="fas fa-star mr-2"></i>
|
<i class="fas fa-star mr-2"></i>
|
||||||
@@ -230,6 +231,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="featured-right">
|
||||||
|
<div class="featured-header">
|
||||||
|
<h2 class="featured-title">
|
||||||
|
<i class="fas fa-server mr-2"></i>
|
||||||
|
<span>HF2P SERVERS</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div id="myServersList" class="featured-list">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-spin fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mods-page" class="page">
|
<div id="mods-page" class="page">
|
||||||
@@ -730,7 +746,6 @@
|
|||||||
|
|
||||||
<div class="version-display-bottom">
|
<div class="version-display-bottom">
|
||||||
<i class="fas fa-code-branch"></i>
|
<i class="fas fa-code-branch"></i>
|
||||||
<span id="launcherVersion"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="fixed bottom-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-sm px-4 py-2">
|
<footer class="fixed bottom-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-sm px-4 py-2">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function escapeHtml(text) {
|
|||||||
*/
|
*/
|
||||||
async function loadFeaturedServers() {
|
async function loadFeaturedServers() {
|
||||||
const featuredContainer = document.getElementById('featuredServersList');
|
const featuredContainer = document.getElementById('featuredServersList');
|
||||||
|
const myServersContainer = document.getElementById('myServersList');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[FeaturedServers] Fetching from', FEATURED_SERVERS_API);
|
console.log('[FeaturedServers] Fetching from', FEATURED_SERVERS_API);
|
||||||
@@ -53,15 +54,6 @@ async function loadFeaturedServers() {
|
|||||||
const escapedName = escapeHtml(server.Name || 'Unknown Server');
|
const escapedName = escapeHtml(server.Name || 'Unknown Server');
|
||||||
const escapedAddress = escapeHtml(server.Address || '');
|
const escapedAddress = escapeHtml(server.Address || '');
|
||||||
const bannerUrl = server.img_Banner || 'https://via.placeholder.com/400x240/1e293b/ffffff?text=Server+Banner';
|
const bannerUrl = server.img_Banner || 'https://via.placeholder.com/400x240/1e293b/ffffff?text=Server+Banner';
|
||||||
const discordUrl = server.discord || '';
|
|
||||||
|
|
||||||
// Build Discord button HTML if discord link exists
|
|
||||||
const discordButton = discordUrl ? `
|
|
||||||
<button class="server-discord-btn" onclick="openServerDiscord('${discordUrl}')">
|
|
||||||
<i class="fab fa-discord"></i>
|
|
||||||
<span>Discord</span>
|
|
||||||
</button>
|
|
||||||
` : '';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="featured-server-card">
|
<div class="featured-server-card">
|
||||||
@@ -75,13 +67,10 @@ async function loadFeaturedServers() {
|
|||||||
<h3 class="featured-server-name">${escapedName}</h3>
|
<h3 class="featured-server-name">${escapedName}</h3>
|
||||||
<div class="featured-server-address">
|
<div class="featured-server-address">
|
||||||
<span class="server-address-text">${escapedAddress}</span>
|
<span class="server-address-text">${escapedAddress}</span>
|
||||||
<div class="server-action-buttons">
|
|
||||||
<button class="copy-address-btn" onclick="copyServerAddress('${escapedAddress}', this)">
|
<button class="copy-address-btn" onclick="copyServerAddress('${escapedAddress}', this)">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
<span>Copy</span>
|
<span>Copy</span>
|
||||||
</button>
|
</button>
|
||||||
${discordButton}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,6 +80,13 @@ async function loadFeaturedServers() {
|
|||||||
featuredContainer.innerHTML = featuredHTML;
|
featuredContainer.innerHTML = featuredHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show "Coming Soon" for my servers
|
||||||
|
myServersContainer.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #94a3b8; font-size: 1.2rem;">
|
||||||
|
<p>Coming Soon</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FeaturedServers] Error loading servers:', error);
|
console.error('[FeaturedServers] Error loading servers:', error);
|
||||||
featuredContainer.innerHTML = `
|
featuredContainer.innerHTML = `
|
||||||
@@ -100,6 +96,11 @@ async function loadFeaturedServers() {
|
|||||||
<p style="font-size: 0.9rem; color: #64748b;">${error.message}</p>
|
<p style="font-size: 0.9rem; color: #64748b;">${error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
myServersContainer.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #94a3b8; font-size: 1.2rem;">
|
||||||
|
<p>Coming Soon</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,22 +151,6 @@ async function copyServerAddress(address, button) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Open server Discord in external browser
|
|
||||||
*/
|
|
||||||
function openServerDiscord(discordUrl) {
|
|
||||||
try {
|
|
||||||
console.log('[FeaturedServers] Opening Discord:', discordUrl);
|
|
||||||
if (window.electronAPI && window.electronAPI.openExternal) {
|
|
||||||
window.electronAPI.openExternal(discordUrl);
|
|
||||||
} else {
|
|
||||||
window.open(discordUrl, '_blank');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FeaturedServers] Failed to open Discord link:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load featured servers when the featured page becomes visible
|
// Load featured servers when the featured page becomes visible
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
|||||||
@@ -22,13 +22,13 @@
|
|||||||
"installationFolder": "Kurulum Klasörü",
|
"installationFolder": "Kurulum Klasörü",
|
||||||
"pathPlaceholder": "Varsayılan konum",
|
"pathPlaceholder": "Varsayılan konum",
|
||||||
"browse": "Gözat",
|
"browse": "Gözat",
|
||||||
"installButton": "HYTALE KUR",
|
"installButton": "HYTALE KURU",
|
||||||
"installing": "KURULUYOR..."
|
"installing": "KURULUYOR..."
|
||||||
},
|
},
|
||||||
"play": {
|
"play": {
|
||||||
"ready": "OYNAMAYA HAZIR",
|
"ready": "OYNAMAYA HAZIR",
|
||||||
"subtitle": "Hytale'ı başlat ve maceraya başla",
|
"subtitle": "Hytale'i başlat ve maceraya başla",
|
||||||
"playButton": "HYTALE'I OYNA",
|
"playButton": "HYTALE'YI OYNA",
|
||||||
"latestNews": "SON HABERLER",
|
"latestNews": "SON HABERLER",
|
||||||
"viewAll": "HEPSINI GÖR",
|
"viewAll": "HEPSINI GÖR",
|
||||||
"checking": "KONTROL EDİLİYOR...",
|
"checking": "KONTROL EDİLİYOR...",
|
||||||
@@ -47,13 +47,13 @@
|
|||||||
"noModsInstalled": "Hiçbir Mod Kurulu Değil",
|
"noModsInstalled": "Hiçbir Mod Kurulu Değil",
|
||||||
"noModsInstalledDesc": "CurseForge'dan modlar ekleyin veya yerel dosyalar içe aktarın",
|
"noModsInstalledDesc": "CurseForge'dan modlar ekleyin veya yerel dosyalar içe aktarın",
|
||||||
"view": "GÖR",
|
"view": "GÖR",
|
||||||
"install": "KUR",
|
"install": "KURU",
|
||||||
"installed": "KURULU",
|
"installed": "KURULU",
|
||||||
"enable": "AÇ",
|
"enable": "ETKİNLEŞTİR",
|
||||||
"disable": "KAPAT",
|
"disable": "DEĞİ",
|
||||||
"active": "AKTİF",
|
"active": "AKTİF",
|
||||||
"disabled": "DEVREDIŞI",
|
"disabled": "DEĞİ",
|
||||||
"delete": "Modu sil",
|
"delete": "Modı sil",
|
||||||
"noDescription": "Açıklama yok",
|
"noDescription": "Açıklama yok",
|
||||||
"confirmDelete": "\"{name}\" öğesini silmek istediğinizden emin misiniz?",
|
"confirmDelete": "\"{name}\" öğesini silmek istediğinizden emin misiniz?",
|
||||||
"confirmDeleteDesc": "Bu işlem geri alınamaz.",
|
"confirmDeleteDesc": "Bu işlem geri alınamaz.",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"title": "OYUNCU SOHBETI",
|
"title": "OYUNCU SOHBETI",
|
||||||
"pickColor": "Renk Seç",
|
"pickColor": "Renk",
|
||||||
"inputPlaceholder": "Mesajınızı yazın...",
|
"inputPlaceholder": "Mesajınızı yazın...",
|
||||||
"send": "Gönder",
|
"send": "Gönder",
|
||||||
"online": "çevrimiçi",
|
"online": "çevrimiçi",
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
"manageUUIDsDesc": "Tüm oyuncu UUID'lerini görüntüleyin ve yönetin",
|
"manageUUIDsDesc": "Tüm oyuncu UUID'lerini görüntüleyin ve yönetin",
|
||||||
"language": "Dil",
|
"language": "Dil",
|
||||||
"selectLanguage": "Dil Seçin",
|
"selectLanguage": "Dil Seçin",
|
||||||
"repairGame": "Oyunu Düzelt",
|
"repairGame": "Oyunu Onarı",
|
||||||
"reinstallGame": "Oyun dosyalarını yeniden kur (veri korur)",
|
"reinstallGame": "Oyun dosyalarını yeniden kur (veri korur)",
|
||||||
"gpuPreference": "GPU Tercihi",
|
"gpuPreference": "GPU Tercihi",
|
||||||
"gpuHint": "Sadece dizüstü bilgisayarlarda bulunan bir özellik; PC'de kullanılıyorsa Entegre olarak ayarlayın.",
|
"gpuHint": "Sadece dizüstü bilgisayarlarda bulunan bir özellik; PC'de kullanılıyorsa Entegre olarak ayarlayın.",
|
||||||
@@ -255,4 +255,3 @@
|
|||||||
"installComplete": "Kurulum tamamlandı!"
|
"installComplete": "Kurulum tamamlandı!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1005,12 +1005,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Featured Servers Styles */
|
/* Featured Servers Styles */
|
||||||
.featured-container {
|
.featured-layout {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
height: calc(100vh - 180px);
|
height: calc(100vh - 180px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 2rem;
|
}
|
||||||
|
|
||||||
|
.featured-left,
|
||||||
|
.featured-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-header {
|
.featured-header {
|
||||||
@@ -1066,8 +1074,8 @@ body {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 300px 1fr;
|
grid-template-columns: 200px 1fr;
|
||||||
min-height: 180px;
|
min-height: 120px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1078,24 +1086,24 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.featured-server-banner {
|
.featured-server-banner {
|
||||||
width: 300px;
|
width: 200px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 180px;
|
min-height: 120px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: linear-gradient(135deg, #1e293b, #334155);
|
background: linear-gradient(135deg, #1e293b, #334155);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-server-content {
|
.featured-server-content {
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featured-server-name {
|
.featured-server-name {
|
||||||
font-size: 1.35rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
color: white;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -1110,40 +1118,27 @@ body {
|
|||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-address-text {
|
.server-address-text {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-action-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-address-btn {
|
.copy-address-btn {
|
||||||
background: linear-gradient(135deg, #9333ea, #7c3aed);
|
background: linear-gradient(135deg, #9333ea, #7c3aed);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.5rem 0.875rem;
|
padding: 0.5rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.5rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,31 +1155,6 @@ body {
|
|||||||
background: linear-gradient(135deg, #10b981, #059669);
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-discord-btn {
|
|
||||||
background: linear-gradient(135deg, #5865F2, #4752C4);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-discord-btn:hover {
|
|
||||||
background: linear-gradient(135deg, #4752C4, #3c45a5);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-discord-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -2,7 +2,7 @@
|
|||||||
# Maintainer: Fazri Gading <fazrigading@gmail.com>
|
# Maintainer: Fazri Gading <fazrigading@gmail.com>
|
||||||
# This PKGBUILD is for Github Releases
|
# This PKGBUILD is for Github Releases
|
||||||
pkgname=Hytale-F2P
|
pkgname=Hytale-F2P
|
||||||
pkgver=2.2.1
|
pkgver=2.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Hytale-F2P - unofficial Hytale Launcher for free to play with multiplayer support"
|
pkgdesc="Hytale-F2P - unofficial Hytale Launcher for free to play with multiplayer support"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -7,18 +7,19 @@
|
|||||||
<p><small>An unofficial cross-platform launcher for Hytale with automatic updates and multiplayer support!</small></p>
|
<p><small>An unofficial cross-platform launcher for Hytale with automatic updates and multiplayer support!</small></p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/releases)
|

|
||||||
[](https://github.com/amiayweb/Hytale-F2P/releases)
|

|
||||||
[](https://github.com/amiayweb/Hytale-F2P/releases)
|

|
||||||
|

|
||||||
|
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/stargazers)
|
[](https://github.com/amiayweb/Hytale-F2P/stargazers)
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/network/members)
|
[](https://github.com/amiayweb/Hytale-F2P/network/members)
|
||||||
[](https://github.com/amiayweb/Hytale-F2P/issues)
|
|
||||||

|
|
||||||
|
|
||||||
### ⚠️ **WARNING: READ [QUICK START](#-quick-start) before Downloading & Installing the Launcher!** ⚠️
|
⭐ **If you find this project useful, please give it a STAR!** ⭐
|
||||||
|
|
||||||
#### 🛑 **Found a problem? [Join the HF2P Discord](https://discord.gg/hf2pdc) and head to `#-⚠️-community-help`** 🛑
|
### ⚠️ **READ [QUICK START](README.md#-quick-start) before Downloading & Installing the Launcher!** ⚠️
|
||||||
|
|
||||||
|
#### 🛑 **Found a problem? Join the Discord and Select #Open-A-Ticket!: https://discord.gg/gME8rUy3MB** 🛑
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
👍 If you like the project, <b>feel free to support us via Buy Me a Coffee!</b> ☕<br>
|
👍 If you like the project, <b>feel free to support us via Buy Me a Coffee!</b> ☕<br>
|
||||||
@@ -29,15 +30,9 @@
|
|||||||
<img src="https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExem14OW1tanN3eHlyYmR4NW1sYmJkOTZmbmJxejdjZXB6MXY5cW12MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/TDQOtnWgsBx99cNoyH/giphy.gif" width="120">
|
<img src="https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExem14OW1tanN3eHlyYmR4NW1sYmJkOTZmbmJxejdjZXB6MXY5cW12MSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/TDQOtnWgsBx99cNoyH/giphy.gif" width="120">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
⭐ **If you find this project useful, please give it a STAR!** ⭐
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#amiayweb/Hytale-F2P&type=date&legend=top-left)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -172,17 +167,17 @@
|
|||||||
### 🪟 Windows Prequisites
|
### 🪟 Windows Prequisites
|
||||||
* **Java JDK 25:**
|
* **Java JDK 25:**
|
||||||
* [Oracle](https://www.oracle.com/java/technologies/downloads/#jdk25-windows)
|
* [Oracle](https://www.oracle.com/java/technologies/downloads/#jdk25-windows)
|
||||||
* or [Alt 1: Adoptium](https://adoptium.net/temurin/releases/?version=25)
|
* [Adoptium](https://adoptium.net/temurin/releases/?version=25)
|
||||||
* or [Alt 2: Microsoft](https://learn.microsoft.com/en-us/java/openjdk/download).
|
* [Microsoft](https://learn.microsoft.com/en-us/java/openjdk/download), has Windows ARM64 support in version 25.
|
||||||
* **Latest Visual Studio Redist:**
|
* **Latest Visual Studio Redist:**
|
||||||
* Download via [All-in-One by Techpowerup](https://www.techpowerup.com/download/visual-c-redistributable-runtime-package-all-in-one/)
|
* Download via [Microsoft Visual C++ Redistributable](https://aka.ms/vc14/vc_redist.x64.exe)
|
||||||
* Or [Microsoft Visual C++ Redistributable](https://aka.ms/vc14/vc_redist.x64.exe)
|
* Or [All-in-One by Techpowerup](https://www.techpowerup.com/download/visual-c-redistributable-runtime-package-all-in-one/)
|
||||||
|
|
||||||
### 🐧 Linux Prequisites
|
### 🐧 Linux Prequisites
|
||||||
|
|
||||||
* Make sure you have already installed newest **GPU driver** especially proprietary NVIDIA, consult your distro docs or wiki.
|
* Make sure you have already installed newest **GPU driver** especially proprietary NVIDIA, consult your distro docs or wiki.
|
||||||
* Also make sure that your GPU can be connected to EGL, try checking it first (again, consult your distro docs or wiki) before installing Hytale game via our launcher.
|
* Also make sure that your GPU can be connected to EGL, try checking it first (again, consult your distro docs or wiki) before installing Hytale game via our launcher.
|
||||||
* [Not needed in update v2.2.0+] Install `libpng` package to avoid `SDL3_Image` error:
|
* Install `libpng` package to avoid `SDL3_Image` error:
|
||||||
* `libpng16-16 libpng-dev` for Ubuntu/Debian-based Distro
|
* `libpng16-16 libpng-dev` for Ubuntu/Debian-based Distro
|
||||||
* `libpng libpng-devel` for Fedora/RHEL-based Distro
|
* `libpng libpng-devel` for Fedora/RHEL-based Distro
|
||||||
* `libpng` for Arch-based Distro
|
* `libpng` for Arch-based Distro
|
||||||
@@ -277,8 +272,8 @@ The `.zip` version is useful for users who prefer a portable installation or nee
|
|||||||
|
|
||||||
1. Open your Singleplayer World
|
1. Open your Singleplayer World
|
||||||
2. Pause the game (Esc) > select Online Play > Turn on `Allow Other Players to Join` > Set password if needed > Press `Save`.
|
2. Pause the game (Esc) > select Online Play > Turn on `Allow Other Players to Join` > Set password if needed > Press `Save`.
|
||||||
3. Check the status `Connected via UPnP`, it means you can use the Invite Codes for your friends.
|
3. Check the status `Connected via UPnP`.
|
||||||
4. If your friends can't connect to your hosted Online-Play feature OR if it's showing `"Restricted (no UPnP)`, please follow the Tailscale/Playit.gg/Radmin tutorial in [SERVER.md](SERVER.md).
|
4. If your friends can't connect to your hosted Online-Play feature, please follow **Local Dedicated Server** tutorial.
|
||||||
|
|
||||||
## 🖧 Host a Dedicated Server
|
## 🖧 Host a Dedicated Server
|
||||||
|
|
||||||
@@ -289,10 +284,10 @@ The `.zip` version is useful for users who prefer a portable installation or nee
|
|||||||
> Use services like Playit.gg, Tailscale, Radmin VPN to share UDP connection if setting up router as an admin is not possible.
|
> Use services like Playit.gg, Tailscale, Radmin VPN to share UDP connection if setting up router as an admin is not possible.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> `HytaleServer.rar` file is needed to set up a server on non-playing hardware (such as VPS/server hosting). Additional: **Linux ARM64** is supported for server only, not client.
|
> `Hytale-F2P-Server.rar` file is needed to set up a server on non-playing hardware (such as VPS/server hosting). Additional: **Linux ARM64** is supported for server only, not client.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> See detailed information of setting up a server here: [SERVER.md](SERVER.md).
|
> See detailed information of setting up a server here: [SERVER.md](SERVER.md). Download the latest patched JAR, the patched RAR, or the SH/BAT scripts from channel `#open-public-server` in our Discord Server.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -310,17 +305,7 @@ See [BUILD.md](docs/BUILD.md) for comprehensive build instructions.
|
|||||||
|
|
||||||
## 📋 Changelog
|
## 📋 Changelog
|
||||||
|
|
||||||
### 🆕 v2.2.1
|
### 🆕 v2.2.0
|
||||||
- 👚 **Avatar Not Saving Bug Fix:** FINALLY, the long-awaited avatar saves is now working! 🙌 Show off your avatar skin in our Discord `#-media` text channel! 👀
|
|
||||||
- 🚀 **HytaleClient Fails to Launch and Persists in Task Manager Bug Fix:** Major bug fix for all affected Windows users! No more ghost processes of `HytaleClient.exe` in Task Manager! And no more launch fail, that's hella one of an achievement 🔥 (If problem persists please create issue on Github 😢)
|
|
||||||
- 🚦 **EPERM Bug Fix in 'Repair Game' Button:** Repair game will not produce Error Permission (EPERM) any more.
|
|
||||||
- 🚨 **'Server Failed to Boot' Bug Fix:** Happy news for internet-limited countries (e.g. 🇷🇺 Russia, 🇹🇷 Turkey, 🇧🇷 Brazil, etc.)! The launcher now using proxy to access our patched JAR & check game version release status!🎉 Make sure you're already allow the `HytaleClient.exe` on Public & Private Windows Firewall 😉!
|
|
||||||
- ⚡ **GPU Detection System Enhancements:** The detection system will now detect your GPU with `CimInstance` instead of `WmicObject`, which deprecated for most Windows 11 updates. Also, it's show how much your VRAM on each iGPU and dGPU! 🔍
|
|
||||||
- ⚠️ **Failed to Deserialize Packets Bug Fix:** Shared `libzstd` library didn't get detected in Fedora/Bazzite/RHEL-based Linux Distros due to incorrect checking library order. 📑
|
|
||||||
- 📟 **UUID Persistence Bug Fix:** Correlates to the avatar not saving bug, this fixes the persistence UUID when changing username. 🔖
|
|
||||||
- 🌐 **Turkish Translation Fix:** 🇹🇷 Turkey players should feel at home now. 🏠
|
|
||||||
|
|
||||||
### 🔄 v2.2.0
|
|
||||||
- 🔃 **Game Patches Auto-Update Improvement:** No need to install 1.5GB for every updates! Game updates now reduced to almost **~90%** (Hytale Game Update 3 to 4 only take ~150MB).
|
- 🔃 **Game Patches Auto-Update Improvement:** No need to install 1.5GB for every updates! Game updates now reduced to almost **~90%** (Hytale Game Update 3 to 4 only take ~150MB).
|
||||||
- 🩹 **Improved Patch System Pre-Release JAR:** In previous version, only Release JAR could be patched. Now it also can be used for Pre-Release JAR!
|
- 🩹 **Improved Patch System Pre-Release JAR:** In previous version, only Release JAR could be patched. Now it also can be used for Pre-Release JAR!
|
||||||
- 🔗 **Fix Mods Manager Issue:** Mods now can be downloaded seamlessly from the launcher, use Profiles to install your preferred mod. It will also automatically copy from selected `Profile/<profilename>` to the `Mods` folder.
|
- 🔗 **Fix Mods Manager Issue:** Mods now can be downloaded seamlessly from the launcher, use Profiles to install your preferred mod. It will also automatically copy from selected `Profile/<profilename>` to the `Mods` folder.
|
||||||
@@ -450,12 +435,23 @@ See [BUILD.md](docs/BUILD.md) for comprehensive build instructions.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📞 Contact Information
|
## 📊 GitHub Stats
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**Questions? Ads? Collaboration? Endorsement? Other business-related?**
|

|
||||||
Message the founders at https://discord.gg/hf2pdc
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**Need help?** Join us: https://discord.gg/gME8rUy3MB
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
Play with friends online! This guide covers both easy in-game hosting and advanced dedicated server setup.
|
||||||
|
|
||||||
### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/hf2pdc**
|
### **DOWNLOAD SERVER FILES (JAR/RAR/SCRIPTS) HERE: https://discord.gg/MEyWUxt77m**
|
||||||
|
|
||||||
**Table of Contents**
|
**Table of Contents**
|
||||||
|
|
||||||
@@ -502,4 +502,3 @@ See [Docker documentation](https://github.com/Hybrowse/hytale-server-docker) for
|
|||||||
- [Hybrowse Docker Image](https://github.com/Hybrowse/hytale-server-docker)
|
- [Hybrowse Docker Image](https://github.com/Hybrowse/hytale-server-docker)
|
||||||
- Auth Server: sanasol.ws
|
- Auth Server: sanasol.ws
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,20 +117,6 @@ function generateLocalTokens(uuid, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
async function launchGame(playerNameOverride = null, progressCallback, javaPathOverride, installPathOverride, gpuPreference = 'auto', branchOverride = null) {
|
||||||
// ==========================================================================
|
|
||||||
// CACHE INVALIDATION: Clear proxyClient module cache to force fresh .env load
|
|
||||||
// This prevents stale cached values from affecting multiple launch attempts
|
|
||||||
// ==========================================================================
|
|
||||||
try {
|
|
||||||
const proxyClientPath = require.resolve('../utils/proxyClient');
|
|
||||||
if (require.cache[proxyClientPath]) {
|
|
||||||
delete require.cache[proxyClientPath];
|
|
||||||
console.log('[Launcher] Cleared proxyClient cache for fresh .env load');
|
|
||||||
}
|
|
||||||
} catch (cacheErr) {
|
|
||||||
console.warn('[Launcher] Could not clear proxyClient cache:', cacheErr.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// STEP 1: Validate player identity FIRST (before any other operations)
|
// STEP 1: Validate player identity FIRST (before any other operations)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -252,8 +238,8 @@ async function launchGame(playerNameOverride = null, progressCallback, javaPathO
|
|||||||
if (patchResult.client) {
|
if (patchResult.client) {
|
||||||
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
console.log(` Client: ${patchResult.client.patchCount || 0} occurrences`);
|
||||||
}
|
}
|
||||||
if (patchResult.agent) {
|
if (patchResult.server) {
|
||||||
console.log(` Agent: ${patchResult.agent.alreadyExists ? 'already present' : patchResult.agent.success ? 'downloaded' : 'failed'}`);
|
console.log(` Server: ${patchResult.server.patchCount || 0} occurrences`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('Game patching failed:', patchResult.error);
|
console.warn('Game patching failed:', patchResult.error);
|
||||||
@@ -357,8 +343,10 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
|
|
||||||
const waylandEnv = setupWaylandEnvironment();
|
const waylandEnv = setupWaylandEnvironment();
|
||||||
Object.assign(env, waylandEnv);
|
Object.assign(env, waylandEnv);
|
||||||
|
|
||||||
const gpuEnv = setupGpuEnvironment(gpuPreference);
|
const gpuEnv = setupGpuEnvironment(gpuPreference);
|
||||||
Object.assign(env, gpuEnv);
|
Object.assign(env, gpuEnv);
|
||||||
|
|
||||||
// Linux: Replace bundled libzstd.so with system version to fix glibc 2.41+ crash
|
// Linux: Replace bundled libzstd.so with system version to fix glibc 2.41+ crash
|
||||||
// The bundled libzstd causes "free(): invalid pointer" on Steam Deck / Ubuntu LTS
|
// The bundled libzstd causes "free(): invalid pointer" on Steam Deck / Ubuntu LTS
|
||||||
if (process.platform === 'linux' && process.env.HYTALE_NO_LIBZSTD_FIX !== '1') {
|
if (process.platform === 'linux' && process.env.HYTALE_NO_LIBZSTD_FIX !== '1') {
|
||||||
@@ -368,9 +356,9 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
|
|
||||||
// Common system libzstd paths
|
// Common system libzstd paths
|
||||||
const systemLibzstdPaths = [
|
const systemLibzstdPaths = [
|
||||||
'/usr/lib64/libzstd.so.1', // Fedora/RHEL
|
|
||||||
'/usr/lib/libzstd.so.1', // Arch Linux, Steam Deck
|
'/usr/lib/libzstd.so.1', // Arch Linux, Steam Deck
|
||||||
'/usr/lib/x86_64-linux-gnu/libzstd.so.1' // Debian/Ubuntu
|
'/usr/lib/x86_64-linux-gnu/libzstd.so.1', // Debian/Ubuntu
|
||||||
|
'/usr/lib64/libzstd.so.1' // Fedora/RHEL
|
||||||
];
|
];
|
||||||
|
|
||||||
let systemLibzstd = null;
|
let systemLibzstd = null;
|
||||||
@@ -408,17 +396,6 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DualAuth Agent: Set JAVA_TOOL_OPTIONS so java picks up -javaagent: flag
|
|
||||||
// This enables runtime auth patching without modifying the server JAR
|
|
||||||
const agentJar = path.join(gameLatest, 'Server', 'dualauth-agent.jar');
|
|
||||||
if (fs.existsSync(agentJar)) {
|
|
||||||
const agentFlag = `-javaagent:${agentJar}`;
|
|
||||||
env.JAVA_TOOL_OPTIONS = env.JAVA_TOOL_OPTIONS
|
|
||||||
? `${env.JAVA_TOOL_OPTIONS} ${agentFlag}`
|
|
||||||
: agentFlag;
|
|
||||||
console.log('DualAuth Agent: enabled via JAVA_TOOL_OPTIONS');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let spawnOptions = {
|
let spawnOptions = {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
@@ -433,35 +410,23 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
|
|
||||||
const child = spawn(clientPath, args, spawnOptions);
|
const child = spawn(clientPath, args, spawnOptions);
|
||||||
|
|
||||||
// Release process reference immediately so it's truly independent
|
|
||||||
// This works on all platforms (Windows, macOS, Linux)
|
|
||||||
child.unref();
|
|
||||||
|
|
||||||
console.log(`Game process started with PID: ${child.pid}`);
|
console.log(`Game process started with PID: ${child.pid}`);
|
||||||
|
|
||||||
let hasExited = false;
|
let hasExited = false;
|
||||||
let outputReceived = false;
|
let outputReceived = false;
|
||||||
let launchCheckTimeout;
|
|
||||||
|
|
||||||
if (child.stdout) {
|
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data) => {
|
||||||
outputReceived = true;
|
outputReceived = true;
|
||||||
const msg = data.toString().trim();
|
console.log(`Game output: ${data.toString().trim()}`);
|
||||||
console.log(`Game output: ${msg}`);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (child.stderr) {
|
|
||||||
child.stderr.on('data', (data) => {
|
child.stderr.on('data', (data) => {
|
||||||
outputReceived = true;
|
outputReceived = true;
|
||||||
const msg = data.toString().trim();
|
console.error(`Game error: ${data.toString().trim()}`);
|
||||||
console.error(`Game error: ${msg}`);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
hasExited = true;
|
hasExited = true;
|
||||||
clearTimeout(launchCheckTimeout);
|
|
||||||
console.error(`Failed to start game process: ${error.message}`);
|
console.error(`Failed to start game process: ${error.message}`);
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(`Failed to start game: ${error.message}`, -1, null, null, null);
|
progressCallback(`Failed to start game: ${error.message}`, -1, null, null, null);
|
||||||
@@ -470,30 +435,30 @@ exec "$REAL_JAVA" "\${ARGS[@]}"
|
|||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
hasExited = true;
|
hasExited = true;
|
||||||
clearTimeout(launchCheckTimeout);
|
|
||||||
|
|
||||||
if (code !== null) {
|
if (code !== null) {
|
||||||
console.log(`Game process exited with code ${code}`);
|
console.log(`Game process exited with code ${code}`);
|
||||||
if (code !== 0) {
|
if (code !== 0 && progressCallback) {
|
||||||
console.error(`[Launcher] Game crashed or exited with error code ${code}`);
|
|
||||||
if (progressCallback) {
|
|
||||||
progressCallback(`Game exited with error code ${code}`, -1, null, null, null);
|
progressCallback(`Game exited with error code ${code}`, -1, null, null, null);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (signal) {
|
} else if (signal) {
|
||||||
console.log(`Game process terminated by signal ${signal}`);
|
console.log(`Game process terminated by signal ${signal}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process is detached and unref'd - it runs independently from the launcher
|
// Monitor game process status in background
|
||||||
// We cannot reliably detect if the game window actually appears from here,
|
setTimeout(() => {
|
||||||
// so we report success after spawning. stdout/stderr logging above provides debugging info.
|
if (!hasExited) {
|
||||||
console.log('Game process spawned and detached successfully');
|
console.log('Game appears to be running successfully');
|
||||||
|
child.unref();
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback('Game launched successfully', 100, null, null, null);
|
progressCallback('Game launched successfully', 100, null, null, null);
|
||||||
}
|
}
|
||||||
|
} else if (!outputReceived) {
|
||||||
|
console.warn('Game process exited immediately with no output - possible issue with game files or dependencies');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
// Return immediately after spawn
|
// Return immediately, don't wait for setTimeout
|
||||||
return { success: true, installed: true, launched: true, pid: child.pid };
|
return { success: true, installed: true, launched: true, pid: child.pid };
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
console.error(`Error spawning game process: ${spawnError.message}`);
|
console.error(`Error spawning game process: ${spawnError.message}`);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFile, exec } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
const { promisify } = require('util');
|
|
||||||
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
const { getResolvedAppDir, findClientPath, findUserDataPath, findUserDataRecursive, GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths');
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
|
const { downloadFile, retryDownload, retryStalledDownload, MAX_AUTOMATIC_STALL_RETRIES } = require('../utils/fileManager');
|
||||||
@@ -12,57 +11,6 @@ const { downloadAndReplaceHomePageUI, downloadAndReplaceLogo } = require('./uiFi
|
|||||||
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig, loadVersionBranch, saveVersionClient, loadVersionClient } = require('../core/config');
|
const { saveUsername, saveInstallPath, loadJavaPath, CONFIG_FILE, loadConfig, loadVersionBranch, saveVersionClient, loadVersionClient } = require('../core/config');
|
||||||
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
|
const { resolveJavaPath, detectSystemJava, downloadJRE, getJavaExec, getBundledJavaPath } = require('./javaManager');
|
||||||
const { getUserDataPath, migrateUserDataToCentralized } = require('../utils/userDataMigration');
|
const { getUserDataPath, migrateUserDataToCentralized } = require('../utils/userDataMigration');
|
||||||
const userDataBackup = require('../utils/userDataBackup');
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
// Helper function to check if game processes are running
|
|
||||||
async function isGameRunning() {
|
|
||||||
try {
|
|
||||||
let command;
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
// On Windows, check for HytaleClient.exe processes
|
|
||||||
command = 'tasklist /FI "IMAGENAME eq HytaleClient.exe" /NH';
|
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
// On macOS, check for HytaleClient processes
|
|
||||||
command = 'pgrep -f HytaleClient';
|
|
||||||
} else {
|
|
||||||
// On Linux, check for HytaleClient processes
|
|
||||||
command = 'pgrep -f HytaleClient';
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout } = await execAsync(command);
|
|
||||||
return stdout.trim().length > 0;
|
|
||||||
} catch (error) {
|
|
||||||
// If command fails, assume no processes are running
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to safely remove directory with retry logic
|
|
||||||
async function safeRemoveDirectory(dirPath, maxRetries = 3) {
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(dirPath)) {
|
|
||||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
||||||
console.log(`Successfully removed directory: ${dirPath}`);
|
|
||||||
}
|
|
||||||
return; // Success, exit the loop
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Attempt ${attempt}/${maxRetries} failed to remove ${dirPath}: ${error.message}`);
|
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
// Wait before retrying (exponential backoff)
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
|
|
||||||
console.log(`Waiting ${delay}ms before retry...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
} else {
|
|
||||||
// Last attempt failed, throw the error
|
|
||||||
throw new Error(`Failed to remove directory ${dirPath} after ${maxRetries} attempts: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
|
async function downloadPWR(branch = 'release', fileName = '7.pwr', progressCallback, cacheDir = CACHE_DIR, manualRetry = false) {
|
||||||
const osName = getOS();
|
const osName = getOS();
|
||||||
@@ -641,14 +589,8 @@ async function uninstallGame() {
|
|||||||
throw new Error('Game is not installed');
|
throw new Error('Game is not installed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if game is running before attempting to delete files
|
|
||||||
const gameRunning = await isGameRunning();
|
|
||||||
if (gameRunning) {
|
|
||||||
throw new Error('Cannot uninstall game while it is running. Please close the game first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await safeRemoveDirectory(appDir);
|
fs.rmSync(appDir, { recursive: true, force: true });
|
||||||
console.log('Game uninstalled successfully - removed entire HytaleF2P folder');
|
console.log('Game uninstalled successfully - removed entire HytaleF2P folder');
|
||||||
|
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
@@ -730,31 +672,14 @@ async function repairGame(progressCallback, branchOverride = null) {
|
|||||||
progressCallback('Removing old game files...', 30, null, null, null);
|
progressCallback('Removing old game files...', 30, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if game is running before attempting to delete files
|
// Delete Game and Cache Directory
|
||||||
const gameRunning = await isGameRunning();
|
|
||||||
if (gameRunning) {
|
|
||||||
console.warn('[RepairGame] Game appears to be running. This may cause permission errors during repair.');
|
|
||||||
console.log('[RepairGame] Please close the game before repairing, or wait for the repair to complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete Game and Cache Directory with retry logic
|
|
||||||
console.log('Removing corrupted game files...');
|
console.log('Removing corrupted game files...');
|
||||||
try {
|
fs.rmSync(gameDir, { recursive: true, force: true });
|
||||||
await safeRemoveDirectory(gameDir);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[RepairGame] Failed to remove game directory: ${error.message}`);
|
|
||||||
throw new Error(`Cannot repair game: ${error.message}. Please ensure the game is not running and try again.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDir = path.join(appDir, 'cache');
|
const cacheDir = path.join(appDir, 'cache');
|
||||||
if (fs.existsSync(cacheDir)) {
|
if (fs.existsSync(cacheDir)) {
|
||||||
console.log('Clearing cache directory...');
|
console.log('Clearing cache directory...');
|
||||||
try {
|
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||||
await safeRemoveDirectory(cacheDir);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[RepairGame] Failed to clear cache directory: ${error.message}`);
|
|
||||||
// Don't throw here, cache cleanup is not critical
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Reinstalling game files...');
|
console.log('Reinstalling game files...');
|
||||||
|
|||||||
@@ -340,70 +340,36 @@ async function extractJRE(archivePath, destDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractZip(zipPath, dest) {
|
function extractZip(zipPath, dest) {
|
||||||
try {
|
|
||||||
const zip = new AdmZip(zipPath);
|
const zip = new AdmZip(zipPath);
|
||||||
const entries = zip.getEntries();
|
const entries = zip.getEntries();
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = path.join(dest, entry.entryName);
|
const entryPath = path.join(dest, entry.entryName);
|
||||||
|
|
||||||
// Security check: prevent zip slip attacks
|
|
||||||
const resolvedPath = path.resolve(entryPath);
|
const resolvedPath = path.resolve(entryPath);
|
||||||
const resolvedDest = path.resolve(dest);
|
const resolvedDest = path.resolve(dest);
|
||||||
if (!resolvedPath.startsWith(resolvedDest)) {
|
if (!resolvedPath.startsWith(resolvedDest)) {
|
||||||
throw new Error(`Invalid file path detected: ${entryPath}`);
|
throw new Error(`Invalid file path detected: ${entryPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
fs.mkdirSync(entryPath, { recursive: true });
|
fs.mkdirSync(entryPath, { recursive: true });
|
||||||
} else {
|
} else {
|
||||||
// Ensure parent directory exists
|
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
||||||
const parentDir = path.dirname(entryPath);
|
fs.writeFileSync(entryPath, entry.getData());
|
||||||
fs.mkdirSync(parentDir, { recursive: true });
|
|
||||||
|
|
||||||
// Get file data and write it
|
|
||||||
const data = entry.getData();
|
|
||||||
if (!data) {
|
|
||||||
console.warn(`Warning: No data for file ${entry.entryName}, skipping`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(entryPath, data);
|
|
||||||
|
|
||||||
// Set permissions on non-Windows platforms
|
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
try {
|
fs.chmodSync(entryPath, entry.header.attr >>> 16);
|
||||||
const mode = entry.header.attr >>> 16;
|
|
||||||
if (mode > 0) {
|
|
||||||
fs.chmodSync(entryPath, mode);
|
|
||||||
}
|
|
||||||
} catch (chmodError) {
|
|
||||||
console.warn(`Warning: Could not set permissions for ${entryPath}: ${chmodError.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (entryError) {
|
|
||||||
console.error(`Error extracting ${entry.entryName}: ${entryError.message}`);
|
|
||||||
// Continue with other entries rather than failing completely
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to extract ZIP archive: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTarGz(tarGzPath, dest) {
|
function extractTarGz(tarGzPath, dest) {
|
||||||
try {
|
|
||||||
return tar.extract({
|
return tar.extract({
|
||||||
file: tarGzPath,
|
file: tarGzPath,
|
||||||
cwd: dest,
|
cwd: dest,
|
||||||
strip: 0
|
strip: 0
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to extract TAR.GZ archive: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenJREDir(jreLatest) {
|
function flattenJREDir(jreLatest) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { downloadFile, findHomePageUIPath, findLogoPath } = require('../utils/fileManager');
|
const { downloadFile, findHomePageUIPath, findLogoPath } = require('../utils/fileManager');
|
||||||
const { smartRequest } = require('../utils/proxyClient');
|
|
||||||
|
|
||||||
async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
||||||
try {
|
try {
|
||||||
@@ -14,8 +13,7 @@ async function downloadAndReplaceHomePageUI(gameDir, progressCallback) {
|
|||||||
const homeUIUrl = 'https://files.hytalef2p.com/api/HomeUI';
|
const homeUIUrl = 'https://files.hytalef2p.com/api/HomeUI';
|
||||||
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
const tempHomePath = path.join(path.dirname(gameDir), 'HomePage_temp.ui');
|
||||||
|
|
||||||
const response = await smartRequest(homeUIUrl, { responseType: 'arraybuffer' });
|
await downloadFile(homeUIUrl, tempHomePath);
|
||||||
fs.writeFileSync(tempHomePath, response.data);
|
|
||||||
|
|
||||||
const existingHomePath = findHomePageUIPath(gameDir);
|
const existingHomePath = findHomePageUIPath(gameDir);
|
||||||
|
|
||||||
@@ -68,8 +66,7 @@ async function downloadAndReplaceLogo(gameDir, progressCallback) {
|
|||||||
const logoUrl = 'https://files.hytalef2p.com/api/Logo';
|
const logoUrl = 'https://files.hytalef2p.com/api/Logo';
|
||||||
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
const tempLogoPath = path.join(path.dirname(gameDir), 'Logo@2x_temp.png');
|
||||||
|
|
||||||
const response = await smartRequest(logoUrl, { responseType: 'arraybuffer' });
|
await downloadFile(logoUrl, tempLogoPath);
|
||||||
fs.writeFileSync(tempLogoPath, response.data);
|
|
||||||
|
|
||||||
const existingLogoPath = findLogoPath(gameDir);
|
const existingLogoPath = findLogoPath(gameDir);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const axios = require('axios');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { getOS, getArch } = require('../utils/platformUtils');
|
const { getOS, getArch } = require('../utils/platformUtils');
|
||||||
const { smartRequest } = require('../utils/proxyClient');
|
|
||||||
|
|
||||||
const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches';
|
const BASE_PATCH_URL = 'https://game-patches.hytale.com/patches';
|
||||||
const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
|
const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
|
||||||
@@ -10,7 +9,8 @@ const MANIFEST_API = 'https://files.hytalef2p.com/api/patch_manifest';
|
|||||||
async function getLatestClientVersion(branch = 'release') {
|
async function getLatestClientVersion(branch = 'release') {
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching latest client version from API (branch: ${branch})...`);
|
console.log(`Fetching latest client version from API (branch: ${branch})...`);
|
||||||
const response = await smartRequest(`https://files.hytalef2p.com/api/version_client?branch=${branch}`, {
|
const response = await axios.get('https://files.hytalef2p.com/api/version_client', {
|
||||||
|
params: { branch },
|
||||||
timeout: 40000,
|
timeout: 40000,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Hytale-F2P-Launcher'
|
'User-Agent': 'Hytale-F2P-Launcher'
|
||||||
@@ -66,7 +66,8 @@ async function fetchPatchManifest(branch = 'release') {
|
|||||||
try {
|
try {
|
||||||
const os = getOS();
|
const os = getOS();
|
||||||
const arch = getArch();
|
const arch = getArch();
|
||||||
const response = await smartRequest(`${MANIFEST_API}?branch=${branch}&os=${os}&arch=${arch}`, {
|
const response = await axios.get(MANIFEST_API, {
|
||||||
|
params: { branch, os, arch },
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
});
|
});
|
||||||
return response.data.patches || {};
|
return response.data.patches || {};
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { smartDownloadStream } = require('./proxyClient');
|
|
||||||
|
|
||||||
// Domain configuration
|
// Domain configuration
|
||||||
const ORIGINAL_DOMAIN = 'hytale.com';
|
const ORIGINAL_DOMAIN = 'hytale.com';
|
||||||
const MIN_DOMAIN_LENGTH = 4;
|
const MIN_DOMAIN_LENGTH = 4;
|
||||||
const MAX_DOMAIN_LENGTH = 16;
|
const MAX_DOMAIN_LENGTH = 16;
|
||||||
|
|
||||||
// DualAuth ByteBuddy Agent (runtime class transformation, no JAR modification)
|
|
||||||
const DUALAUTH_AGENT_URL = 'https://github.com/sanasol/hytale-auth-server/releases/latest/download/dualauth-agent.jar';
|
|
||||||
const DUALAUTH_AGENT_FILENAME = 'dualauth-agent.jar';
|
|
||||||
|
|
||||||
function getTargetDomain() {
|
function getTargetDomain() {
|
||||||
if (process.env.HYTALE_AUTH_DOMAIN) {
|
if (process.env.HYTALE_AUTH_DOMAIN) {
|
||||||
return process.env.HYTALE_AUTH_DOMAIN;
|
return process.env.HYTALE_AUTH_DOMAIN;
|
||||||
@@ -27,7 +22,7 @@ const DEFAULT_NEW_DOMAIN = 'auth.sanasol.ws';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Patches HytaleClient binary to replace hytale.com with custom domain
|
* Patches HytaleClient binary to replace hytale.com with custom domain
|
||||||
* Server auth is handled by DualAuth ByteBuddy Agent (-javaagent: flag)
|
* Server patching is done via pre-patched JAR download from CDN
|
||||||
*
|
*
|
||||||
* Supports domains from 4 to 16 characters:
|
* Supports domains from 4 to 16 characters:
|
||||||
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
|
* - All F2P traffic routes to single endpoint: https://{domain} (no subdomains)
|
||||||
@@ -498,95 +493,227 @@ class ClientPatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to the DualAuth Agent JAR in a directory
|
* Check if server JAR contains DualAuth classes (was patched)
|
||||||
*/
|
*/
|
||||||
getAgentPath(dir) {
|
serverJarContainsDualAuth(serverPath) {
|
||||||
return path.join(dir, DUALAUTH_AGENT_FILENAME);
|
try {
|
||||||
|
const data = fs.readFileSync(serverPath);
|
||||||
|
// Check for DualAuthContext class signature in JAR
|
||||||
|
const signature = Buffer.from('DualAuthContext', 'utf8');
|
||||||
|
return data.includes(signature);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download DualAuth ByteBuddy Agent (replaces old pre-patched JAR approach)
|
* Validate downloaded file is not corrupt/partial
|
||||||
* The agent provides runtime class transformation via -javaagent: flag
|
* Server JAR should be at least 50MB
|
||||||
* No server JAR modification needed - original JAR stays pristine
|
|
||||||
*/
|
*/
|
||||||
async ensureAgentAvailable(serverDir, progressCallback) {
|
validateServerJarSize(serverPath) {
|
||||||
const agentPath = this.getAgentPath(serverDir);
|
|
||||||
|
|
||||||
console.log('=== DualAuth Agent (ByteBuddy) ===');
|
|
||||||
console.log(`Target: ${agentPath}`);
|
|
||||||
|
|
||||||
// Check if agent already exists and is valid
|
|
||||||
if (fs.existsSync(agentPath)) {
|
|
||||||
try {
|
try {
|
||||||
const stats = fs.statSync(agentPath);
|
const stats = fs.statSync(serverPath);
|
||||||
if (stats.size > 1024) {
|
const minSize = 50 * 1024 * 1024; // 50MB minimum
|
||||||
console.log(`DualAuth Agent present (${(stats.size / 1024).toFixed(0)} KB)`);
|
if (stats.size < minSize) {
|
||||||
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
|
console.error(` Downloaded JAR too small: ${(stats.size / 1024 / 1024).toFixed(2)} MB (expected >50MB)`);
|
||||||
return { success: true, agentPath, alreadyExists: true };
|
return false;
|
||||||
}
|
}
|
||||||
// File exists but too small - corrupt, re-download
|
console.log(` Downloaded size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
||||||
console.log('Agent file appears corrupt, re-downloading...');
|
return true;
|
||||||
fs.unlinkSync(agentPath);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not check agent file:', e.message);
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download agent from GitHub releases
|
/**
|
||||||
if (progressCallback) progressCallback('Downloading DualAuth Agent...', 20);
|
* Patch server JAR by downloading pre-patched version from CDN
|
||||||
console.log(`Downloading from: ${DUALAUTH_AGENT_URL}`);
|
*/
|
||||||
|
async patchServer(serverPath, progressCallback, branch = 'release') {
|
||||||
|
const newDomain = this.getNewDomain();
|
||||||
|
|
||||||
try {
|
console.log('=== Server Patcher (Pre-patched Download) ===');
|
||||||
// Ensure server directory exists
|
console.log(`Target: ${serverPath}`);
|
||||||
if (!fs.existsSync(serverDir)) {
|
console.log(`Branch: ${branch}`);
|
||||||
fs.mkdirSync(serverDir, { recursive: true });
|
console.log(`Domain: ${newDomain}`);
|
||||||
}
|
|
||||||
|
|
||||||
const tmpPath = agentPath + '.tmp';
|
if (!fs.existsSync(serverPath)) {
|
||||||
const file = fs.createWriteStream(tmpPath);
|
const error = `Server JAR not found: ${serverPath}`;
|
||||||
|
|
||||||
const stream = await smartDownloadStream(DUALAUTH_AGENT_URL, (chunk, downloadedBytes, total) => {
|
|
||||||
if (progressCallback && total) {
|
|
||||||
const percent = 20 + Math.floor((downloadedBytes / total) * 70);
|
|
||||||
progressCallback(`Downloading agent... ${(downloadedBytes / 1024).toFixed(0)} KB`, percent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.pipe(file);
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
file.on('finish', () => { file.close(); resolve(); });
|
|
||||||
file.on('error', reject);
|
|
||||||
stream.on('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify download
|
|
||||||
const stats = fs.statSync(tmpPath);
|
|
||||||
if (stats.size < 1024) {
|
|
||||||
fs.unlinkSync(tmpPath);
|
|
||||||
const error = 'Downloaded agent too small (corrupt or failed download)';
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return { success: false, error };
|
return { success: false, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic move
|
// Check if already patched
|
||||||
if (fs.existsSync(agentPath)) {
|
const patchFlagFile = serverPath + '.dualauth_patched';
|
||||||
fs.unlinkSync(agentPath);
|
let needsRestore = false;
|
||||||
}
|
|
||||||
fs.renameSync(tmpPath, agentPath);
|
|
||||||
|
|
||||||
console.log(`DualAuth Agent downloaded (${(stats.size / 1024).toFixed(0)} KB)`);
|
if (fs.existsSync(patchFlagFile)) {
|
||||||
if (progressCallback) progressCallback('DualAuth Agent ready', 100);
|
try {
|
||||||
return { success: true, agentPath };
|
const flagData = JSON.parse(fs.readFileSync(patchFlagFile, 'utf8'));
|
||||||
|
if (flagData.domain === newDomain && flagData.branch === branch) {
|
||||||
|
// Verify JAR actually contains DualAuth classes (game may have auto-updated)
|
||||||
|
if (this.serverJarContainsDualAuth(serverPath)) {
|
||||||
|
console.log(`Server already patched for ${newDomain} (${branch}), skipping`);
|
||||||
|
if (progressCallback) progressCallback('Server already patched', 100);
|
||||||
|
return { success: true, alreadyPatched: true };
|
||||||
|
} else {
|
||||||
|
console.log(' Flag exists but JAR not patched (was auto-updated?), will re-download...');
|
||||||
|
// Delete stale flag file
|
||||||
|
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Server patched for "${flagData.domain}" (${flagData.branch}), need to change to "${newDomain}" (${branch})`);
|
||||||
|
needsRestore = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Flag file corrupt, re-patch
|
||||||
|
console.log(' Flag file corrupt, will re-download');
|
||||||
|
try { fs.unlinkSync(patchFlagFile); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore backup if patched for different domain
|
||||||
|
if (needsRestore) {
|
||||||
|
const backupPath = serverPath + '.original';
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
if (progressCallback) progressCallback('Restoring original for domain change...', 5);
|
||||||
|
console.log('Restoring original JAR from backup for re-patching...');
|
||||||
|
fs.copyFileSync(backupPath, serverPath);
|
||||||
|
if (fs.existsSync(patchFlagFile)) {
|
||||||
|
fs.unlinkSync(patchFlagFile);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(' No backup found to restore - will download fresh patched JAR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
if (progressCallback) progressCallback('Creating backup...', 10);
|
||||||
|
console.log('Creating backup...');
|
||||||
|
const backupResult = this.backupClient(serverPath);
|
||||||
|
if (!backupResult) {
|
||||||
|
console.warn(' Could not create backup - proceeding without backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only support standard domain (auth.sanasol.ws) via pre-patched download
|
||||||
|
if (newDomain !== 'auth.sanasol.ws' && newDomain !== 'sanasol.ws') {
|
||||||
|
console.error(`Domain "${newDomain}" requires DualAuthPatcher - only auth.sanasol.ws is supported via pre-patched download`);
|
||||||
|
return { success: false, error: `Unsupported domain: ${newDomain}. Only auth.sanasol.ws is supported.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download pre-patched JAR
|
||||||
|
if (progressCallback) progressCallback('Downloading patched server JAR...', 30);
|
||||||
|
console.log('Downloading pre-patched HytaleServer.jar...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// Use different URL for pre-release vs release
|
||||||
|
let url;
|
||||||
|
if (branch === 'pre-release') {
|
||||||
|
url = 'https://patcher.authbp.xyz/download/patched_prerelease';
|
||||||
|
console.log(' Using pre-release patched server from:', url);
|
||||||
|
} else {
|
||||||
|
url = 'https://patcher.authbp.xyz/download/patched_release';
|
||||||
|
console.log(' Using release patched server from:', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const handleResponse = (response) => {
|
||||||
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
|
https.get(response.headers.location, handleResponse).on('error', reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = fs.createWriteStream(serverPath);
|
||||||
|
const totalSize = parseInt(response.headers['content-length'], 10);
|
||||||
|
let downloaded = 0;
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
downloaded += chunk.length;
|
||||||
|
if (progressCallback && totalSize) {
|
||||||
|
const percent = 30 + Math.floor((downloaded / totalSize) * 60);
|
||||||
|
progressCallback(`Downloading... ${(downloaded / 1024 / 1024).toFixed(2)} MB`, percent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
https.get(url, handleResponse).on('error', (err) => {
|
||||||
|
fs.unlink(serverPath, () => {});
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(' Download successful');
|
||||||
|
|
||||||
|
// Verify downloaded JAR size and contents
|
||||||
|
if (progressCallback) progressCallback('Verifying downloaded JAR...', 95);
|
||||||
|
|
||||||
|
if (!this.validateServerJarSize(serverPath)) {
|
||||||
|
console.error('Downloaded JAR appears corrupt or incomplete');
|
||||||
|
|
||||||
|
// Restore backup on verification failure
|
||||||
|
const backupPath = serverPath + '.original';
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.copyFileSync(backupPath, serverPath);
|
||||||
|
console.log('Restored backup after verification failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'Downloaded JAR verification failed - file too small (corrupt/partial download)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.serverJarContainsDualAuth(serverPath)) {
|
||||||
|
console.error('Downloaded JAR does not contain DualAuth classes - invalid or corrupt download');
|
||||||
|
|
||||||
|
// Restore backup on verification failure
|
||||||
|
const backupPath = serverPath + '.original';
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.copyFileSync(backupPath, serverPath);
|
||||||
|
console.log('Restored backup after verification failure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'Downloaded JAR verification failed - missing DualAuth classes' };
|
||||||
|
}
|
||||||
|
console.log(' Verification successful - DualAuth classes present');
|
||||||
|
|
||||||
|
// Mark as patched
|
||||||
|
const sourceUrl = branch === 'pre-release'
|
||||||
|
? 'https://patcher.authbp.xyz/download/patched_prerelease'
|
||||||
|
: 'https://patcher.authbp.xyz/download/patched_release';
|
||||||
|
|
||||||
|
fs.writeFileSync(patchFlagFile, JSON.stringify({
|
||||||
|
domain: newDomain,
|
||||||
|
branch: branch,
|
||||||
|
patchedAt: new Date().toISOString(),
|
||||||
|
patcher: 'PrePatchedDownload',
|
||||||
|
source: sourceUrl
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (progressCallback) progressCallback('Server patching complete', 100);
|
||||||
|
console.log('=== Server Patching Complete ===');
|
||||||
|
return { success: true, patchCount: 1 };
|
||||||
|
|
||||||
} catch (downloadError) {
|
} catch (downloadError) {
|
||||||
console.error(`Failed to download DualAuth Agent: ${downloadError.message}`);
|
console.error(`Failed to download patched JAR: ${downloadError.message}`);
|
||||||
// Clean up temp file
|
|
||||||
const tmpPath = agentPath + '.tmp';
|
// Restore backup on failure
|
||||||
if (fs.existsSync(tmpPath)) {
|
const backupPath = serverPath + '.original';
|
||||||
try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ }
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.copyFileSync(backupPath, serverPath);
|
||||||
|
console.log('Restored backup after download failure');
|
||||||
}
|
}
|
||||||
return { success: false, error: downloadError.message };
|
|
||||||
|
return { success: false, error: `Failed to download patched server: ${downloadError.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,12 +758,12 @@ class ClientPatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure client is patched and DualAuth Agent is available before launching
|
* Ensure both client and server are patched before launching
|
||||||
*/
|
*/
|
||||||
async ensureClientPatched(gameDir, progressCallback, javaPath = null, branch = 'release') {
|
async ensureClientPatched(gameDir, progressCallback, javaPath = null, branch = 'release') {
|
||||||
const results = {
|
const results = {
|
||||||
client: null,
|
client: null,
|
||||||
agent: null,
|
server: null,
|
||||||
success: true
|
success: true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -653,23 +780,22 @@ class ClientPatcher {
|
|||||||
results.client = { success: false, error: 'Client binary not found' };
|
results.client = { success: false, error: 'Client binary not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download DualAuth ByteBuddy Agent (runtime patching, no JAR modification)
|
const serverPath = this.findServerPath(gameDir);
|
||||||
const serverDir = path.join(gameDir, 'Server');
|
if (serverPath) {
|
||||||
if (fs.existsSync(serverDir)) {
|
if (progressCallback) progressCallback('Patching server JAR...', 50);
|
||||||
if (progressCallback) progressCallback('Checking DualAuth Agent...', 50);
|
results.server = await this.patchServer(serverPath, (msg, pct) => {
|
||||||
results.agent = await this.ensureAgentAvailable(serverDir, (msg, pct) => {
|
|
||||||
if (progressCallback) {
|
if (progressCallback) {
|
||||||
progressCallback(`Agent: ${msg}`, pct ? 50 + pct / 2 : null);
|
progressCallback(`Server: ${msg}`, pct ? 50 + pct / 2 : null);
|
||||||
}
|
}
|
||||||
});
|
}, branch);
|
||||||
} else {
|
} else {
|
||||||
console.warn('Server directory not found, skipping agent download');
|
console.warn('Could not find HytaleServer.jar');
|
||||||
results.agent = { success: true, skipped: true };
|
results.server = { success: false, error: 'Server JAR not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
results.success = (results.client && results.client.success) || (results.agent && results.agent.success);
|
results.success = (results.client && results.client.success) || (results.server && results.server.success);
|
||||||
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.agent && results.agent.alreadyExists);
|
results.alreadyPatched = (results.client && results.client.alreadyPatched) && (results.server && results.server.alreadyPatched);
|
||||||
results.patchCount = results.client ? results.client.patchCount || 0 : 0;
|
results.patchCount = (results.client ? results.client.patchCount || 0 : 0) + (results.server ? results.server.patchCount || 0 : 0);
|
||||||
|
|
||||||
if (progressCallback) progressCallback('Patching complete', 100);
|
if (progressCallback) progressCallback('Patching complete', 100);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { execSync, spawnSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
function getOS() {
|
function getOS() {
|
||||||
@@ -116,454 +116,117 @@ function detectGpu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectGpuLinux() {
|
function detectGpuLinux() {
|
||||||
let output = '';
|
const output = execSync('lspci -nn | grep \'VGA\\|3D\'', { encoding: 'utf8' });
|
||||||
try {
|
|
||||||
output = execSync('lspci -nn | grep -E "VGA|3D"', { encoding: 'utf8' });
|
|
||||||
} catch (e) {
|
|
||||||
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = output.split('\n').filter(line => line.trim());
|
const lines = output.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
let gpus = {
|
let integratedName = null;
|
||||||
integrated: [],
|
let dedicatedName = null;
|
||||||
dedicated: []
|
let hasNvidia = false;
|
||||||
};
|
let hasAmd = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Example: 01:00.0 VGA compatible controller [0300]: NVIDIA Corporation TU116 [GeForce GTX 1660 Ti] [10de:2182] (rev a1)
|
if (line.includes('VGA') || line.includes('3D')) {
|
||||||
|
const match = line.match(/\[([^\]]+)\]/g);
|
||||||
// Matches all content inside [...]
|
let modelName = null;
|
||||||
const brackets = line.match(/\[([^\]]+)\]/g);
|
if (match && match.length >= 2) {
|
||||||
|
modelName = match[1].slice(1, -1);
|
||||||
let name = line; // fallback
|
|
||||||
let vendorId = '';
|
|
||||||
|
|
||||||
if (brackets && brackets.length >= 2) {
|
|
||||||
const idBracket = brackets.find(b => b.includes(':')); // [10de:2182]
|
|
||||||
if (idBracket) {
|
|
||||||
vendorId = idBracket.replace(/[\[\]]/g, '').split(':')[0].toLowerCase();
|
|
||||||
|
|
||||||
// The bracket before the ID bracket is usually the model name.
|
|
||||||
const idIndex = brackets.indexOf(idBracket);
|
|
||||||
if (idIndex > 0) {
|
|
||||||
name = brackets[idIndex - 1].replace(/[\[\]]/g, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (brackets && brackets.length === 1) {
|
|
||||||
name = brackets[0].replace(/[\[\]]/g, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean name
|
if (line.includes('10de:') || line.toLowerCase().includes('nvidia')) {
|
||||||
name = name.trim();
|
hasNvidia = true;
|
||||||
const lowerName = name.toLowerCase();
|
dedicatedName = "NVIDIA " + modelName || 'NVIDIA GPU';
|
||||||
const lowerLine = line.toLowerCase();
|
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||||
|
} else if (line.includes('1002:') || line.toLowerCase().includes('amd') || line.toLowerCase().includes('radeon')) {
|
||||||
// Vendor detection
|
hasAmd = true;
|
||||||
const isNvidia = lowerLine.includes('nvidia') || vendorId === '10de';
|
dedicatedName = "AMD " + modelName || 'AMD GPU';
|
||||||
const isAmd = lowerLine.includes('amd') || lowerLine.includes('radeon') || vendorId === '1002';
|
console.log('Detected AMD GPU:', dedicatedName);
|
||||||
const isIntel = lowerLine.includes('intel') || vendorId === '8086';
|
} else if (line.includes('8086:') || line.toLowerCase().includes('intel')) {
|
||||||
|
integratedName = "Intel " + modelName || 'Intel GPU';
|
||||||
// Intel Arc detection
|
console.log('Detected Intel GPU:', integratedName);
|
||||||
const isIntelArc = isIntel && (lowerName.includes('arc') || lowerName.includes('a770') || lowerName.includes('a750') || lowerName.includes('a380'));
|
|
||||||
|
|
||||||
let vendor = 'unknown';
|
|
||||||
if (isNvidia) vendor = 'nvidia';
|
|
||||||
else if (isAmd) vendor = 'amd';
|
|
||||||
else if (isIntel) vendor = 'intel';
|
|
||||||
|
|
||||||
let vramMb = 0;
|
|
||||||
|
|
||||||
// VRAM Detection Logic
|
|
||||||
if (isNvidia) {
|
|
||||||
try {
|
|
||||||
// Try nvidia-smi
|
|
||||||
const smiOutput = execSync('nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
||||||
const vramVal = parseInt(smiOutput.split('\n')[0]); // Take first if multiple
|
|
||||||
if (!isNaN(vramVal)) {
|
|
||||||
vramMb = vramVal;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
// failed
|
|
||||||
}
|
|
||||||
} else if (isAmd) {
|
|
||||||
// Try /sys/class/drm/card*/device/mem_info_vram_total
|
|
||||||
// This is a bit heuristical, we need to match the card.
|
|
||||||
// But usually checking any card with AMD vendor in /sys is a good guess if we just want "the AMD GPU vram".
|
|
||||||
try {
|
|
||||||
const cards = fs.readdirSync('/sys/class/drm').filter(c => c.startsWith('card') && !c.includes('-'));
|
|
||||||
for (const card of cards) {
|
|
||||||
try {
|
|
||||||
const vendorFile = fs.readFileSync(`/sys/class/drm/${card}/device/vendor`, 'utf8').trim();
|
|
||||||
if (vendorFile === '0x1002') { // AMD vendor ID
|
|
||||||
const vramBytes = fs.readFileSync(`/sys/class/drm/${card}/device/mem_info_vram_total`, 'utf8').trim();
|
|
||||||
vramMb = Math.round(parseInt(vramBytes) / (1024 * 1024));
|
|
||||||
if (vramMb > 0) break;
|
|
||||||
}
|
|
||||||
} catch (e2) {}
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
} else if (isIntel) {
|
|
||||||
// Try lspci -v to get prefetchable memory (stolen/dedicated aperture)
|
|
||||||
try {
|
|
||||||
// Extract slot from line, e.g. "00:02.0"
|
|
||||||
const slot = line.split(' ')[0];
|
|
||||||
if (slot && /^[0-9a-f:.]+$/.test(slot)) {
|
|
||||||
const verbose = execSync(`lspci -v -s ${slot}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
||||||
const vLines = verbose.split('\n');
|
|
||||||
for (const vLine of vLines) {
|
|
||||||
// Match "Memory at ... (..., prefetchable) [size=256M]"
|
|
||||||
// Must ensure it is prefetchable and NOT non-prefetchable
|
|
||||||
if (vLine.includes('prefetchable') && !vLine.includes('non-prefetchable')) {
|
|
||||||
const match = vLine.match(/size=([0-9]+)([KMGT])/);
|
|
||||||
if (match) {
|
|
||||||
let size = parseInt(match[1]);
|
|
||||||
const unit = match[2];
|
|
||||||
if (unit === 'G') size *= 1024;
|
|
||||||
else if (unit === 'K') size /= 1024;
|
|
||||||
// M is default
|
|
||||||
if (size > 0) {
|
|
||||||
vramMb = size;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const gpuInfo = {
|
if (hasNvidia) {
|
||||||
name: name,
|
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
vendor: vendor,
|
} else if (hasAmd) {
|
||||||
vram: vramMb
|
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
};
|
|
||||||
|
|
||||||
if (isNvidia || isAmd || isIntelArc) {
|
|
||||||
gpus.dedicated.push(gpuInfo);
|
|
||||||
} else if (isIntel) {
|
|
||||||
gpus.integrated.push(gpuInfo);
|
|
||||||
} else {
|
} else {
|
||||||
// Unknown vendor or other, fallback to integrated list to be safe
|
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
|
||||||
gpus.integrated.push(gpuInfo);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Attempt to get Integrated VRAM via glxinfo if it's STILL 0 (common for Intel iGPUs if lspci failed)
|
|
||||||
// glxinfo -B usually reports the active renderer's "Video memory" which includes shared memory for iGPUs.
|
|
||||||
if (gpus.integrated.length > 0 && gpus.integrated[0].vram === 0) {
|
|
||||||
try {
|
|
||||||
const glxOut = execSync('glxinfo -B', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
||||||
const lines = glxOut.split('\n');
|
|
||||||
let glxVendor = '';
|
|
||||||
let glxMem = 0;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trim = line.trim();
|
|
||||||
if (trim.startsWith('Device:')) {
|
|
||||||
const lower = trim.toLowerCase();
|
|
||||||
if (lower.includes('intel')) glxVendor = 'intel';
|
|
||||||
else if (lower.includes('nvidia')) glxVendor = 'nvidia';
|
|
||||||
else if (lower.includes('amd') || lower.includes('ati')) glxVendor = 'amd';
|
|
||||||
} else if (trim.startsWith('Video memory:')) {
|
|
||||||
// Example: "Video memory: 15861MB"
|
|
||||||
const memStr = trim.split(':')[1].replace('MB', '').trim();
|
|
||||||
glxMem = parseInt(memStr, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If glxinfo reports Intel and we have an Intel integrated GPU, update it
|
|
||||||
// We check vendor match to ensure we don't accidentally assign Nvidia VRAM to Intel if user is running on dGPU
|
|
||||||
if (glxVendor === 'intel' && gpus.integrated[0].vendor === 'intel' && glxMem > 0) {
|
|
||||||
gpus.integrated[0].vram = glxMem;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// glxinfo missing or failed, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryDedicated = gpus.dedicated[0] || null;
|
|
||||||
const primaryIntegrated = gpus.integrated[0] || { name: 'Intel GPU', vram: 0 };
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: primaryDedicated ? 'dedicated' : 'integrated',
|
|
||||||
vendor: primaryDedicated ? primaryDedicated.vendor : (gpus.integrated[0] ? gpus.integrated[0].vendor : 'intel'),
|
|
||||||
integratedName: primaryIntegrated.name,
|
|
||||||
dedicatedName: primaryDedicated ? primaryDedicated.name : null,
|
|
||||||
dedicatedVram: primaryDedicated ? primaryDedicated.vram : 0,
|
|
||||||
integratedVram: primaryIntegrated.vram
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectGpuWindows() {
|
function detectGpuWindows() {
|
||||||
let output = '';
|
const output = execSync('wmic path win32_VideoController get name', { encoding: 'utf8' });
|
||||||
let commandUsed = 'cim'; // Track which command succeeded
|
const lines = output.split('\n').map(line => line.trim()).filter(line => line && line !== 'Name');
|
||||||
const POWERSHELL_TIMEOUT = 5000; // 5 second timeout to prevent hanging
|
|
||||||
|
|
||||||
try {
|
let integratedName = null;
|
||||||
// Use spawnSync with explicit timeout instead of execSync to avoid ghost processes
|
let dedicatedName = null;
|
||||||
// Fetch Name and AdapterRAM (VRAM in bytes)
|
let hasNvidia = false;
|
||||||
const result = spawnSync('powershell.exe', [
|
let hasAmd = false;
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy', 'Bypass',
|
|
||||||
'-Command',
|
|
||||||
'Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM | ConvertTo-Csv -NoTypeInformation'
|
|
||||||
], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: POWERSHELL_TIMEOUT,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === 0 && result.stdout) {
|
|
||||||
output = result.stdout;
|
|
||||||
} else {
|
|
||||||
throw new Error(`PowerShell returned status ${result.status || result.signal}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
try {
|
|
||||||
// Fallback to Get-WmiObject (Older PowerShell)
|
|
||||||
commandUsed = 'wmi';
|
|
||||||
const result = spawnSync('powershell.exe', [
|
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy', 'Bypass',
|
|
||||||
'-Command',
|
|
||||||
'Get-WmiObject Win32_VideoController | Select-Object Name, AdapterRAM | ConvertTo-Csv -NoTypeInformation'
|
|
||||||
], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: POWERSHELL_TIMEOUT,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === 0 && result.stdout) {
|
|
||||||
output = result.stdout;
|
|
||||||
} else {
|
|
||||||
throw new Error(`PowerShell WMI returned status ${result.status || result.signal}`);
|
|
||||||
}
|
|
||||||
} catch (e2) {
|
|
||||||
// Fallback to wmic (Deprecated, often missing on newer Windows)
|
|
||||||
// Note: This fallback likely won't provide VRAM in the same reliable CSV format easily,
|
|
||||||
// so we stick to just getting the Name to at least allow the app to launch.
|
|
||||||
try {
|
|
||||||
commandUsed = 'wmic';
|
|
||||||
const result = spawnSync('wmic.exe', ['path', 'win32_VideoController', 'get', 'name'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: POWERSHELL_TIMEOUT,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === 0 && result.stdout) {
|
|
||||||
output = result.stdout;
|
|
||||||
} else {
|
|
||||||
throw new Error(`wmic returned status ${result.status || result.signal}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('All Windows GPU detection methods failed:', err.message);
|
|
||||||
return { mode: 'unknown', vendor: 'none', integratedName: null, dedicatedName: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse lines.
|
|
||||||
// PowerShell CSV output (Get-CimInstance/Get-WmiObject) usually looks like:
|
|
||||||
// "Name","AdapterRAM"
|
|
||||||
// "NVIDIA GeForce RTX 3060","12884901888"
|
|
||||||
//
|
|
||||||
// WMIC output is just plain text lines with the name (if we used the wmic command above).
|
|
||||||
|
|
||||||
const lines = output.split(/\r?\n/).filter(l => l.trim().length > 0);
|
|
||||||
|
|
||||||
let gpus = {
|
|
||||||
integrated: [],
|
|
||||||
dedicated: []
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Skip header lines
|
const lowerLine = line.toLowerCase();
|
||||||
if (line.toLowerCase().includes('name') && (line.includes('AdapterRAM') || commandUsed === 'wmic')) {
|
if (lowerLine.includes('nvidia')) {
|
||||||
continue;
|
hasNvidia = true;
|
||||||
|
dedicatedName = line;
|
||||||
|
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||||
|
} else if (lowerLine.includes('amd') || lowerLine.includes('radeon')) {
|
||||||
|
hasAmd = true;
|
||||||
|
dedicatedName = line;
|
||||||
|
console.log('Detected AMD GPU:', dedicatedName);
|
||||||
|
} else if (lowerLine.includes('intel')) {
|
||||||
|
integratedName = line;
|
||||||
|
console.log('Detected Intel GPU:', integratedName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = '';
|
if (hasNvidia) {
|
||||||
let vramBytes = 0;
|
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
|
} else if (hasAmd) {
|
||||||
if (commandUsed === 'wmic') {
|
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Intel GPU', dedicatedName };
|
||||||
name = line.trim();
|
|
||||||
} else {
|
} else {
|
||||||
// Parse CSV: "Name","AdapterRAM"
|
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Intel GPU', dedicatedName: null };
|
||||||
// Simple regex to handle potential quotes.
|
|
||||||
// This assumes simple CSV structure from ConvertTo-Csv.
|
|
||||||
const parts = line.split(',');
|
|
||||||
// Remove surrounding quotes if present
|
|
||||||
const rawName = parts[0] ? parts[0].replace(/^"|"$/g, '') : '';
|
|
||||||
const rawRam = parts[1] ? parts[1].replace(/^"|"$/g, '') : '0';
|
|
||||||
|
|
||||||
name = rawName.trim();
|
|
||||||
vramBytes = parseInt(rawRam, 10) || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name) continue;
|
|
||||||
|
|
||||||
const lowerName = name.toLowerCase();
|
|
||||||
const vramMb = Math.round(vramBytes / (1024 * 1024));
|
|
||||||
|
|
||||||
// Logic for dGPU detection; added isIntelArc check
|
|
||||||
const isNvidia = lowerName.includes('nvidia');
|
|
||||||
const isAmd = lowerName.includes('amd') || lowerName.includes('radeon');
|
|
||||||
const isIntelArc = lowerName.includes('arc') && lowerName.includes('intel');
|
|
||||||
|
|
||||||
const gpuInfo = {
|
|
||||||
name: name,
|
|
||||||
vendor: isNvidia ? 'nvidia' : (isAmd ? 'amd' : (isIntelArc ? 'intel' : 'unknown')),
|
|
||||||
vram: vramMb
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isNvidia || isAmd || isIntelArc) {
|
|
||||||
gpus.dedicated.push(gpuInfo);
|
|
||||||
} else if (lowerName.includes('intel') || lowerName.includes('iris') || lowerName.includes('uhd')) {
|
|
||||||
gpus.integrated.push(gpuInfo);
|
|
||||||
} else {
|
|
||||||
// Fallback: If unknown vendor but high VRAM (> 512MB), treat as dedicated?
|
|
||||||
// Or just assume integrated if generic "Microsoft Basic Display Adapter" etc.
|
|
||||||
// For now, if we can't identify it as dedicated vendor, put in integrated/other.
|
|
||||||
gpus.integrated.push(gpuInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryDedicated = gpus.dedicated[0] || null;
|
|
||||||
const primaryIntegrated = gpus.integrated[0] || { name: 'Intel GPU', vram: 0 };
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: primaryDedicated ? 'dedicated' : 'integrated',
|
|
||||||
vendor: primaryDedicated ? primaryDedicated.vendor : 'intel', // Default to intel if only integrated found
|
|
||||||
integratedName: primaryIntegrated.name,
|
|
||||||
dedicatedName: primaryDedicated ? primaryDedicated.name : null,
|
|
||||||
// Add VRAM info if available (mostly for debug or UI)
|
|
||||||
dedicatedVram: primaryDedicated ? primaryDedicated.vram : 0,
|
|
||||||
integratedVram: primaryIntegrated.vram
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectGpuMac() {
|
function detectGpuMac() {
|
||||||
let output = '';
|
const output = execSync('system_profiler SPDisplaysDataType', { encoding: 'utf8' });
|
||||||
try {
|
|
||||||
output = execSync('system_profiler SPDisplaysDataType', { encoding: 'utf8' });
|
|
||||||
} catch (e) {
|
|
||||||
return { mode: 'integrated', vendor: 'intel', integratedName: 'Unknown', dedicatedName: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = output.split('\n');
|
const lines = output.split('\n');
|
||||||
let gpus = {
|
|
||||||
integrated: [],
|
|
||||||
dedicated: []
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentGpu = null;
|
let integratedName = null;
|
||||||
|
let dedicatedName = null;
|
||||||
|
let hasNvidia = false;
|
||||||
|
let hasAmd = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
if (line.includes('Chipset Model:')) {
|
||||||
|
const gpuName = line.split('Chipset Model:')[1].trim();
|
||||||
// New block starts with "Chipset Model:"
|
const lowerGpu = gpuName.toLowerCase();
|
||||||
if (trimmed.startsWith('Chipset Model:')) {
|
if (lowerGpu.includes('nvidia')) {
|
||||||
if (currentGpu) {
|
hasNvidia = true;
|
||||||
// Push previous
|
dedicatedName = gpuName;
|
||||||
categorizeMacGpu(currentGpu, gpus);
|
console.log('Detected NVIDIA GPU:', dedicatedName);
|
||||||
}
|
} else if (lowerGpu.includes('amd') || lowerGpu.includes('radeon')) {
|
||||||
currentGpu = {
|
hasAmd = true;
|
||||||
name: trimmed.split(':')[1].trim(),
|
dedicatedName = gpuName;
|
||||||
vendor: 'unknown',
|
console.log('Detected AMD GPU:', dedicatedName);
|
||||||
vram: 0
|
} else if (lowerGpu.includes('intel') || lowerGpu.includes('iris') || lowerGpu.includes('uhd')) {
|
||||||
};
|
integratedName = gpuName;
|
||||||
} else if (currentGpu) {
|
console.log('Detected Intel GPU:', integratedName);
|
||||||
if (trimmed.startsWith('VRAM (Total):') || trimmed.startsWith('VRAM (Dynamic, Max):')) {
|
} else if (!dedicatedName && !integratedName) {
|
||||||
// Parse VRAM: "1.5 GB" or "1536 MB"
|
// Fallback for Apple Silicon or other
|
||||||
const valParts = trimmed.split(':')[1].trim().split(' ');
|
integratedName = gpuName;
|
||||||
let val = parseFloat(valParts[0]);
|
|
||||||
if (valParts[1] && valParts[1].toUpperCase() === 'GB') {
|
|
||||||
val = val * 1024;
|
|
||||||
}
|
|
||||||
currentGpu.vram = Math.round(val);
|
|
||||||
} else if (trimmed.startsWith('Vendor:') || trimmed.startsWith('Vendor Name:')) {
|
|
||||||
// "Vendor: NVIDIA (0x10de)"
|
|
||||||
const v = trimmed.split(':')[1].toLowerCase();
|
|
||||||
if (v.includes('nvidia')) currentGpu.vendor = 'nvidia';
|
|
||||||
else if (v.includes('amd') || v.includes('ati')) currentGpu.vendor = 'amd';
|
|
||||||
else if (v.includes('intel')) currentGpu.vendor = 'intel';
|
|
||||||
else if (v.includes('apple')) currentGpu.vendor = 'apple';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Push last one
|
|
||||||
if (currentGpu) {
|
|
||||||
categorizeMacGpu(currentGpu, gpus);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have an Apple Silicon GPU (vendor=apple) but VRAM is 0, fetch system memory as it is unified.
|
if (hasNvidia) {
|
||||||
gpus.dedicated.forEach(gpu => {
|
return { mode: 'dedicated', vendor: 'nvidia', integratedName: integratedName || 'Integrated GPU', dedicatedName };
|
||||||
if (gpu.vendor === 'apple' && gpu.vram === 0) {
|
} else if (hasAmd) {
|
||||||
try {
|
return { mode: 'dedicated', vendor: 'amd', integratedName: integratedName || 'Integrated GPU', dedicatedName };
|
||||||
const memSize = execSync('sysctl -n hw.memsize', { encoding: 'utf8' }).trim();
|
|
||||||
// memSize is in bytes
|
|
||||||
const memMb = Math.round(parseInt(memSize, 10) / (1024 * 1024));
|
|
||||||
if (memMb > 0) gpu.vram = memMb;
|
|
||||||
} catch (err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const primaryDedicated = gpus.dedicated[0] || null;
|
|
||||||
const primaryIntegrated = gpus.integrated[0] || { name: 'Integrated GPU', vram: 0 };
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: primaryDedicated ? 'dedicated' : 'integrated',
|
|
||||||
vendor: primaryDedicated ? primaryDedicated.vendor : (gpus.integrated[0] ? gpus.integrated[0].vendor : 'intel'),
|
|
||||||
integratedName: primaryIntegrated.name,
|
|
||||||
dedicatedName: primaryDedicated ? primaryDedicated.name : null,
|
|
||||||
dedicatedVram: primaryDedicated ? primaryDedicated.vram : 0,
|
|
||||||
integratedVram: primaryIntegrated.vram
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function categorizeMacGpu(gpu, gpus) {
|
|
||||||
const lowerName = gpu.name.toLowerCase();
|
|
||||||
|
|
||||||
// Refine vendor if still unknown
|
|
||||||
if (gpu.vendor === 'unknown') {
|
|
||||||
if (lowerName.includes('nvidia')) gpu.vendor = 'nvidia';
|
|
||||||
else if (lowerName.includes('amd') || lowerName.includes('radeon')) gpu.vendor = 'amd';
|
|
||||||
else if (lowerName.includes('intel')) gpu.vendor = 'intel';
|
|
||||||
else if (lowerName.includes('apple') || lowerName.includes('m1') || lowerName.includes('m2') || lowerName.includes('m3')) gpu.vendor = 'apple';
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNvidia = gpu.vendor === 'nvidia';
|
|
||||||
const isAmd = gpu.vendor === 'amd';
|
|
||||||
const isApple = gpu.vendor === 'apple';
|
|
||||||
|
|
||||||
// Per user request, "project is not meant for Intel Mac (x86)",
|
|
||||||
// so we treat Apple Silicon as the primary "dedicated-like" GPU for this app's context.
|
|
||||||
|
|
||||||
if (isNvidia || isAmd || isApple) {
|
|
||||||
gpus.dedicated.push(gpu);
|
|
||||||
} else {
|
} else {
|
||||||
// Intel or unknown
|
return { mode: 'integrated', vendor: 'intel', integratedName: integratedName || 'Integrated GPU', dedicatedName: null };
|
||||||
gpus.integrated.push(gpu);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,108 +267,11 @@ function setupGpuEnvironment(gpuPreference) {
|
|||||||
return envVars;
|
return envVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSystemType() {
|
|
||||||
const platform = getOS();
|
|
||||||
try {
|
|
||||||
if (platform === 'linux') return getSystemTypeLinux();
|
|
||||||
if (platform === 'windows') return getSystemTypeWindows();
|
|
||||||
if (platform === 'darwin') return getSystemTypeMac();
|
|
||||||
return 'desktop'; // Default to desktop if unknown
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to detect system type, defaulting to desktop:', err.message);
|
|
||||||
return 'desktop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSystemTypeLinux() {
|
|
||||||
try {
|
|
||||||
// Try reliable DMI check first
|
|
||||||
if (fs.existsSync('/sys/class/dmi/id/chassis_type')) {
|
|
||||||
const type = parseInt(fs.readFileSync('/sys/class/dmi/id/chassis_type', 'utf8').trim());
|
|
||||||
// 8=Portable, 9=Laptop, 10=Notebook, 11=Hand Held, 12=Docking Station, 14=Sub Notebook
|
|
||||||
if ([8, 9, 10, 11, 12, 14, 31, 32].includes(type)) {
|
|
||||||
return 'laptop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback to chassis_id for some systems? Usually chassis_type is enough.
|
|
||||||
return 'desktop';
|
|
||||||
} catch (e) {
|
|
||||||
return 'desktop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSystemTypeWindows() {
|
|
||||||
const POWERSHELL_TIMEOUT = 5000; // 5 second timeout
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use spawnSync instead of execSync to avoid ghost processes
|
|
||||||
const result = spawnSync('powershell.exe', [
|
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy', 'Bypass',
|
|
||||||
'-Command',
|
|
||||||
'Get-CimInstance Win32_SystemEnclosure | Select-Object -ExpandProperty ChassisTypes'
|
|
||||||
], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: POWERSHELL_TIMEOUT,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error || result.status !== 0) {
|
|
||||||
throw new Error(`PowerShell failed: ${result.error?.message || result.signal}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = (result.stdout || '').trim();
|
|
||||||
// Output might be a single number or array.
|
|
||||||
// Clean it up
|
|
||||||
const types = output.split(/\s+/).map(t => parseInt(t)).filter(n => !isNaN(n));
|
|
||||||
|
|
||||||
// Laptop codes: 8, 9, 10, 11, 12, 14, 31, 32
|
|
||||||
const laptopCodes = [8, 9, 10, 11, 12, 14, 31, 32];
|
|
||||||
|
|
||||||
for (const t of types) {
|
|
||||||
if (laptopCodes.includes(t)) return 'laptop';
|
|
||||||
}
|
|
||||||
return 'desktop';
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback wmic
|
|
||||||
try {
|
|
||||||
const result = spawnSync('wmic.exe', ['path', 'win32_systemenclosure', 'get', 'chassistypes'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: POWERSHELL_TIMEOUT,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 0 && result.stdout) {
|
|
||||||
const output = result.stdout.trim();
|
|
||||||
if (output.includes('8') || output.includes('9') || output.includes('10') || output.includes('14')) {
|
|
||||||
return 'laptop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('System type detection failed:', err.message);
|
|
||||||
}
|
|
||||||
return 'desktop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSystemTypeMac() {
|
|
||||||
try {
|
|
||||||
const model = execSync('sysctl -n hw.model', { encoding: 'utf8' }).trim().toLowerCase();
|
|
||||||
if (model.includes('book')) return 'laptop';
|
|
||||||
return 'desktop';
|
|
||||||
} catch (e) {
|
|
||||||
return 'desktop';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getOS,
|
getOS,
|
||||||
getArch,
|
getArch,
|
||||||
isWaylandSession,
|
isWaylandSession,
|
||||||
setupWaylandEnvironment,
|
setupWaylandEnvironment,
|
||||||
detectGpu,
|
detectGpu,
|
||||||
setupGpuEnvironment,
|
setupGpuEnvironment
|
||||||
getSystemType
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,426 +0,0 @@
|
|||||||
const crypto = require('crypto');
|
|
||||||
const axios = require('axios');
|
|
||||||
const https = require('https');
|
|
||||||
const { PassThrough } = require('stream');
|
|
||||||
|
|
||||||
const PROXY_URL = process.env.HF2P_PROXY_URL || 'your_proxy_url_here';
|
|
||||||
const SECRET_KEY = process.env.HF2P_SECRET_KEY || 'your_secret_key_here_for_jwt';
|
|
||||||
const USE_DIRECT_FALLBACK = process.env.HF2P_USE_FALLBACK !== 'false';
|
|
||||||
const DIRECT_TIMEOUT = 7000; // 7 seconds timeout
|
|
||||||
|
|
||||||
console.log('[ProxyClient] Initialized with proxy URL:', PROXY_URL ? 'YES' : 'NO');
|
|
||||||
console.log('[ProxyClient] Secret key configured:', SECRET_KEY ? 'YES' : 'NO');
|
|
||||||
console.log('[ProxyClient] Direct connection fallback:', USE_DIRECT_FALLBACK ? 'ENABLED' : 'DISABLED');
|
|
||||||
console.log('[ProxyClient] Direct timeout before fallback:', DIRECT_TIMEOUT / 1000, 'seconds');
|
|
||||||
|
|
||||||
function generateToken() {
|
|
||||||
const timestamp = Date.now().toString();
|
|
||||||
const hash = crypto
|
|
||||||
.createHmac('sha256', SECRET_KEY)
|
|
||||||
.update(timestamp)
|
|
||||||
.digest('hex');
|
|
||||||
const token = `${timestamp}:${hash}`;
|
|
||||||
console.log('[ProxyClient] Generated auth token:', token.substring(0, 20) + '...');
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct request without proxy
|
|
||||||
async function directRequest(url, options = {}) {
|
|
||||||
console.log('[ProxyClient] Attempting direct request (no proxy)');
|
|
||||||
console.log('[ProxyClient] Direct URL:', url);
|
|
||||||
|
|
||||||
const timeoutMs = options.timeout || DIRECT_TIMEOUT;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
console.warn('[ProxyClient] TIMEOUT! Aborting direct request after', timeoutMs, 'ms');
|
|
||||||
controller.abort();
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = {
|
|
||||||
method: options.method || 'GET',
|
|
||||||
url: url,
|
|
||||||
headers: options.headers || {},
|
|
||||||
timeout: timeoutMs,
|
|
||||||
responseType: options.responseType,
|
|
||||||
signal: controller.signal
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await axios(config);
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy request (original function)
|
|
||||||
async function proxyRequest(url, options = {}) {
|
|
||||||
console.log('[ProxyClient] Starting proxy request');
|
|
||||||
console.log('[ProxyClient] Original URL:', url);
|
|
||||||
console.log('[ProxyClient] Options:', JSON.stringify(options, null, 2));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = generateToken();
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const targetUrl = `${urlObj.protocol}//${urlObj.host}`;
|
|
||||||
|
|
||||||
console.log('[ProxyClient] Parsed URL components:');
|
|
||||||
console.log(' - Protocol:', urlObj.protocol);
|
|
||||||
console.log(' - Host:', urlObj.host);
|
|
||||||
console.log(' - Pathname:', urlObj.pathname);
|
|
||||||
console.log(' - Search:', urlObj.search);
|
|
||||||
console.log(' - Target URL:', targetUrl);
|
|
||||||
|
|
||||||
const proxyEndpoint = `${PROXY_URL}/proxy${urlObj.pathname}${urlObj.search}`;
|
|
||||||
console.log('[ProxyClient] Proxy endpoint:', proxyEndpoint);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
method: options.method || 'GET',
|
|
||||||
url: proxyEndpoint,
|
|
||||||
headers: {
|
|
||||||
'X-Auth-Token': token,
|
|
||||||
'X-Target-URL': targetUrl,
|
|
||||||
...(options.headers || {})
|
|
||||||
},
|
|
||||||
timeout: options.timeout || 30000,
|
|
||||||
responseType: options.responseType
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[ProxyClient] Request config:', JSON.stringify({
|
|
||||||
method: config.method,
|
|
||||||
url: config.url,
|
|
||||||
headers: config.headers,
|
|
||||||
timeout: config.timeout,
|
|
||||||
responseType: config.responseType
|
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
const response = await axios(config);
|
|
||||||
console.log('[ProxyClient] Response received - Status:', response.status);
|
|
||||||
console.log('[ProxyClient] Response headers:', JSON.stringify(response.headers, null, 2));
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ProxyClient] Request failed!');
|
|
||||||
console.error('[ProxyClient] Error type:', error.constructor.name);
|
|
||||||
console.error('[ProxyClient] Error message:', error.message);
|
|
||||||
if (error.response) {
|
|
||||||
console.error('[ProxyClient] Response status:', error.response.status);
|
|
||||||
console.error('[ProxyClient] Response data:', error.response.data);
|
|
||||||
console.error('[ProxyClient] Response headers:', error.response.headers);
|
|
||||||
}
|
|
||||||
if (error.config) {
|
|
||||||
console.error('[ProxyClient] Failed request URL:', error.config.url);
|
|
||||||
console.error('[ProxyClient] Failed request headers:', error.config.headers);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smart request with automatic fallback
|
|
||||||
async function smartRequest(url, options = {}) {
|
|
||||||
if (!USE_DIRECT_FALLBACK) {
|
|
||||||
console.log('[ProxyClient] Fallback disabled, using proxy directly');
|
|
||||||
return proxyRequest(url, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[ProxyClient] Smart request with fallback enabled');
|
|
||||||
console.log('[ProxyClient] Direct timeout configured:', DIRECT_TIMEOUT, 'ms');
|
|
||||||
|
|
||||||
const directStartTime = Date.now();
|
|
||||||
try {
|
|
||||||
console.log('[ProxyClient] [ATTEMPT 1/2] Trying direct connection first...');
|
|
||||||
const response = await directRequest(url, options);
|
|
||||||
const directDuration = Date.now() - directStartTime;
|
|
||||||
console.log('[ProxyClient] [SUCCESS] Direct connection successful in', directDuration, 'ms');
|
|
||||||
return response;
|
|
||||||
} catch (directError) {
|
|
||||||
const directDuration = Date.now() - directStartTime;
|
|
||||||
console.warn('[ProxyClient] [FAILED] Direct connection failed after', directDuration, 'ms');
|
|
||||||
console.warn('[ProxyClient] Error message:', directError.message);
|
|
||||||
console.warn('[ProxyClient] Error code:', directError.code);
|
|
||||||
|
|
||||||
// Always fallback to proxy on any error
|
|
||||||
console.log('[ProxyClient] Attempting proxy fallback for all errors...');
|
|
||||||
|
|
||||||
if (true) {
|
|
||||||
console.log('[ProxyClient] [ATTEMPT 2/2] Falling back to proxy connection...');
|
|
||||||
try {
|
|
||||||
const proxyStartTime = Date.now();
|
|
||||||
const response = await proxyRequest(url, options);
|
|
||||||
const proxyDuration = Date.now() - proxyStartTime;
|
|
||||||
console.log('[ProxyClient] [SUCCESS] Proxy connection successful in', proxyDuration, 'ms');
|
|
||||||
return response;
|
|
||||||
} catch (proxyError) {
|
|
||||||
console.error('[ProxyClient] [FAILED] Both direct and proxy connections failed!');
|
|
||||||
console.error('[ProxyClient] Direct error:', directError.message);
|
|
||||||
console.error('[ProxyClient] Proxy error:', proxyError.message);
|
|
||||||
throw proxyError;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[ProxyClient] [SKIP] Direct error not related to connectivity, not falling back');
|
|
||||||
throw directError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct download stream without proxy
|
|
||||||
function directDownloadStream(url, onData) {
|
|
||||||
console.log('[ProxyClient] Starting direct download stream (no proxy)');
|
|
||||||
console.log('[ProxyClient] Direct download URL:', url);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const protocol = urlObj.protocol === 'https:' ? https : require('http');
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
hostname: urlObj.hostname,
|
|
||||||
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
||||||
path: urlObj.pathname + urlObj.search,
|
|
||||||
method: 'GET',
|
|
||||||
timeout: DIRECT_TIMEOUT
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResponse = (response) => {
|
|
||||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
||||||
const redirectUrl = response.headers.location;
|
|
||||||
console.log('[ProxyClient] Direct redirect to:', redirectUrl);
|
|
||||||
directDownloadStream(redirectUrl, onData).then(resolve).catch(reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
reject(new Error(`Direct HTTP ${response.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onData) {
|
|
||||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
|
||||||
let downloaded = 0;
|
|
||||||
const passThrough = new PassThrough();
|
|
||||||
|
|
||||||
response.on('data', (chunk) => {
|
|
||||||
downloaded += chunk.length;
|
|
||||||
onData(chunk, downloaded, totalSize);
|
|
||||||
});
|
|
||||||
|
|
||||||
response.pipe(passThrough);
|
|
||||||
resolve(passThrough);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = protocol.get(options, handleResponse);
|
|
||||||
|
|
||||||
req.on('error', (error) => {
|
|
||||||
console.error('[ProxyClient] Direct download error:', error.message);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('timeout', () => {
|
|
||||||
console.warn('[ProxyClient] TIMEOUT! Direct download timed out after', DIRECT_TIMEOUT, 'ms');
|
|
||||||
req.destroy();
|
|
||||||
const timeoutError = new Error('ETIMEDOUT: Direct connection timeout');
|
|
||||||
timeoutError.code = 'ETIMEDOUT';
|
|
||||||
reject(timeoutError);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProxyDownloadStream(url, onData) {
|
|
||||||
console.log('[ProxyClient] Starting download stream');
|
|
||||||
console.log('[ProxyClient] Download URL:', url);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const token = generateToken();
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const targetUrl = `${urlObj.protocol}//${urlObj.host}`;
|
|
||||||
|
|
||||||
console.log('[ProxyClient] Download URL parsed:');
|
|
||||||
console.log(' - Protocol:', urlObj.protocol);
|
|
||||||
console.log(' - Host:', urlObj.host);
|
|
||||||
console.log(' - Hostname:', urlObj.hostname);
|
|
||||||
console.log(' - Port:', urlObj.port);
|
|
||||||
console.log(' - Pathname:', urlObj.pathname);
|
|
||||||
console.log(' - Search:', urlObj.search);
|
|
||||||
console.log(' - Target URL:', targetUrl);
|
|
||||||
|
|
||||||
const proxyUrl = new URL(PROXY_URL);
|
|
||||||
const requestPath = `/proxy${urlObj.pathname}${urlObj.search}`;
|
|
||||||
|
|
||||||
console.log('[ProxyClient] Proxy configuration:');
|
|
||||||
console.log(' - Proxy URL:', PROXY_URL);
|
|
||||||
console.log(' - Proxy protocol:', proxyUrl.protocol);
|
|
||||||
console.log(' - Proxy hostname:', proxyUrl.hostname);
|
|
||||||
console.log(' - Proxy port:', proxyUrl.port);
|
|
||||||
console.log(' - Request path:', requestPath);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
hostname: proxyUrl.hostname,
|
|
||||||
port: proxyUrl.port || (proxyUrl.protocol === 'https:' ? 443 : 80),
|
|
||||||
path: requestPath,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'X-Auth-Token': token,
|
|
||||||
'X-Target-URL': targetUrl
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[ProxyClient] HTTP request options:', JSON.stringify(options, null, 2));
|
|
||||||
|
|
||||||
const protocol = proxyUrl.protocol === 'https:' ? https : require('http');
|
|
||||||
console.log('[ProxyClient] Using protocol:', proxyUrl.protocol);
|
|
||||||
|
|
||||||
const handleResponse = (response) => {
|
|
||||||
console.log('[ProxyClient] Response received - Status:', response.statusCode);
|
|
||||||
console.log('[ProxyClient] Response headers:', JSON.stringify(response.headers, null, 2));
|
|
||||||
|
|
||||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
||||||
const redirectUrl = response.headers.location;
|
|
||||||
console.log('[ProxyClient] Redirect detected to:', redirectUrl);
|
|
||||||
|
|
||||||
if (redirectUrl.startsWith('http')) {
|
|
||||||
console.log('[ProxyClient] Following redirect...');
|
|
||||||
getProxyDownloadStream(redirectUrl, onData).then(resolve).catch(reject);
|
|
||||||
} else {
|
|
||||||
console.error('[ProxyClient] Invalid redirect URL:', redirectUrl);
|
|
||||||
reject(new Error(`Invalid redirect: ${redirectUrl}`));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
console.error('[ProxyClient] Unexpected status code:', response.statusCode);
|
|
||||||
console.error('[ProxyClient] Response message:', response.statusMessage);
|
|
||||||
reject(new Error(`HTTP ${response.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onData) {
|
|
||||||
const totalSize = parseInt(response.headers['content-length'], 10);
|
|
||||||
console.log('[ProxyClient] Download starting - Total size:', totalSize, 'bytes');
|
|
||||||
|
|
||||||
let downloaded = 0;
|
|
||||||
const passThrough = new PassThrough();
|
|
||||||
|
|
||||||
response.on('data', (chunk) => {
|
|
||||||
downloaded += chunk.length;
|
|
||||||
const progress = ((downloaded / totalSize) * 100).toFixed(2);
|
|
||||||
onData(chunk, downloaded, totalSize);
|
|
||||||
});
|
|
||||||
|
|
||||||
response.on('end', () => {
|
|
||||||
console.log('[ProxyClient] Download completed -', downloaded, 'bytes received');
|
|
||||||
});
|
|
||||||
|
|
||||||
response.on('error', (error) => {
|
|
||||||
console.error('[ProxyClient] Response stream error:', error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
response.pipe(passThrough);
|
|
||||||
console.log('[ProxyClient] Stream piped to PassThrough');
|
|
||||||
resolve(passThrough);
|
|
||||||
} else {
|
|
||||||
console.log('[ProxyClient] Returning raw response stream (no progress callback)');
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const request = protocol.get(options, handleResponse);
|
|
||||||
|
|
||||||
request.on('error', (error) => {
|
|
||||||
console.error('[ProxyClient] HTTP request error!');
|
|
||||||
console.error('[ProxyClient] Error type:', error.constructor.name);
|
|
||||||
console.error('[ProxyClient] Error message:', error.message);
|
|
||||||
console.error('[ProxyClient] Error code:', error.code);
|
|
||||||
console.error('[ProxyClient] Error stack:', error.stack);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[ProxyClient] HTTP request sent');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ProxyClient] Exception in getProxyDownloadStream!');
|
|
||||||
console.error('[ProxyClient] Error type:', error.constructor.name);
|
|
||||||
console.error('[ProxyClient] Error message:', error.message);
|
|
||||||
console.error('[ProxyClient] Error stack:', error.stack);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smart download stream with automatic fallback
|
|
||||||
function smartDownloadStream(url, onData) {
|
|
||||||
if (!USE_DIRECT_FALLBACK) {
|
|
||||||
console.log('[ProxyClient] Fallback disabled, using proxy stream directly');
|
|
||||||
return getProxyDownloadStream(url, onData);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[ProxyClient] Smart download stream with fallback enabled');
|
|
||||||
console.log('[ProxyClient] Direct timeout configured:', DIRECT_TIMEOUT, 'ms');
|
|
||||||
|
|
||||||
return new Promise(async (resolve, reject) => {
|
|
||||||
const directStartTime = Date.now();
|
|
||||||
try {
|
|
||||||
console.log('[ProxyClient] [DOWNLOAD 1/2] Trying direct download first...');
|
|
||||||
const stream = await directDownloadStream(url, onData);
|
|
||||||
const directDuration = Date.now() - directStartTime;
|
|
||||||
console.log('[ProxyClient] [SUCCESS] Direct download stream established in', directDuration, 'ms');
|
|
||||||
resolve(stream);
|
|
||||||
} catch (directError) {
|
|
||||||
const directDuration = Date.now() - directStartTime;
|
|
||||||
console.warn('[ProxyClient] [FAILED] Direct download failed after', directDuration, 'ms');
|
|
||||||
console.warn('[ProxyClient] Error message:', directError.message);
|
|
||||||
console.warn('[ProxyClient] Error code:', directError.code);
|
|
||||||
|
|
||||||
// Always fallback to proxy on any error
|
|
||||||
console.log('[ProxyClient] Attempting proxy fallback for all download errors...');
|
|
||||||
|
|
||||||
if (true) {
|
|
||||||
console.log('[ProxyClient] [DOWNLOAD 2/2] Falling back to proxy download...');
|
|
||||||
try {
|
|
||||||
const proxyStartTime = Date.now();
|
|
||||||
const stream = await getProxyDownloadStream(url, onData);
|
|
||||||
const proxyDuration = Date.now() - proxyStartTime;
|
|
||||||
console.log('[ProxyClient] [SUCCESS] Proxy download stream established in', proxyDuration, 'ms');
|
|
||||||
resolve(stream);
|
|
||||||
} catch (proxyError) {
|
|
||||||
console.error('[ProxyClient] [FAILED] Both direct and proxy downloads failed!');
|
|
||||||
console.error('[ProxyClient] Direct error:', directError.message);
|
|
||||||
console.error('[ProxyClient] Proxy error:', proxyError.message);
|
|
||||||
reject(proxyError);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[ProxyClient] [SKIP] Direct error not related to connectivity, not falling back');
|
|
||||||
reject(directError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
// Recommended: Smart functions with automatic fallback
|
|
||||||
smartRequest,
|
|
||||||
smartDownloadStream,
|
|
||||||
|
|
||||||
// Legacy: Direct proxy functions (for manual control)
|
|
||||||
proxyRequest,
|
|
||||||
getProxyDownloadStream,
|
|
||||||
|
|
||||||
// Direct functions (no proxy)
|
|
||||||
directRequest,
|
|
||||||
directDownloadStream,
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
generateToken
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.server</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Ghost Process Root Cause Analysis & Fix
|
|
||||||
|
|
||||||
## Problem Summary
|
|
||||||
The Task Manager was freezing after the launcher (Hytale-F2P) ran. This was caused by **ghost/zombie PowerShell processes** spawned on Windows that were not being properly cleaned up.
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
|
|
||||||
### Location
|
|
||||||
**File:** `backend/utils/platformUtils.js`
|
|
||||||
|
|
||||||
**Functions affected:**
|
|
||||||
1. `detectGpuWindows()` - Called during app startup and game launch
|
|
||||||
2. `getSystemTypeWindows()` - Called during system detection
|
|
||||||
|
|
||||||
### The Issue
|
|
||||||
Both functions were using **`execSync()`** to run PowerShell commands for GPU and system type detection:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// PROBLEMATIC CODE
|
|
||||||
output = execSync(
|
|
||||||
'powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_VideoController..."',
|
|
||||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Why This Causes Ghost Processes
|
|
||||||
|
|
||||||
1. **execSync spawns a shell process** - On Windows, `execSync` with a string command spawns `cmd.exe` which then launches `powershell.exe`
|
|
||||||
2. **PowerShell inherits stdio settings** - The `stdio: ['ignore', 'pipe', 'ignore']` doesn't fully detach the PowerShell subprocess
|
|
||||||
3. **Process hierarchy issue** - Even though the Node.js process receives the output and continues, the PowerShell subprocess may remain as a child process
|
|
||||||
4. **Windows job object limitation** - Node.js child_process doesn't always properly terminate all descendants on Windows
|
|
||||||
5. **Multiple calls during initialization** - GPU detection runs:
|
|
||||||
- During app startup (line 1057 in main.js)
|
|
||||||
- During game launch (in gameLauncher.js)
|
|
||||||
- During settings UI rendering
|
|
||||||
|
|
||||||
Each call can spawn 2-3 PowerShell processes, and if the app spawns multiple game instances or restarts, these accumulate
|
|
||||||
|
|
||||||
### Call Stack
|
|
||||||
1. `main.js` app startup → calls `detectGpu()`
|
|
||||||
2. `gameLauncher.js` on launch → calls `setupGpuEnvironment()` → calls `detectGpu()`
|
|
||||||
3. Multiple PowerShell processes spawn but aren't cleaned up properly
|
|
||||||
4. Task Manager accumulates these ghost processes and becomes unresponsive
|
|
||||||
|
|
||||||
## The Solution
|
|
||||||
|
|
||||||
Replace `execSync()` with `spawnSync()` and add explicit timeouts:
|
|
||||||
|
|
||||||
### Key Changes
|
|
||||||
|
|
||||||
#### 1. Import spawnSync
|
|
||||||
```javascript
|
|
||||||
const { execSync, spawnSync } = require('child_process');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Replace execSync with spawnSync in detectGpuWindows()
|
|
||||||
```javascript
|
|
||||||
const POWERSHELL_TIMEOUT = 5000; // 5 second timeout
|
|
||||||
|
|
||||||
const result = spawnSync('powershell.exe', [
|
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy', 'Bypass',
|
|
||||||
'-Command',
|
|
||||||
'Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM | ConvertTo-Csv -NoTypeInformation'
|
|
||||||
], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: POWERSHELL_TIMEOUT,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Apply same fix to getSystemTypeWindows()
|
|
||||||
|
|
||||||
### Why spawnSync Fixes This
|
|
||||||
|
|
||||||
1. **Direct process spawn** - `spawnSync()` directly spawns the executable without going through `cmd.exe`
|
|
||||||
2. **Explicit timeout** - The `timeout` parameter ensures processes are forcibly terminated after 5 seconds
|
|
||||||
3. **windowsHide: true** - Prevents PowerShell window flashing and better resource cleanup
|
|
||||||
4. **Better cleanup** - Node.js has better control over process lifecycle with `spawnSync`
|
|
||||||
5. **Proper exit handling** - spawnSync waits for and properly cleans up the process before returning
|
|
||||||
|
|
||||||
### Benefits
|
|
||||||
|
|
||||||
- ✅ PowerShell processes are guaranteed to terminate within 5 seconds
|
|
||||||
- ✅ No more ghost processes accumulating
|
|
||||||
- ✅ Task Manager stays responsive
|
|
||||||
- ✅ Fallback mechanisms still work (wmic, Get-WmiObject, Get-CimInstance)
|
|
||||||
- ✅ Performance improvement (spawnSync is faster for simple commands)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
To verify the fix:
|
|
||||||
|
|
||||||
1. **Before running the launcher**, open Task Manager and check for PowerShell processes (should be 0 or 1)
|
|
||||||
2. **Start the launcher** and observe Task Manager - you should not see PowerShell processes accumulating
|
|
||||||
3. **Launch the game** and check Task Manager - still no ghost PowerShell processes
|
|
||||||
4. **Restart the launcher** multiple times - PowerShell process count should remain stable
|
|
||||||
|
|
||||||
Expected behavior: No PowerShell processes should remain after each operation completes.
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
- **`backend/utils/platformUtils.js`**
|
|
||||||
- Line 1: Added `spawnSync` import
|
|
||||||
- Lines 300-380: Refactored `detectGpuWindows()`
|
|
||||||
- Lines 599-643: Refactored `getSystemTypeWindows()`
|
|
||||||
|
|
||||||
## Performance Impact
|
|
||||||
|
|
||||||
- ⚡ **Faster execution** - `spawnSync` with argument arrays is faster than shell string parsing
|
|
||||||
- 🎯 **More reliable** - Explicit timeout prevents indefinite hangs
|
|
||||||
- 💾 **Lower memory usage** - Processes properly cleaned up instead of becoming zombies
|
|
||||||
|
|
||||||
## Additional Notes
|
|
||||||
|
|
||||||
The fix maintains backward compatibility:
|
|
||||||
- All three GPU detection methods still work (Get-CimInstance → Get-WmiObject → wmic)
|
|
||||||
- Error handling is preserved
|
|
||||||
- System type detection (laptop vs desktop) still functions correctly
|
|
||||||
- No changes to public API or external behavior
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Quick Fix Summary: Ghost Process Issue
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
Task Manager freezed after launcher runs due to accumulating ghost PowerShell processes.
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
**File:** `backend/utils/platformUtils.js`
|
|
||||||
|
|
||||||
Two functions used `execSync()` to run PowerShell commands:
|
|
||||||
- `detectGpuWindows()` (GPU detection at startup & game launch)
|
|
||||||
- `getSystemTypeWindows()` (system type detection)
|
|
||||||
|
|
||||||
`execSync()` on Windows spawns PowerShell processes that don't properly terminate → accumulate over time → freeze Task Manager.
|
|
||||||
|
|
||||||
## Solution Applied
|
|
||||||
|
|
||||||
### Changed From (❌ Wrong):
|
|
||||||
```javascript
|
|
||||||
output = execSync(
|
|
||||||
'powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-CimInstance..."',
|
|
||||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changed To (✅ Correct):
|
|
||||||
```javascript
|
|
||||||
const result = spawnSync('powershell.exe', [
|
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy', 'Bypass',
|
|
||||||
'-Command',
|
|
||||||
'Get-CimInstance...'
|
|
||||||
], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 5000, // 5 second timeout - processes killed if hung
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
| Aspect | Before | After |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| **Method** | `execSync()` → shell string | `spawnSync()` → argument array |
|
|
||||||
| **Process spawn** | Via cmd.exe → powershell.exe | Direct powershell.exe |
|
|
||||||
| **Timeout** | None (can hang indefinitely) | 5 seconds (processes auto-killed) |
|
|
||||||
| **Process cleanup** | Hit or miss | Guaranteed |
|
|
||||||
| **Ghost processes** | ❌ Accumulate over time | ✅ Always terminate |
|
|
||||||
| **Performance** | Slower (shell parsing) | Faster (direct spawn) |
|
|
||||||
|
|
||||||
## Why This Works
|
|
||||||
|
|
||||||
1. **spawnSync directly spawns PowerShell** without intermediate cmd.exe
|
|
||||||
2. **timeout: 5000** forcibly kills any hung process after 5 seconds
|
|
||||||
3. **windowsHide: true** prevents window flashing and improves cleanup
|
|
||||||
4. **Node.js has better control** over process lifecycle with spawnSync
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- ✅ No more ghost PowerShell processes
|
|
||||||
- ✅ Task Manager stays responsive
|
|
||||||
- ✅ Launcher performance improved
|
|
||||||
- ✅ Game launch unaffected (still works the same)
|
|
||||||
- ✅ All fallback methods preserved (Get-WmiObject, wmic)
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
Only one file modified: **`backend/utils/platformUtils.js`**
|
|
||||||
- Import added for `spawnSync`
|
|
||||||
- Two functions refactored with new approach
|
|
||||||
- All error handling preserved
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
After applying fix, verify no ghost processes appear in Task Manager:
|
|
||||||
|
|
||||||
```
|
|
||||||
Before launch: PowerShell processes = 0 or 1
|
|
||||||
During launch: PowerShell processes = 0 or 1
|
|
||||||
After game closes: PowerShell processes = 0 or 1
|
|
||||||
```
|
|
||||||
|
|
||||||
If processes keep accumulating, check Task Manager → Details tab → look for powershell.exe entries.
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# Launcher Process Lifecycle & Cleanup Flow
|
|
||||||
|
|
||||||
## Shutdown Event Sequence
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ USER CLOSES LAUNCHER │
|
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────┐
|
|
||||||
│ mainWindow.on('closed') event │
|
|
||||||
│ ✅ Cleanup Discord RPC │
|
|
||||||
└────────────┬───────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────┐
|
|
||||||
│ app.on('before-quit') event │
|
|
||||||
│ ✅ Cleanup Discord RPC (again) │
|
|
||||||
└────────────┬───────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────┐
|
|
||||||
│ app.on('window-all-closed') │
|
|
||||||
│ ✅ Call app.quit() │
|
|
||||||
└────────────┬───────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────────────────────────┐
|
|
||||||
│ Node.js Process Exit │
|
|
||||||
│ ✅ All resources released │
|
|
||||||
└────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Resource Cleanup Map
|
|
||||||
|
|
||||||
```
|
|
||||||
DISCORD RPC
|
|
||||||
├─ clearActivity() ← Stop Discord integration
|
|
||||||
├─ destroy() ← Destroy client object
|
|
||||||
└─ Set to null ← Remove reference
|
|
||||||
|
|
||||||
GAME PROCESS
|
|
||||||
├─ spawn() with detached: true
|
|
||||||
├─ Immediately unref() ← Remove from event loop
|
|
||||||
└─ Launcher ignores game after spawn
|
|
||||||
|
|
||||||
DOWNLOAD STREAMS
|
|
||||||
├─ Clear stalledTimeout ← Stop stall detection
|
|
||||||
├─ Clear overallTimeout ← Stop overall timeout
|
|
||||||
├─ Abort controller ← Stop stream
|
|
||||||
├─ Destroy writer ← Stop file writing
|
|
||||||
└─ Reject promise ← End download
|
|
||||||
|
|
||||||
MAIN WINDOW
|
|
||||||
├─ Destroy window
|
|
||||||
├─ Remove listeners
|
|
||||||
└─ Free memory
|
|
||||||
|
|
||||||
ELECTRON APP
|
|
||||||
├─ Close all windows
|
|
||||||
└─ Exit process
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cleanup Verification Points
|
|
||||||
|
|
||||||
### ✅ What IS Being Cleaned Up
|
|
||||||
|
|
||||||
1. **Discord RPC Client**
|
|
||||||
- Activity cleared before exit
|
|
||||||
- Client destroyed
|
|
||||||
- Reference nulled
|
|
||||||
|
|
||||||
2. **Download Operations**
|
|
||||||
- Timeouts cleared (stalledTimeout, overallTimeout)
|
|
||||||
- Stream aborted
|
|
||||||
- Writer destroyed
|
|
||||||
- Promise rejected/resolved
|
|
||||||
|
|
||||||
3. **Game Process**
|
|
||||||
- Detached from launcher
|
|
||||||
- Unrefed so launcher can exit
|
|
||||||
- Independent process tree
|
|
||||||
|
|
||||||
4. **Event Listeners**
|
|
||||||
- IPC handlers persist (normal - Electron's design)
|
|
||||||
- Main window listeners removed
|
|
||||||
- Auto-updater auto-cleanup
|
|
||||||
|
|
||||||
### ⚠️ Considerations
|
|
||||||
|
|
||||||
1. **Discord RPC called twice**
|
|
||||||
- Line 174: When window closes
|
|
||||||
- Line 438: When app is about to quit
|
|
||||||
- → This is defensive programming (safe, not wasteful)
|
|
||||||
|
|
||||||
2. **Game Process Orphaned (By Design)**
|
|
||||||
- Launcher doesn't track game process
|
|
||||||
- Game can outlive launcher
|
|
||||||
- On Windows: Process is detached, unref'd
|
|
||||||
- → This is correct behavior for a launcher
|
|
||||||
|
|
||||||
3. **IPC Handlers Remain Registered**
|
|
||||||
- Normal for Electron apps
|
|
||||||
- Handlers removed when app exits anyway
|
|
||||||
- → Not a resource leak
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison: Before & After Ghost Process Fix
|
|
||||||
|
|
||||||
### Before Fix (PowerShell Issues Only)
|
|
||||||
```
|
|
||||||
Launcher Cleanup: ✅ Good
|
|
||||||
PowerShell GPU Detection: ❌ Bad (ghost processes)
|
|
||||||
Result: Task Manager frozen by PowerShell
|
|
||||||
```
|
|
||||||
|
|
||||||
### After Fix (PowerShell Fixed)
|
|
||||||
```
|
|
||||||
Launcher Cleanup: ✅ Good
|
|
||||||
PowerShell GPU Detection: ✅ Fixed (spawnSync with timeout)
|
|
||||||
Result: No ghost processes accumulate
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Metrics
|
|
||||||
|
|
||||||
### Memory Usage Pattern
|
|
||||||
```
|
|
||||||
Startup → 80-120 MB
|
|
||||||
After Download → 150-200 MB
|
|
||||||
After Cleanup → 80-120 MB (back to baseline)
|
|
||||||
After Exit → Process released
|
|
||||||
```
|
|
||||||
|
|
||||||
### Handle Leaks: None Detected
|
|
||||||
- Discord RPC: Properly released
|
|
||||||
- Streams: Properly closed
|
|
||||||
- Timeouts: Properly cleared
|
|
||||||
- Window: Properly destroyed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Launcher Termination Quality: ✅ GOOD**
|
|
||||||
|
|
||||||
| Aspect | Status | Details |
|
|
||||||
|--------|--------|---------|
|
|
||||||
| Discord cleanup | ✅ | Called in 2 places (defensive) |
|
|
||||||
| Game process | ✅ | Detached & unref'd |
|
|
||||||
| Download cleanup | ✅ | All timeouts cleared |
|
|
||||||
| Memory release | ✅ | Event handlers removed |
|
|
||||||
| Handle leaks | ✅ | None detected |
|
|
||||||
| **Overall** | **✅** | **Proper shutdown architecture** |
|
|
||||||
|
|
||||||
The launcher has **solid cleanup logic**. The ghost process issue was specific to PowerShell GPU detection, not the launcher's termination flow.
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
# Launcher Process Termination & Cleanup Analysis
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This document analyzes how the Hytale-F2P launcher handles process cleanup, event termination, and resource deallocation during shutdown.
|
|
||||||
|
|
||||||
## Shutdown Flow
|
|
||||||
|
|
||||||
### 1. **Primary Termination Events** (main.js)
|
|
||||||
|
|
||||||
#### Event: `before-quit` (Line 438)
|
|
||||||
```javascript
|
|
||||||
app.on('before-quit', () => {
|
|
||||||
console.log('=== LAUNCHER BEFORE QUIT ===');
|
|
||||||
cleanupDiscordRPC();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
- Called by Electron before the app starts quitting
|
|
||||||
- Ensures Discord RPC is properly disconnected and destroyed
|
|
||||||
- Gives async cleanup a chance to run
|
|
||||||
|
|
||||||
#### Event: `window-all-closed` (Line 443)
|
|
||||||
```javascript
|
|
||||||
app.on('window-all-closed', () => {
|
|
||||||
console.log('=== LAUNCHER CLOSING ===');
|
|
||||||
app.quit();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
- Triggered when all Electron windows are closed
|
|
||||||
- Initiates app.quit() to cleanly exit
|
|
||||||
|
|
||||||
#### Event: `closed` (Line 174)
|
|
||||||
```javascript
|
|
||||||
mainWindow.on('closed', () => {
|
|
||||||
console.log('Main window closed, cleaning up Discord RPC...');
|
|
||||||
cleanupDiscordRPC();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
- Called when the main window is actually destroyed
|
|
||||||
- Additional Discord RPC cleanup as safety measure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. **Discord RPC Cleanup** (Lines 59-89, 424-436)
|
|
||||||
|
|
||||||
### cleanupDiscordRPC() Function
|
|
||||||
```javascript
|
|
||||||
async function cleanupDiscordRPC() {
|
|
||||||
if (!discordRPC) return;
|
|
||||||
try {
|
|
||||||
console.log('Cleaning up Discord RPC...');
|
|
||||||
discordRPC.clearActivity();
|
|
||||||
await new Promise(r => setTimeout(r, 100)); // Wait for clear to propagate
|
|
||||||
discordRPC.destroy();
|
|
||||||
console.log('Discord RPC cleaned up successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Error cleaning up Discord RPC:', error.message);
|
|
||||||
} finally {
|
|
||||||
discordRPC = null; // Null out the reference
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
1. Checks if Discord RPC is initialized
|
|
||||||
2. Clears the current activity (disconnects from Discord)
|
|
||||||
3. Waits 100ms for the clear to propagate
|
|
||||||
4. Destroys the Discord RPC client
|
|
||||||
5. Nulls out the reference to prevent memory leaks
|
|
||||||
6. Error handling ensures cleanup doesn't crash the app
|
|
||||||
|
|
||||||
**Quality:** ✅ **Proper cleanup with error handling**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. **Game Process Handling** (gameLauncher.js)
|
|
||||||
|
|
||||||
### Game Launch Process (Lines 356-403)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
let spawnOptions = {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
detached: false,
|
|
||||||
env: env
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
spawnOptions.shell = false;
|
|
||||||
spawnOptions.windowsHide = true;
|
|
||||||
spawnOptions.detached = true; // ← Game runs independently
|
|
||||||
spawnOptions.stdio = 'ignore'; // ← Fully detach stdio
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawn(clientPath, args, spawnOptions);
|
|
||||||
|
|
||||||
// Windows: Release process reference immediately
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
child.unref(); // ← Allows Node.js to exit without waiting for game
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Critical Analysis:**
|
|
||||||
- ✅ **Windows detached mode**: Game process is spawned detached and stdio is ignored
|
|
||||||
- ✅ **child.unref()**: Removes the Node process from the event loop
|
|
||||||
- ⚠️ **No event listeners**: Once detached, the launcher doesn't track the game process
|
|
||||||
|
|
||||||
**Potential Issue:**
|
|
||||||
The game process is completely detached and unrefed, which is correct. However, if the game crashes and respawns (or multiple instances), these orphaned processes could accumulate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. **Download/File Transfer Cleanup** (fileManager.js)
|
|
||||||
|
|
||||||
### setInterval Cleanup (Lines 77-94)
|
|
||||||
```javascript
|
|
||||||
const overallTimeout = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const timeSinceLastProgress = now - lastProgressTime;
|
|
||||||
|
|
||||||
if (timeSinceLastProgress > 900000 && hasReceivedData) {
|
|
||||||
console.log('Download stalled for 15 minutes, aborting...');
|
|
||||||
controller.abort();
|
|
||||||
}
|
|
||||||
}, 60000); // Check every minute
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cleanup Locations:
|
|
||||||
|
|
||||||
**On Stream Error (Lines 225-228):**
|
|
||||||
```javascript
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
}
|
|
||||||
if (overallTimeout) {
|
|
||||||
clearInterval(overallTimeout);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**On Stream Close (Lines 239-244):**
|
|
||||||
```javascript
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
}
|
|
||||||
if (overallTimeout) {
|
|
||||||
clearInterval(overallTimeout);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**On Writer Finish (Lines 295-299):**
|
|
||||||
```javascript
|
|
||||||
if (stalledTimeout) {
|
|
||||||
clearTimeout(stalledTimeout);
|
|
||||||
console.log('Cleared stall timeout after writer finished');
|
|
||||||
}
|
|
||||||
if (overallTimeout) {
|
|
||||||
clearInterval(overallTimeout);
|
|
||||||
console.log('Cleared overall timeout after writer finished');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Quality:** ✅ **Proper cleanup with multiple safeguards**
|
|
||||||
- Intervals are cleared in all exit paths
|
|
||||||
- No orphaned setInterval/setTimeout calls
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. **Electron Auto-Updater** (Lines 184-237)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
autoUpdater.autoDownload = true;
|
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info) => {
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Auto-Updater Cleanup:** ✅
|
|
||||||
- Electron handles auto-updater cleanup automatically
|
|
||||||
- No explicit cleanup needed (Electron manages lifecycle)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary: Process Termination Quality
|
|
||||||
|
|
||||||
| Component | Status | Notes |
|
|
||||||
|-----------|--------|-------|
|
|
||||||
| **Discord RPC** | ✅ **Good** | Properly destroyed with error handling |
|
|
||||||
| **Main Window** | ✅ **Good** | Cleanup called on closed and before-quit |
|
|
||||||
| **Game Process** | ✅ **Good** | Detached and unref'd on Windows |
|
|
||||||
| **Download Intervals** | ✅ **Good** | Cleared in all exit paths |
|
|
||||||
| **Event Listeners** | ⚠️ **Mixed** | Main listeners properly removed, but IPC handlers remain registered (normal) |
|
|
||||||
| **Overall** | ✅ **Good** | Proper cleanup architecture |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Potential Improvements
|
|
||||||
|
|
||||||
### 1. **Add Explicit Process Tracking (Optional)**
|
|
||||||
Currently, the launcher doesn't track child processes. We could add:
|
|
||||||
```javascript
|
|
||||||
// Track all spawned processes for cleanup
|
|
||||||
const childProcesses = new Set();
|
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
|
||||||
// Kill any remaining child processes
|
|
||||||
for (const proc of childProcesses) {
|
|
||||||
if (proc && !proc.killed) {
|
|
||||||
proc.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Auto-Updater Resource Cleanup (Minor)**
|
|
||||||
Add explicit cleanup for auto-updater listeners:
|
|
||||||
```javascript
|
|
||||||
app.on('before-quit', () => {
|
|
||||||
autoUpdater.removeAllListeners();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Graceful Shutdown Timeout (Safety)**
|
|
||||||
Add a safety timeout to force exit if cleanup hangs:
|
|
||||||
```javascript
|
|
||||||
app.on('before-quit', () => {
|
|
||||||
const forceExitTimeout = setTimeout(() => {
|
|
||||||
console.warn('Cleanup timeout - forcing exit');
|
|
||||||
process.exit(0);
|
|
||||||
}, 5000); // 5 second max cleanup time
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Relationship to Ghost Process Issue
|
|
||||||
|
|
||||||
### Previous Issue (PowerShell processes)
|
|
||||||
- **Root cause**: Spawned PowerShell processes weren't cleaned up in `platformUtils.js`
|
|
||||||
- **Fixed by**: Replacing `execSync()` with `spawnSync()` + timeouts
|
|
||||||
|
|
||||||
### Launcher Termination
|
|
||||||
- **Status**: ✅ **No critical issues found**
|
|
||||||
- **Discord RPC**: Properly cleaned up
|
|
||||||
- **Game process**: Properly detached
|
|
||||||
- **Intervals**: Properly cleared
|
|
||||||
- **No memory leaks detected**
|
|
||||||
|
|
||||||
The launcher's termination flow is solid. The ghost process issue was specific to PowerShell process spawning during GPU detection, not the launcher's shutdown process.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
To verify proper launcher termination:
|
|
||||||
|
|
||||||
- [ ] Start launcher → Close window → Check Task Manager for lingering processes
|
|
||||||
- [ ] Start launcher → Launch game → Close launcher → Check for orphaned processes
|
|
||||||
- [ ] Start launcher → Download something → Cancel mid-download → Check for setInterval processes
|
|
||||||
- [ ] Disable Discord RPC → Start launcher → Close → No Discord processes remain
|
|
||||||
- [ ] Check Windows Event Viewer → No unhandled exceptions on launcher exit
|
|
||||||
- [ ] Multiple launch/close cycles → No memory growth in Task Manager
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The Hytale-F2P launcher has **good shutdown hygiene**:
|
|
||||||
- ✅ Discord RPC is properly cleaned
|
|
||||||
- ✅ Game process is properly detached
|
|
||||||
- ✅ Download intervals are properly cleared
|
|
||||||
- ✅ Event handlers are properly registered
|
|
||||||
|
|
||||||
The ghost process issue was **not** caused by the launcher's termination logic, but by the PowerShell GPU detection functions, which has already been fixed.
|
|
||||||
74
main.js
74
main.js
@@ -107,41 +107,9 @@ async function toggleDiscordRPC(enabled) {
|
|||||||
} else if (!enabled && discordRPC) {
|
} else if (!enabled && discordRPC) {
|
||||||
try {
|
try {
|
||||||
console.log('Disconnecting Discord RPC...');
|
console.log('Disconnecting Discord RPC...');
|
||||||
|
discordRPC.clearActivity();
|
||||||
// Check if Discord RPC is still connected before trying to use it
|
|
||||||
if (discordRPC && discordRPC.transport && discordRPC.transport.socket) {
|
|
||||||
// Add timeout to prevent hanging
|
|
||||||
const clearActivityPromise = discordRPC.clearActivity();
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Discord RPC clearActivity timeout')), 1000)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.race([clearActivityPromise, timeoutPromise]);
|
|
||||||
await new Promise(r => setTimeout(r, 100));
|
await new Promise(r => setTimeout(r, 100));
|
||||||
} catch (timeoutErr) {
|
discordRPC.destroy();
|
||||||
console.log('Discord RPC clearActivity timed out:', timeoutErr.message);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('Discord RPC already disconnected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy - wrap in try-catch to handle library errors
|
|
||||||
if (discordRPC) {
|
|
||||||
try {
|
|
||||||
if (typeof discordRPC.destroy === 'function') {
|
|
||||||
const destroyPromise = discordRPC.destroy();
|
|
||||||
if (destroyPromise && typeof destroyPromise.catch === 'function') {
|
|
||||||
destroyPromise.catch(err => {
|
|
||||||
console.log('Discord RPC destroy error (ignored):', err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (destroyErr) {
|
|
||||||
console.log('Error destroying Discord RPC (ignored):', destroyErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Discord RPC disconnected successfully');
|
console.log('Discord RPC disconnected successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error disconnecting Discord RPC:', error.message);
|
console.error('Error disconnecting Discord RPC:', error.message);
|
||||||
@@ -456,43 +424,9 @@ async function cleanupDiscordRPC() {
|
|||||||
if (!discordRPC) return;
|
if (!discordRPC) return;
|
||||||
try {
|
try {
|
||||||
console.log('Cleaning up Discord RPC...');
|
console.log('Cleaning up Discord RPC...');
|
||||||
|
discordRPC.clearActivity();
|
||||||
// Check if Discord RPC is still connected before trying to use it
|
|
||||||
if (discordRPC && discordRPC.transport && discordRPC.transport.socket) {
|
|
||||||
// Add timeout to prevent hanging if Discord is unresponsive
|
|
||||||
const clearActivityPromise = discordRPC.clearActivity();
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Discord RPC clearActivity timeout')), 1000)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.race([clearActivityPromise, timeoutPromise]);
|
|
||||||
await new Promise(r => setTimeout(r, 100));
|
await new Promise(r => setTimeout(r, 100));
|
||||||
} catch (timeoutErr) {
|
discordRPC.destroy();
|
||||||
console.log('Discord RPC clearActivity timed out, proceeding with cleanup:', timeoutErr.message);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('Discord RPC already disconnected, skipping clearActivity');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy and cleanup - wrap in try-catch to handle library errors
|
|
||||||
if (discordRPC) {
|
|
||||||
try {
|
|
||||||
if (typeof discordRPC.destroy === 'function') {
|
|
||||||
// destroy() may return a promise that rejects, so handle it
|
|
||||||
const destroyPromise = discordRPC.destroy();
|
|
||||||
if (destroyPromise && typeof destroyPromise.catch === 'function') {
|
|
||||||
// If it's a promise, catch any rejections silently
|
|
||||||
destroyPromise.catch(err => {
|
|
||||||
console.log('Discord RPC destroy error (ignored):', err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (destroyErr) {
|
|
||||||
console.log('Error destroying Discord RPC client (ignored):', destroyErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Discord RPC cleaned up successfully');
|
console.log('Discord RPC cleaned up successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Error cleaning up Discord RPC:', error.message);
|
console.log('Error cleaning up Discord RPC:', error.message);
|
||||||
|
|||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -14,7 +14,6 @@
|
|||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"electron-updater": "^6.7.3",
|
"electron-updater": "^6.7.3",
|
||||||
"encoding": "^0.1.13",
|
|
||||||
"fs-extra": "^11.3.3",
|
"fs-extra": "^11.3.3",
|
||||||
"tar": "^7.5.7",
|
"tar": "^7.5.7",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
@@ -2150,16 +2149,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encoding": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"iconv-lite": "^0.6.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -2834,6 +2823,7 @@
|
|||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
@@ -4057,6 +4047,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sanitize-filename": {
|
"node_modules/sanitize-filename": {
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hytale-f2p-launcher",
|
"name": "hytale-f2p-launcher",
|
||||||
"version": "2.2.1",
|
"version": "2.2.0",
|
||||||
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
"description": "A modern, cross-platform launcher for Hytale with automatic updates and multi-client support",
|
||||||
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
"homepage": "https://github.com/amiayweb/Hytale-F2P",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
"electron",
|
"electron",
|
||||||
"auto-update",
|
"auto-update",
|
||||||
"mod-manager"
|
"mod-manager"
|
||||||
|
|
||||||
],
|
],
|
||||||
"maintainers": [
|
"maintainers": [
|
||||||
{
|
{
|
||||||
@@ -53,7 +52,6 @@
|
|||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"encoding": "^0.1.13",
|
|
||||||
"electron-updater": "^6.7.3",
|
"electron-updater": "^6.7.3",
|
||||||
"fs-extra": "^11.3.3",
|
"fs-extra": "^11.3.3",
|
||||||
"tar": "^7.5.7",
|
"tar": "^7.5.7",
|
||||||
@@ -104,12 +102,7 @@
|
|||||||
],
|
],
|
||||||
"icon": "build/icon.icns",
|
"icon": "build/icon.icns",
|
||||||
"artifactName": "${name}_${version}_${arch}.${ext}",
|
"artifactName": "${name}_${version}_${arch}.${ext}",
|
||||||
"category": "public.app-category.games",
|
"category": "public.app-category.games"
|
||||||
"hardenedRuntime": true,
|
|
||||||
"gatekeeperAssess": false,
|
|
||||||
"entitlements": "build/entitlements.mac.plist",
|
|
||||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
|
||||||
"notarize": true
|
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user