const { app, BrowserWindow, ipcMain, globalShortcut, shell, dialog, protocol, session, net, } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); const { autoUpdater } = require('electron-updater'); // ── DRM cache scheme — must be registered before app.whenReady() ────────────── protocol.registerSchemesAsPrivileged([{ scheme: 'bmcache', privileges: { standard: true, secure: true, bypassCSP: true, corsEnabled: true, stream: true, supportFetchAPI: true, }, }]); // ── Persistent store ────────────────────────────────────────────────────────── let Store; async function getStore() { if (!Store) { const mod = await import('electron-store'); Store = mod.default; } return new Store({ name: 'burmalda' }); } // ── DRM cache helpers ───────────────────────────────────────────────────────── const CACHE_DIR = path.join(os.homedir(), '.burmalda-cache'); async function getCacheKey() { const store = await getStore(); let hex = store.get('cacheKey'); if (!hex) { hex = crypto.randomBytes(32).toString('hex'); store.set('cacheKey', hex); } return Buffer.from(hex, 'hex'); } function cacheFilePath(trackId) { fs.mkdirSync(CACHE_DIR, { recursive: true }); return path.join(CACHE_DIR, `${trackId}.bmc`); } async function readCached(trackId) { const fp = cacheFilePath(trackId); if (!fs.existsSync(fp)) return null; try { const enc = fs.readFileSync(fp); const key = await getCacheKey(); const iv = enc.slice(0, 16); const body = enc.slice(16); const dec = crypto.createDecipheriv('aes-256-cbc', key, iv); const plain = Buffer.concat([dec.update(body), dec.final()]); const mimeLen = plain.readUInt32BE(0); const mime = plain.slice(4, 4 + mimeLen).toString('utf8'); const audio = plain.slice(4 + mimeLen); return { audio, mime }; } catch { try { fs.unlinkSync(fp); } catch {} return null; } } const _downloading = new Set(); async function downloadAndCache(url, trackId) { if (_downloading.has(trackId)) return; const fp = cacheFilePath(trackId); if (fs.existsSync(fp)) return; _downloading.add(trackId); try { const resp = await net.fetch(url); if (!resp.ok) return; const mime = resp.headers.get('content-type') || 'audio/mpeg'; const audio = Buffer.from(await resp.arrayBuffer()); const key = await getCacheKey(); const iv = crypto.randomBytes(16); const mimeBuf = Buffer.from(mime, 'utf8'); const lenBuf = Buffer.allocUnsafe(4); lenBuf.writeUInt32BE(mimeBuf.length, 0); const plain = Buffer.concat([lenBuf, mimeBuf, audio]); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); const enc = Buffer.concat([iv, cipher.update(plain), cipher.final()]); fs.writeFileSync(fp, enc); } catch { // network errors silently ignored } finally { _downloading.delete(trackId); } } // Extract a stable track-id from CDN URL (filename without extension) function trackIdFromUrl(url) { return path.basename(new URL(url).pathname).replace(/\.[^.]+$/, ''); } // ── DRM cache interception ──────────────────────────────────────────────────── function setupDrmCache() { // 1. Serve decrypted audio via bmcache:// scheme protocol.handle('bmcache', async (request) => { const trackId = decodeURIComponent(new URL(request.url).pathname.replace('/audio/', '')); const cached = await readCached(trackId); if (cached) { return new Response(cached.audio, { status: 200, headers: { 'content-type': cached.mime, 'content-length': String(cached.audio.length), 'accept-ranges': 'bytes', 'access-control-allow-origin': '*', }, }); } return new Response('not cached', { status: 404 }); }); // 2. Intercept CDN audio-only requests → redirect to local cache when available // Cover images (.jpg) and other non-audio assets pass through untouched. const AUDIO_EXTS = new Set(['.mp3', '.m4a', '.ogg', '.wav', '.flac', '.aac', '.opus', '.webm', '.weba']); const CDN_PATTERNS = [ 'https://cdn.nodeshift.space/music/*', 'https://cdnl.nodeshift.space/music/*', ]; session.defaultSession.webRequest.onBeforeRequest({ urls: CDN_PATTERNS }, (details, cb) => { const url = details.url; const ext = path.extname(new URL(url).pathname).toLowerCase(); if (!AUDIO_EXTS.has(ext)) { cb({}); return; } const trackId = trackIdFromUrl(url); const fp = cacheFilePath(trackId); if (fs.existsSync(fp)) { cb({ redirectURL: `bmcache://audio/${encodeURIComponent(trackId)}` }); } else { downloadAndCache(url, trackId); // cache in background cb({}); // pass through for now } }); } // ── IPC: cache management (exposed to renderer via contextBridge) ───────────── ipcMain.handle('cache-size', async () => { try { const files = fs.readdirSync(CACHE_DIR).filter(f => f.endsWith('.bmc')); const bytes = files.reduce((s, f) => s + fs.statSync(path.join(CACHE_DIR, f)).size, 0); return { count: files.length, bytes }; } catch { return { count: 0, bytes: 0 }; } }); ipcMain.handle('cache-clear', async () => { try { const files = fs.readdirSync(CACHE_DIR).filter(f => f.endsWith('.bmc')); files.forEach(f => { try { fs.unlinkSync(path.join(CACHE_DIR, f)); } catch {} }); return files.length; } catch { return 0; } }); // ── Main window ─────────────────────────────────────────────────────────────── const MUSIC_URL = 'https://music.nodeshift.space'; let mainWindow = null; function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, minWidth: 900, minHeight: 600, frame: false, titleBarStyle: 'hidden', titleBarOverlay: { color: '#0a0a0a', symbolColor: '#ffffff', height: 38 }, backgroundColor: '#0a0a0a', show: false, icon: path.join(__dirname, 'src/assets/icon.png'), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, webSecurity: true, allowRunningInsecureContent: false, }, }); mainWindow.loadURL(MUSIC_URL); mainWindow.once('ready-to-show', () => { mainWindow.show(); mainWindow.focus(); }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); mainWindow.webContents.on('will-navigate', (_event, url) => { if (!url.includes('nodeshift.space') && !url.startsWith('https://accounts.google.com')) { _event.preventDefault(); shell.openExternal(url); } }); } // ── Auto-update ─────────────────────────────────────────────────────────────── function setupAutoUpdater() { autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on('update-downloaded', () => { dialog.showMessageBox(mainWindow, { type: 'info', title: 'Обновление готово', message: 'Новая версия Burmalda Music скачана. Перезапустить сейчас?', buttons: ['Перезапустить', 'Позже'], defaultId: 0, }).then(({ response }) => { if (response === 0) autoUpdater.quitAndInstall(); }); }); setTimeout(() => autoUpdater.checkForUpdates().catch(() => {}), 3000); setInterval(() => autoUpdater.checkForUpdates().catch(() => {}), 4 * 60 * 60 * 1000); } // ── App lifecycle ───────────────────────────────────────────────────────────── app.whenReady().then(() => { setupDrmCache(); createWindow(); setupMediaKeys(); setupAutoUpdater(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('will-quit', () => globalShortcut.unregisterAll()); // ── Window controls ─────────────────────────────────────────────────────────── ipcMain.on('win-minimize', () => mainWindow?.minimize()); ipcMain.on('win-maximize', () => { if (mainWindow?.isMaximized()) mainWindow.unmaximize(); else mainWindow?.maximize(); }); ipcMain.on('win-close', () => mainWindow?.close()); ipcMain.on('win-fullscreen', () => mainWindow?.setFullScreen(!mainWindow.isFullScreen())); // ── Persistent store IPC ────────────────────────────────────────────────────── ipcMain.handle('store-get', async (_, key) => { const s = await getStore(); return s.get(key); }); ipcMain.handle('store-set', async (_, key, value) => { const s = await getStore(); s.set(key, value); }); ipcMain.handle('store-delete', async (_, key) => { const s = await getStore(); s.delete(key); }); ipcMain.handle('get-version', () => app.getVersion()); // ── Media keys ──────────────────────────────────────────────────────────────── function execSafe(js) { mainWindow?.webContents.executeJavaScript(js).catch(() => {}); } function setupMediaKeys() { globalShortcut.register('MediaPlayPause', () => execSafe('typeof togglePlay==="function"&&togglePlay()')); globalShortcut.register('MediaNextTrack', () => execSafe('typeof next==="function"&&next()')); globalShortcut.register('MediaPreviousTrack', () => execSafe('typeof prev==="function"&&prev()')); }