const fs = require('fs'); const path = require('path'); const os = require('os'); const zlib = require('zlib'); const logger = require('../logger'); const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB per file /** * Get the HytaleSaves directory (game client logs) */ function getHytaleSavesDir() { const home = os.homedir(); if (process.platform === 'win32') { return path.join(home, 'AppData', 'Local', 'HytaleSaves'); } else if (process.platform === 'darwin') { return path.join(home, 'Library', 'Application Support', 'HytaleSaves'); } else { return path.join(home, '.hytalesaves'); } } /** * Read a log file, capping at MAX_FILE_SIZE (keeps tail/most recent lines) */ function readLogFile(filePath) { try { const stats = fs.statSync(filePath); if (stats.size <= MAX_FILE_SIZE) { return fs.readFileSync(filePath, 'utf8'); } // Read only the last MAX_FILE_SIZE bytes const fd = fs.openSync(filePath, 'r'); const buffer = Buffer.alloc(MAX_FILE_SIZE); fs.readSync(fd, buffer, 0, MAX_FILE_SIZE, stats.size - MAX_FILE_SIZE); fs.closeSync(fd); const content = buffer.toString('utf8'); // Skip first partial line const firstNewline = content.indexOf('\n'); const trimmed = firstNewline >= 0 ? content.substring(firstNewline + 1) : content; return `[... truncated ${stats.size - MAX_FILE_SIZE} bytes ...]\n` + trimmed; } catch (err) { return `[Error reading file: ${err.message}]`; } } /** * Get files matching a date pattern from a directory */ function getFilesForDate(dir, dateStr, pattern) { if (!fs.existsSync(dir)) return []; try { return fs.readdirSync(dir) .filter(f => f.includes(dateStr) && (pattern ? pattern.test(f) : true)) .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime })) .sort((a, b) => b.mtime - a.mtime); } catch (err) { return []; } } // ============================================================================ // ZIP BUILDER (pure Node.js, no dependencies) // ============================================================================ /** * CRC32 lookup table */ const crc32Table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let j = 0; j < 8; j++) { c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); } crc32Table[i] = c; } function crc32(buf) { let crc = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) { crc = crc32Table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8); } return (crc ^ 0xFFFFFFFF) >>> 0; } /** * Create a ZIP file from an array of {name, content} entries * Uses DEFLATE compression via built-in zlib */ function createZipBuffer(files) { const localHeaders = []; const centralEntries = []; let offset = 0; for (const file of files) { const nameBytes = Buffer.from(file.name, 'utf8'); const contentBytes = Buffer.from(file.content, 'utf8'); const compressed = zlib.deflateRawSync(contentBytes); const crcVal = crc32(contentBytes); // Local file header (30 bytes + filename) const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); // signature local.writeUInt16LE(20, 4); // version needed local.writeUInt16LE(0, 6); // flags local.writeUInt16LE(8, 8); // compression: DEFLATE local.writeUInt16LE(0, 10); // mod time local.writeUInt16LE(0, 12); // mod date local.writeUInt32LE(crcVal, 14); // crc32 local.writeUInt32LE(compressed.length, 18); // compressed size local.writeUInt32LE(contentBytes.length, 22); // uncompressed size local.writeUInt16LE(nameBytes.length, 26); // filename length local.writeUInt16LE(0, 28); // extra field length localHeaders.push(local, nameBytes, compressed); // Central directory entry (46 bytes + filename) const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); // signature central.writeUInt16LE(20, 4); // version made by central.writeUInt16LE(20, 6); // version needed central.writeUInt16LE(0, 8); // flags central.writeUInt16LE(8, 10); // compression central.writeUInt16LE(0, 12); // mod time central.writeUInt16LE(0, 14); // mod date central.writeUInt32LE(crcVal, 16); // crc32 central.writeUInt32LE(compressed.length, 20); // compressed size central.writeUInt32LE(contentBytes.length, 24); // uncompressed size central.writeUInt16LE(nameBytes.length, 28); // filename length central.writeUInt16LE(0, 30); // extra field length central.writeUInt16LE(0, 32); // comment length central.writeUInt16LE(0, 34); // disk number central.writeUInt16LE(0, 36); // internal attrs central.writeUInt32LE(0, 38); // external attrs central.writeUInt32LE(offset, 42); // local header offset centralEntries.push(central, nameBytes); offset += 30 + nameBytes.length + compressed.length; } const centralDirBuf = Buffer.concat(centralEntries); // End of central directory (22 bytes) const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); // signature eocd.writeUInt16LE(0, 4); // disk number eocd.writeUInt16LE(0, 6); // cd disk number eocd.writeUInt16LE(files.length, 8); // entries on disk eocd.writeUInt16LE(files.length, 10); // total entries eocd.writeUInt32LE(centralDirBuf.length, 12); // cd size eocd.writeUInt32LE(offset, 16); // cd offset eocd.writeUInt16LE(0, 20); // comment length return Buffer.concat([...localHeaders, centralDirBuf, eocd]); } // ============================================================================ /** * Collect all relevant logs for submission * Returns { files: [{name, content}], meta: {username, platform, version} } */ function collectLogs() { const files = []; const today = new Date(); const todayStr = today.toISOString().split('T')[0]; // YYYY-MM-DD const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const yesterdayStr = yesterday.toISOString().split('T')[0]; // 1. Launcher logs const launcherLogDir = logger.getLogDirectory(); if (launcherLogDir && fs.existsSync(launcherLogDir)) { // Today's launcher logs const todayLogs = getFilesForDate(launcherLogDir, todayStr, /^launcher-.*\.log$/); for (const f of todayLogs) { files.push({ name: `launcher/${f.name}`, content: readLogFile(f.path) }); } // Most recent from yesterday (just one) const yesterdayLogs = getFilesForDate(launcherLogDir, yesterdayStr, /^launcher-.*\.log$/); if (yesterdayLogs.length > 0) { files.push({ name: `launcher/${yesterdayLogs[0].name}`, content: readLogFile(yesterdayLogs[0].path) }); } } // 2. Game client logs const savesDir = getHytaleSavesDir(); const clientLogDir = path.join(savesDir, 'Logs'); if (fs.existsSync(clientLogDir)) { const clientLogs = getFilesForDate(clientLogDir, todayStr, /client\.log$/); for (const f of clientLogs) { files.push({ name: `client/${f.name}`, content: readLogFile(f.path) }); } } // 3. Config snapshot const appDir = logger.getAppDir ? logger.getAppDir() : logger.getInstallPath(); const configPath = path.join(appDir, 'config.json'); if (fs.existsSync(configPath)) { try { const configContent = fs.readFileSync(configPath, 'utf8'); files.push({ name: 'config.json', content: configContent }); } catch (err) { files.push({ name: 'config.json', content: `[Error reading config: ${err.message}]` }); } } // Build metadata const { app } = require('electron'); let username = 'unknown'; try { const configFile = path.join(appDir, 'config.json'); if (fs.existsSync(configFile)) { const cfg = JSON.parse(fs.readFileSync(configFile, 'utf8')); username = cfg.username || cfg.playerName || 'unknown'; } } catch (err) {} return { files, meta: { username, platform: `${process.platform}-${process.arch}`, version: app.getVersion() } }; } module.exports = { collectLogs, createZipBuffer };