diff --git a/index.html b/index.html index 356bc5a..e8453dc 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,10 @@ + +
- +
Downloading...
@@ -157,6 +161,22 @@
+ + + + +
+ + +
+
diff --git a/package.json b/package.json index d482e8c..38b866b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "legacylauncher", - "version": "2.9.1", + "version": "3.0.0", "description": "", "main": "main.js", "scripts": { diff --git a/renderer.js b/renderer.js index a0b4b2f..53a08fa 100644 --- a/renderer.js +++ b/renderer.js @@ -20,6 +20,8 @@ let currentReleaseIndex = 0; let isProcessing = false; let isGameRunning = false; +let snapshotInstanceId = null; + const Store = { async get(key, defaultValue) { const val = await ipcRenderer.invoke('store-get', key); @@ -33,7 +35,6 @@ const Store = { } }; -// Gamepad Controller Support const GamepadManager = { active: false, lastInputTime: 0, @@ -138,7 +139,7 @@ const GamepadManager = { }, getVisibleNavItems() { - const modals = ['update-modal', 'options-modal', 'profile-modal', 'servers-modal', 'instances-modal', 'add-instance-modal', 'skin-modal']; + const modals = ['update-modal', 'options-modal', 'profile-modal', 'servers-modal', 'instances-modal', 'add-instance-modal', 'skin-modal', 'snapshots-modal']; let activeModal = null; for (const id of modals) { const m = document.getElementById(id); @@ -237,11 +238,12 @@ const GamepadManager = { else if (activeModal.id === 'add-instance-modal') toggleAddInstance(false); else if (activeModal.id === 'update-modal') document.getElementById('btn-skip-update')?.click(); else if (activeModal.id === 'skin-modal') closeSkinManager(); + else if (activeModal.id === 'snapshots-modal') toggleSnapshots(false); } }, getActiveModal() { - const modals = ['update-modal', 'options-modal', 'profile-modal', 'servers-modal', 'instances-modal', 'add-instance-modal', 'skin-modal']; + const modals = ['update-modal', 'options-modal', 'profile-modal', 'servers-modal', 'instances-modal', 'add-instance-modal', 'skin-modal', 'snapshots-modal']; for (const id of modals) { const m = document.getElementById(id); if (m && m.style.display === 'flex') return m; @@ -284,10 +286,13 @@ const GamepadManager = { scrollActive(val) { const serverList = document.getElementById('servers-list-container'); const instanceList = document.getElementById('instances-list-container'); + const snapshotList = document.getElementById('snapshots-list-container'); if (this.getActiveModal()?.id === 'servers-modal' && serverList) { serverList.scrollTop += val; } else if (this.getActiveModal()?.id === 'instances-modal' && instanceList) { instanceList.scrollTop += val; + } else if (this.getActiveModal()?.id === 'snapshots-modal' && snapshotList) { + snapshotList.scrollTop += val; } else if (!this.getActiveModal()) { const sidebar = document.getElementById('updates-list')?.parentElement; if (sidebar) sidebar.scrollTop += val; @@ -295,7 +300,6 @@ const GamepadManager = { } }; -// Music Player logic const MusicManager = { audio: new Audio(), playlist: [], @@ -304,12 +308,21 @@ const MusicManager = { async init() { this.enabled = await Store.get('legacy_music_enabled', true); + this.audio.volume = await Store.get('legacy_music_volume', 0.5); this.updateIcon(); - this.audio.volume = 1.0; this.audio.onended = () => this.playNext(); if (this.enabled) { this.start(); } + + const slider = document.getElementById('volume-slider'); + if (slider) { + slider.value = this.audio.volume; + slider.oninput = async () => { + this.audio.volume = slider.value; + await Store.set('legacy_music_volume', this.audio.volume); + }; + } }, async scan() { @@ -430,40 +443,75 @@ async function migrateLegacyConfig() { } window.onload = async () => { - await migrateLegacyConfig(); - - document.getElementById('repo-input').value = currentInstance.repo; - document.getElementById('exec-input').value = currentInstance.execPath; - document.getElementById('username-input').value = await Store.get('legacy_username', ""); - document.getElementById('ip-input').value = currentInstance.ip; - document.getElementById('port-input').value = currentInstance.port; - document.getElementById('server-checkbox').checked = currentInstance.isServer; - document.getElementById('install-path-input').value = currentInstance.installPath; - - if (process.platform === 'linux' || process.platform === 'darwin') { - document.getElementById('compat-option-container').style.display = 'block'; - scanCompatibilityLayers(); - } else { - currentInstance.compatLayer = 'direct'; - await saveInstancesToStore(); - } + try { + await migrateLegacyConfig(); + + const repoInput = document.getElementById('repo-input'); + const execInput = document.getElementById('exec-input'); + const usernameInput = document.getElementById('username-input'); + const ipInput = document.getElementById('ip-input'); + const portInput = document.getElementById('port-input'); + const serverCheck = document.getElementById('server-checkbox'); + const installInput = document.getElementById('install-path-input'); - ipcRenderer.on('window-is-maximized', (event, isMaximized) => { - const btn = document.getElementById('maximize-btn'); - if (btn) btn.textContent = isMaximized ? '❐' : '▢'; - }); - - fetchGitHubData(); - checkForLauncherUpdates(); - loadSplashText(); - MusicManager.init(); - GamepadManager.init(); - - window.addEventListener('keydown', (e) => { - if (e.key === 'F9') { - checkForLauncherUpdates(true); + if (repoInput) repoInput.value = currentInstance.repo; + if (execInput) execInput.value = currentInstance.execPath; + if (usernameInput) usernameInput.value = await Store.get('legacy_username', ""); + if (ipInput) ipInput.value = currentInstance.ip; + if (portInput) portInput.value = currentInstance.port; + if (serverCheck) serverCheck.checked = currentInstance.isServer; + if (installInput) installInput.value = currentInstance.installPath; + + if (process.platform === 'linux' || process.platform === 'darwin') { + const compatContainer = document.getElementById('compat-option-container'); + if (compatContainer) { + compatContainer.style.display = 'block'; + scanCompatibilityLayers(); + } + } else { + currentInstance.compatLayer = 'direct'; + await saveInstancesToStore(); } - }); + + ipcRenderer.on('window-is-maximized', (event, isMaximized) => { + const btn = document.getElementById('maximize-btn'); + if (btn) btn.textContent = isMaximized ? '❐' : '▢'; + }); + + // Initialize features + fetchGitHubData(); + checkForLauncherUpdates(); + loadSplashText(); + MusicManager.init(); + GamepadManager.init(); + + window.addEventListener('keydown', (e) => { + if (e.key === 'F9') { + checkForLauncherUpdates(true); + } + }); + + window.addEventListener('online', () => { + document.getElementById('offline-indicator').style.display = 'none'; + showToast("Back Online! Refreshing..."); + fetchGitHubData(); + }); + + window.addEventListener('offline', () => { + document.getElementById('offline-indicator').style.display = 'block'; + showToast("Connection Lost. Entering Offline Mode."); + }); + + if (!navigator.onLine) { + document.getElementById('offline-indicator').style.display = 'block'; + } + } catch (e) { + console.error("Startup error:", e); + // Hide loader anyway so user isn't stuck + const loader = document.getElementById('loader'); + if (loader) loader.style.display = 'none'; + showToast("Error during startup: " + e.message); + } }; async function saveInstancesToStore() { @@ -509,6 +557,7 @@ async function renderInstancesList() { ${inst.installPath}
+
BACKUPS
${!isActive ? `
SWITCH
` : ''}
DELETE
@@ -645,6 +694,19 @@ async function updatePlayButtonText() { btn.classList.remove('running'); } + // Offline / No Data Case + if (releasesData.length === 0) { + const fullPath = await getInstalledPath(); + if (currentInstance.installedTag && fs.existsSync(fullPath)) { + btn.textContent = "PLAY"; + btn.classList.remove('disabled'); + } else { + btn.textContent = "OFFLINE"; + btn.classList.add('disabled'); + } + return; + } + const release = releasesData[currentReleaseIndex]; if (!release) { btn.textContent = "PLAY"; @@ -704,9 +766,26 @@ async function fetchGitHubData() { const repo = currentInstance.repo; const loader = document.getElementById('loader'); const loaderText = document.getElementById('loader-text'); + const offlineInd = document.getElementById('offline-indicator'); + if (loader) loader.style.display = 'flex'; if (loaderText) loaderText.textContent = "SYNCING: " + repo; + const hideLoader = () => { + if (loader) { + loader.style.opacity = '0'; + setTimeout(() => { loader.style.display = 'none'; }, 300); + } + }; + + if (!navigator.onLine) { + console.log("Offline detected, skipping GitHub sync."); + if (offlineInd) offlineInd.style.display = 'block'; + handleOfflineData(); + setTimeout(hideLoader, 500); + return; + } + try { const [relRes, commRes] = await Promise.all([ fetch(`https://api.github.com/repos/${repo}/releases`), @@ -721,24 +800,27 @@ async function fetchGitHubData() { populateVersions(); populateUpdatesSidebar(); - setTimeout(() => { - if (loader) { - loader.style.opacity = '0'; - setTimeout(() => loader.style.display = 'none', 300); - } - }, 500); + setTimeout(hideLoader, 500); } catch (err) { + console.error("Fetch error:", err); if (loaderText) loaderText.textContent = "REPO NOT FOUND OR API ERROR"; - showToast("Check repository name in Options."); - setTimeout(() => { - if (loader) { - loader.style.opacity = '0'; - setTimeout(() => loader.style.display = 'none', 300); - } - }, 2500); + + // Even if we fail due to some API error, we should still allow offline play if installed + handleOfflineData(); + + showToast("Entering Offline Mode."); + if (offlineInd) offlineInd.style.display = 'block'; + setTimeout(hideLoader, 2500); } } +function handleOfflineData() { + releasesData = []; + commitsData = []; + populateVersions(); + populateUpdatesSidebar(); +} + function populateVersions() { const select = document.getElementById('version-select'); const display = document.getElementById('current-version-display'); @@ -746,7 +828,17 @@ function populateVersions() { select.innerHTML = ''; if(releasesData.length === 0) { - if (display) display.textContent = "No Releases Found"; + // Check if we have a local version installed + if (currentInstance.installedTag) { + const opt = document.createElement('option'); + opt.value = 0; + opt.textContent = `Installed (${currentInstance.installedTag})`; + select.appendChild(opt); + if (display) display.textContent = opt.textContent; + } else { + if (display) display.textContent = "No Connection / No Install"; + } + updatePlayButtonText(); return; } @@ -798,6 +890,20 @@ function updateSelectedRelease() { async function launchGame() { if (isProcessing || isGameRunning) return; + + if (!navigator.onLine || releasesData.length === 0) { + const fullPath = await getInstalledPath(); + if (currentInstance.installedTag && fs.existsSync(fullPath)) { + setProcessingState(true); + updateProgress(100, "Offline Launch..."); + await launchLocalClient(); + setProcessingState(false); + } else { + showToast("You need an internet connection to install the game!"); + } + return; + } + const release = releasesData[currentReleaseIndex]; if (!release) return; const asset = release.assets.find(a => a.name === TARGET_FILE); @@ -893,7 +999,6 @@ async function launchLocalClient() { try { fs.chmodSync(fullPath, 0o755); } catch (e) { console.warn("Failed to set executable permissions:", e); } } return new Promise(async (resolve, reject) => { - const compat = currentInstance.compatLayer; const username = await Store.get('legacy_username', ""); const ip = currentInstance.ip; const port = currentInstance.port; @@ -906,8 +1011,13 @@ async function launchLocalClient() { const argString = args.map(a => `"${a}"`).join(" "); let cmd = `"${fullPath}" ${argString}`; if (process.platform === 'linux' || process.platform === 'darwin') { + let compat = currentInstance.compatLayer; + if (compat === 'custom' && currentInstance.customCompatPath) { + compat = currentInstance.customCompatPath; + } + if (compat === 'wine64' || compat === 'wine') cmd = `${compat} "${fullPath}" ${argString}`; - else if (compat.includes('Proton')) { + else if (compat.includes('Proton') || compat.includes('/proton') || (currentInstance.compatLayer === 'custom' && currentInstance.customCompatPath)) { const prefix = path.join(path.dirname(fullPath), 'pfx'); if (!fs.existsSync(prefix)) fs.mkdirSync(prefix, { recursive: true }); cmd = `STEAM_COMPAT_CLIENT_INSTALL_PATH="" STEAM_COMPAT_DATA_PATH="${prefix}" "${compat}" run "${fullPath}" ${argString}`; @@ -953,6 +1063,13 @@ async function handleElectronFlow(url) { const parentDir = path.dirname(extractDir); const zipPath = path.join(parentDir, TARGET_FILE); const backupDir = path.join(parentDir, 'LegacyClient_Backup'); + + // Snapshot before update + if (fs.existsSync(extractDir)) { + updateProgress(0, "Snapshotting Instance..."); + await createSnapshot(currentInstance); + } + updateProgress(5, "Downloading " + TARGET_FILE + "..."); await downloadFile(url, zipPath); updateProgress(75, "Extracting Archive..."); @@ -1108,6 +1225,7 @@ async function saveOptions() { const ip = document.getElementById('ip-input').value.trim(); const port = document.getElementById('port-input').value.trim(); const isServer = document.getElementById('server-checkbox').checked; + const customProtonPath = document.getElementById('custom-proton-path').value.trim(); const newInstallPath = document.getElementById('install-path-input').value.trim(); const oldInstallPath = currentInstance.installPath; if (newInstallPath && newInstallPath !== oldInstallPath) { @@ -1124,7 +1242,10 @@ async function saveOptions() { if (newRepo) currentInstance.repo = newRepo; if (newExec) currentInstance.execPath = newExec; currentInstance.ip = ip; currentInstance.port = port; currentInstance.isServer = isServer; - if (compatSelect) currentInstance.compatLayer = compatSelect.value; + if (compatSelect) { + currentInstance.compatLayer = compatSelect.value; + currentInstance.customCompatPath = customProtonPath; + } await saveInstancesToStore(); toggleOptions(false); fetchGitHubData(); updatePlayButtonText(); showToast("Settings Saved"); } @@ -1149,6 +1270,10 @@ function scanCompatibilityLayers() { const select = document.getElementById('compat-select'); if (!select) return; const savedValue = currentInstance.compatLayer; const layers = [{ name: 'Default (Direct)', cmd: 'direct' }, { name: 'Wine64', cmd: 'wine64' }, { name: 'Wine', cmd: 'wine' }]; + + // Add custom option + layers.push({ name: 'Custom (Linux)', cmd: 'custom' }); + const homeDir = require('os').homedir(); let steamPaths = []; if (process.platform === 'linux') steamPaths = [path.join(homeDir, '.steam', 'steam', 'steamapps', 'common'), path.join(homeDir, '.local', 'share', 'Steam', 'steamapps', 'common'), path.join(homeDir, '.var', 'app', 'com.valvesoftware.Steam', 'data', 'Steam', 'steamapps', 'common')]; else if (process.platform === 'darwin') steamPaths = [path.join(homeDir, 'Library', 'Application Support', 'Steam', 'steamapps', 'common')]; @@ -1158,11 +1283,18 @@ function scanCompatibilityLayers() { select.innerHTML = ''; layers.forEach(l => { const opt = document.createElement('option'); opt.value = l.cmd; opt.textContent = l.name; select.appendChild(opt); if (l.cmd === savedValue) opt.selected = true; }); updateCompatDisplay(); + + const customPathInput = document.getElementById('custom-proton-path'); + if (customPathInput) customPathInput.value = currentInstance.customCompatPath || ""; } function updateCompatDisplay() { const select = document.getElementById('compat-select'); const display = document.getElementById('current-compat-display'); - if (select && display && select.selectedIndex !== -1) display.textContent = select.options[select.selectedIndex].text; + const customGroup = document.getElementById('custom-proton-group'); + if (select && display && select.selectedIndex !== -1) { + display.textContent = select.options[select.selectedIndex].text; + if (customGroup) customGroup.style.display = select.value === 'custom' ? 'block' : 'none'; + } } function toggleSidebar() { @@ -1253,6 +1385,154 @@ async function loadSplashText() { } } +async function toggleSnapshots(show, id = null) { + const modal = document.getElementById('snapshots-modal'); + if (show) { + snapshotInstanceId = id || currentInstanceId; + const inst = instances.find(i => i.id === snapshotInstanceId); + document.getElementById('snapshot-instance-name').textContent = inst ? inst.name : ""; + await renderSnapshotsList(); + document.activeElement?.blur(); + modal.style.display = 'flex'; + modal.style.opacity = '1'; + } else { + modal.style.opacity = '0'; + setTimeout(() => modal.style.display = 'none', 300); + } +} + +async function renderSnapshotsList() { + const container = document.getElementById('snapshots-list-container'); + container.innerHTML = ''; + const inst = instances.find(i => i.id === snapshotInstanceId); + if (!inst || !inst.snapshots || inst.snapshots.length === 0) { + container.innerHTML = '
No snapshots found.
'; + return; + } + + inst.snapshots.sort((a,b) => b.timestamp - a.timestamp).forEach((snap) => { + const item = document.createElement('div'); + item.className = 'flex justify-between items-center p-3 border-b border-[#333] hover:bg-[#111]'; + const date = new Date(snap.timestamp).toLocaleString(); + item.innerHTML = ` +
+ ${snap.tag || 'Unknown Version'} + ${date} +
+
+
ROLLBACK
+
DELETE
+
+ `; + container.appendChild(item); + }); +} + +function openSnapshotsManager(id) { + toggleSnapshots(true, id); +} + +async function createSnapshotManual() { + const inst = instances.find(i => i.id === snapshotInstanceId); + if (!inst) return; + setProcessingState(true); + updateProgress(0, "Creating Snapshot..."); + try { + await createSnapshot(inst); + showToast("Snapshot Created!"); + await renderSnapshotsList(); + } catch (e) { + showToast("Failed to create snapshot: " + e.message); + } + setProcessingState(false); +} + +async function createSnapshot(inst) { + if (!fs.existsSync(inst.installPath)) return; + + const snapshotId = 'snap-' + Date.now(); + const snapshotsDir = path.join(path.dirname(inst.installPath), 'Snapshots', inst.id); + if (!fs.existsSync(snapshotsDir)) fs.mkdirSync(snapshotsDir, { recursive: true }); + + const dest = path.join(snapshotsDir, snapshotId); + + // Copy entire folder. fs.cpSync is available in modern Node/Electron + fs.cpSync(inst.installPath, dest, { recursive: true }); + + if (!inst.snapshots) inst.snapshots = []; + inst.snapshots.push({ + id: snapshotId, + timestamp: Date.now(), + tag: inst.installedTag || 'Manual Snapshot', + path: dest + }); + + await saveInstancesToStore(); +} + +async function rollbackToSnapshot(snapId) { + const inst = instances.find(i => i.id === snapshotInstanceId); + if (!inst) return; + const snap = inst.snapshots.find(s => s.id === snapId); + if (!snap) return; + + if (!confirm(`Are you sure you want to ROLLBACK ${inst.name} to the snapshot from ${new Date(snap.timestamp).toLocaleString()}? This will overwrite your current files.`)) return; + + setProcessingState(true); + updateProgress(10, "Preparing Rollback..."); + + try { + if (fs.existsSync(inst.installPath)) { + // Move current to temp just in case + const temp = inst.installPath + "_rollback_temp"; + if (fs.existsSync(temp)) fs.rmSync(temp, { recursive: true, force: true }); + fs.renameSync(inst.installPath, temp); + } + + updateProgress(50, "Restoring Files..."); + fs.cpSync(snap.path, inst.installPath, { recursive: true }); + + inst.installedTag = snap.tag; + await saveInstancesToStore(); + + // Cleanup temp + const temp = inst.installPath + "_rollback_temp"; + if (fs.existsSync(temp)) fs.rmSync(temp, { recursive: true, force: true }); + + showToast("Rollback Successful!"); + if (snapshotInstanceId === currentInstanceId) { + updatePlayButtonText(); + if (window.loadMainMenuSkin) window.loadMainMenuSkin(); + } + } catch (e) { + showToast("Rollback Failed: " + e.message); + console.error(e); + } + setProcessingState(false); +} + +async function deleteSnapshot(snapId) { + const inst = instances.find(i => i.id === snapshotInstanceId); + if (!inst) return; + const snapIndex = inst.snapshots.findIndex(s => s.id === snapId); + if (snapIndex === -1) return; + + if (!confirm("Delete this snapshot? (This will free up disk space)")) return; + + try { + const snap = inst.snapshots[snapIndex]; + if (fs.existsSync(snap.path)) { + fs.rmSync(snap.path, { recursive: true, force: true }); + } + inst.snapshots.splice(snapIndex, 1); + await saveInstancesToStore(); + renderSnapshotsList(); + showToast("Snapshot Deleted"); + } catch (e) { + showToast("Error deleting snapshot: " + e.message); + } +} + // Global functions for HTML onclick window.toggleSidebar = toggleSidebar; window.minimizeWindow = minimizeWindow; @@ -1280,3 +1560,37 @@ window.saveNewInstance = saveNewInstance; window.switchInstance = switchInstance; window.deleteInstance = deleteInstance; window.toggleAddInstance = toggleAddInstance; +window.openSnapshotsManager = openSnapshotsManager; +window.rollbackToSnapshot = rollbackToSnapshot; +window.deleteSnapshot = deleteSnapshot; +window.createSnapshotManual = createSnapshotManual; +window.toggleSnapshots = toggleSnapshots; +// Desktop shortcut for Linux AppImage +function ensureDesktopShortcut() { + if (typeof process === 'undefined' || process.platform !== 'linux') return; + try { + const os = require('os'); + const fs = require('fs'); + const path = require('path'); + const home = os.homedir(); + const desktopDir = path.join(home, '.local', 'share', 'applications'); + const desktopPath = path.join(desktopDir, 'LegacyLauncher.desktop'); + if (fs.existsSync(desktopPath)) return; + const appPath = process.env.APPIMAGE || process.argv[0]; + if (!appPath) return; + const content = `[Desktop Entry] +Type=Application +Name=LegacyLauncher +Comment=LegacyLauncher AppImage +Exec="${appPath}" %U +Icon=LegacyLauncher +Terminal=false +Categories=Game;Emulation;`; + fs.mkdirSync(desktopDir, { recursive: true }); + fs.writeFileSync(desktopPath, content); + } catch (e) { + console.error('Failed to create desktop shortcut:', e); + } +} +// Ensure shortcut exists on startup +ensureDesktopShortcut(); diff --git a/skin_manager.js b/skin_manager.js index 9b59d5f..97b1ddf 100644 --- a/skin_manager.js +++ b/skin_manager.js @@ -102,7 +102,10 @@ function initMainMenuSkinViewer() { async function loadMainMenuSkin() { try { + // Ensure install dir is available (currentInstance might not be ready) const installDir = await window.getInstallDir(); + if (!installDir) return; + const skinPath = path.join(installDir, 'Common', 'res', 'mob', 'char.png'); if (fs.existsSync(skinPath)) { @@ -120,7 +123,7 @@ async function loadMainMenuSkin() { console.log("No skin found at " + skinPath); } } catch (e) { - console.error("Failed to load main menu skin:", e); + console.warn("Could not load main menu skin (startup race condition?):", e); } }