254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
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()'));
|
|
}
|