diff --git a/js/accounts/auth.js b/js/accounts/auth.js index 4f22669..e00705e 100644 --- a/js/accounts/auth.js +++ b/js/accounts/auth.js @@ -22,6 +22,24 @@ export class AuthManager { init() { if (!auth) return; + // Persist Neutralino Env params across navigation via sessionStorage + window.MonochromeEnv = window.MonochromeEnv || {}; + const initUrlParams = new URLSearchParams(window.location.search); + + // Only update sessionStorage if params are present in URL (e.g. fresh launch) + if (initUrlParams.has('nl_port')) sessionStorage.setItem('NL_PORT', initUrlParams.get('nl_port')); + if (initUrlParams.has('nl_token')) sessionStorage.setItem('NL_TOKEN', initUrlParams.get('nl_token')); + if (initUrlParams.has('os')) sessionStorage.setItem('NL_OS', initUrlParams.get('os')); + if (initUrlParams.has('mode')) sessionStorage.setItem('NL_MODE', initUrlParams.get('mode')); + + // Populate window.MonochromeEnv from sessionStorage + window.MonochromeEnv.nl_port = sessionStorage.getItem('NL_PORT'); + window.MonochromeEnv.nl_token = sessionStorage.getItem('NL_TOKEN'); + window.MonochromeEnv.os = sessionStorage.getItem('NL_OS'); + + console.log('[Auth] Initializing. Current URL:', window.location.href); + console.log('[Auth] Persisted Env (SessionStorage):', window.MonochromeEnv); + this.unsubscribe = onAuthStateChanged(auth, (user) => { this.user = user; this.updateUI(user); @@ -50,6 +68,90 @@ export class AuthManager { return; } + console.log('[Auth] URL Debug:', window.location.href); + + // Check for Neutralino mode + // We trust NL_MODE or specific params. + const isNeutralino = + window.NL_MODE === true || + sessionStorage.getItem('NL_MODE') === 'neutralino' || + (window.Neutralino && typeof window.Neutralino === 'object'); + + // Check for OS/Port/Token from URL params OR persisted env + const urlParams = new URLSearchParams(window.location.search); + + // Populate from env if not in URL + const nlPort = urlParams.get('nl_port') || window.MonochromeEnv?.nl_port; + const nlToken = urlParams.get('nl_token') || window.MonochromeEnv?.nl_token; + + console.log('[Auth] Starting Google Sign-In. Mode:', isNeutralino ? 'Neutralino' : 'Web'); + + if (isNeutralino) { + // Neutralino (Desktop) Mode + // We use the External Auth Bridge for ALL desktop platforms (Windows/Linux/Mac) + // This avoids issues with internal webview restrictions (e.g. Google blocking embedded webviews) + + if (!nlPort || !nlToken) { + alert('Missing Neutralino connection parameters. Cannot launch external auth.'); + return; + } + + console.log('[Auth] Desktop detected. Launching external browser for authentication...'); + + // Construct the local URL for the bridge file + // Use window.location.origin to ensure we use the correct server (Vite in dev, Neutralino in prod) + const bridgeUrl = `${window.location.origin}/auth_bridge.html?port=${nlPort}&token=${nlToken}`; + + try { + await window.Neutralino.os.open(bridgeUrl); + + // Show a waiting UI + const connectBtn = document.getElementById('firebase-connect-btn'); + if (connectBtn) { + connectBtn.textContent = 'Waiting for browser...'; + connectBtn.disabled = true; + } + + // Setup one-time listener for the success event + const authHandler = async (detail) => { + // We received a raw ID token (Google ID Token) or similar? + // The bridge sends { uid, email, accessToken }. + // We need to create a credential from it. + + try { + const { GoogleAuthProvider, signInWithCredential } = + await import('https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js'); + // Create credential using Google's tokens passed from the bridge + const credential = GoogleAuthProvider.credential(detail.idToken, detail.accessToken); + + const result = await signInWithCredential(auth, credential); + console.log('[Auth] External Login successful'); + + this.user = result.user; + this.updateUI(result.user); + this.authListeners.forEach((listener) => listener(result.user)); + + // Cleanup + window.Neutralino.events.off('externalAuthSuccess', authHandler); + } catch (e) { + console.error('[Auth] Failed to sign in with external credential:', e); + alert('Failed to complete login from external browser.'); + if (connectBtn) { + connectBtn.textContent = 'Connect with Google'; + connectBtn.disabled = false; + } + } + }; + + window.Neutralino.events.on('externalAuthSuccess', authHandler); + } catch (e) { + console.error('[Auth] Failed to open external browser:', e); + alert('Failed to launch external browser.'); + } + return; + } + + // Web Mode try { const result = await signInWithPopup(auth, provider); diff --git a/js/app.js b/js/app.js index 9d2e52e..4e496e9 100644 --- a/js/app.js +++ b/js/app.js @@ -316,13 +316,55 @@ document.addEventListener('DOMContentLoaded', async () => { initTracker(player); // Initialize desktop features if in Neutralino mode - if ( + // We only assume Neutralino mode if explicitly flagged or if specific params are present + const isNeutralinoMode = typeof window !== 'undefined' && (window.NL_MODE || window.location.search.includes('mode=neutralino') || - (window.Neutralino && typeof window.Neutralino === 'object')) - ) { + window.location.search.includes('nl_port=')); + + if (isNeutralinoMode) { window.NL_MODE = true; + + // Function to restore env vars for Neutralino + const restoreNeutralinoEnv = () => { + if (window.MonochromeEnv) { + if (window.MonochromeEnv.nl_port) window.NL_PORT = window.MonochromeEnv.nl_port; + if (window.MonochromeEnv.nl_token) window.NL_TOKEN = window.MonochromeEnv.nl_token; + } else { + // Fallback direct read + const p = sessionStorage.getItem('NL_PORT'); + const t = sessionStorage.getItem('NL_TOKEN'); + if (p) window.NL_PORT = p; + if (t) window.NL_TOKEN = t; + } + // Polyfill NL_ARGS to prevent crash in neutralino.js (it checks for debug flags) + window.NL_ARGS = window.NL_ARGS || []; + console.log('[App] Restored Neutralino Env:', { port: window.NL_PORT, token: !!window.NL_TOKEN }); + }; + + // Ensure Neutralino global is available + if (typeof window.Neutralino === 'undefined') { + console.log('[App] Neutralino global not found. Injecting script...'); + try { + // Dynamically load neutralino.js from the server root + const script = document.createElement('script'); + script.src = '/neutralino.js'; + script.onload = () => { + console.log('[App] neutralino.js loaded.'); + restoreNeutralinoEnv(); // Restore BEFORE init + window.Neutralino.init(); + }; + document.body.appendChild(script); + } catch (e) { + console.error('[App] Failed to inject neutralino.js:', e); + } + } else { + // Already present + restoreNeutralinoEnv(); // Restore BEFORE init + window.Neutralino.init(); + } + import('./desktop/desktop.js').then((m) => m.initDesktop(player)); } diff --git a/js/desktop/neutralino-bridge.js b/js/desktop/neutralino-bridge.js index ac70ef3..c4ad4d4 100644 --- a/js/desktop/neutralino-bridge.js +++ b/js/desktop/neutralino-bridge.js @@ -65,6 +65,61 @@ export const app = { }, }; +export const os = { + open: async (url) => { + if (!isNeutralino) return; + window.parent.postMessage({ type: 'NL_OS_OPEN', url }, '*'); + }, + showSaveDialog: async (title, options) => { + if (!isNeutralino) return; + return new Promise((resolve) => { + const id = Math.random().toString(36).substring(7); + const handler = (event) => { + if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) { + window.removeEventListener('message', handler); + resolve(event.data.result); + } + }; + window.addEventListener('message', handler); + window.parent.postMessage({ type: 'NL_OS_SHOW_SAVE_DIALOG', id, title, options }, '*'); + }); + }, +}; + +export const filesystem = { + writeBinaryFile: async (path, buffer) => { + if (!isNeutralino) return; + return new Promise((resolve, reject) => { + const id = Math.random().toString(36).substring(7); + const handler = (event) => { + if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) { + window.removeEventListener('message', handler); + if (event.data.error) reject(event.data.error); + else resolve(event.data.result); + } + }; + window.addEventListener('message', handler); + window.parent.postMessage({ type: 'NL_FS_WRITE_BINARY', id, path, buffer }, '*', [buffer]); + }); + }, + appendBinaryFile: async (path, buffer) => { + if (!isNeutralino) return; + return new Promise((resolve, reject) => { + const id = Math.random().toString(36).substring(7); + const handler = (event) => { + if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) { + window.removeEventListener('message', handler); + if (event.data.error) reject(event.data.error); + else resolve(event.data.result); + } + }; + window.addEventListener('message', handler); + // Transfer buffer if possible to save memory + window.parent.postMessage({ type: 'NL_FS_APPEND_BINARY', id, path, buffer }, '*', [buffer]); + }); + }, +}; + export const _window = { minimize: async () => { if (!isNeutralino) return; @@ -98,5 +153,7 @@ export default { events, extensions, app, + os, + filesystem, window: _window, }; diff --git a/js/downloads.js b/js/downloads.js index 9122e99..df5f4cf 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -537,6 +537,169 @@ async function bulkDownloadToZipBlob( } } +async function bulkDownloadToZipNeutralino( + tracks, + folderName, + api, + quality, + lyricsManager, + notification, + coverBlob = null, + type = 'playlist', + metadata = null +) { + const { abortController } = bulkDownloadTasks.get(notification); + const signal = abortController.signal; + const { downloadZip } = await loadClientZip(); + + // Re-use logic for generating file entries + async function* yieldFiles() { + // Add cover if available + if (coverBlob) { + yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; + } + + // Generate playlist files first + const useRelativePaths = playlistSettings.shouldUseRelativePaths(); + + if (playlistSettings.shouldGenerateM3U()) { + const m3uContent = generateM3U(metadata || { title: folderName }, tracks, useRelativePaths); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, + lastModified: new Date(), + input: m3uContent, + }; + } + + if (playlistSettings.shouldGenerateM3U8()) { + const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, + lastModified: new Date(), + input: m3u8Content, + }; + } + + if (playlistSettings.shouldGenerateNFO()) { + const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, + lastModified: new Date(), + input: nfoContent, + }; + } + + if (playlistSettings.shouldGenerateJSON()) { + const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.json`, + lastModified: new Date(), + input: jsonContent, + }; + } + + // For albums, generate CUE file + if (type === 'album' && playlistSettings.shouldGenerateCUE()) { + const audioFilename = `${sanitizeForFilename(folderName)}.flac`; // Assume FLAC for CUE + const cueContent = generateCUE(metadata, tracks, audioFilename); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.cue`, + lastModified: new Date(), + input: cueContent, + }; + } + + // Download tracks + for (let i = 0; i < tracks.length; i++) { + if (signal.aborted) break; + const track = tracks[i]; + const trackTitle = getTrackTitle(track); + + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); + + try { + const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal); + const filename = buildTrackFilename(track, quality, extension); + yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob }; + + if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { + try { + const lyricsData = await lyricsManager.fetchLyrics(track.id, track); + if (lyricsData) { + const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); + if (lrcContent) { + const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); + yield { + name: `${folderName}/${lrcFilename}`, + lastModified: new Date(), + input: lrcContent, + }; + } + } + } catch { + /* ignore */ + } + } + } catch (err) { + if (err.name === 'AbortError') throw err; + console.error(`Failed to download track ${trackTitle}:`, err); + } + } + } + + try { + // Load the bridge explicitly to ensure we go through the parent shell + const bridge = await import('./desktop/neutralino-bridge.js'); + + // Native Save Dialog via Bridge + const savePath = await bridge.os.showSaveDialog(`Select save location for ${folderName}.zip`, { + defaultPath: `${folderName}.zip`, + filters: [{ name: 'ZIP Archive', extensions: ['zip'] }], + }); + + if (!savePath) { + // Cancelled + removeBulkDownloadTask(notification); + return; + } + + const response = downloadZip(yieldFiles()); + + // Initialize file (empty) to ensure it exists + // We use writeBinaryFile with an empty buffer to create/overwrite + await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0)); + + // Stream the response body + if (!response.body) throw new Error('ZIP response body is null'); + + const reader = response.body.getReader(); + let receivedLength = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // 'value' is a Uint8Array. Neutralino filesystem expects ArrayBuffer. + // value.buffer might contain the whole backing store, so we should be careful to slice if offset is non-zero + // but usually read() returns fresh chunks. + // However, neutralino bridge's appendBinaryFile takes ArrayBuffer. + const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); + + await bridge.filesystem.appendBinaryFile(savePath, chunk); + receivedLength += value.length; + + // Optional: Update granular progress if we want, but we typically update per-track in yieldFiles + } + + console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`); + + completeBulkDownload(notification, true); + } catch (error) { + if (error.name === 'AbortError') return; + throw error; + } +} + async function startBulkDownload( tracks, defaultName, @@ -551,12 +714,26 @@ async function startBulkDownload( const notification = createBulkDownloadNotification(type, name, tracks.length); try { + const isNeutralino = window.NL_MODE === true; const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype; const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); - if (useZip) { + if (isNeutralino) { + // Neutralino Native Logic + await bulkDownloadToZipNeutralino( + tracks, + defaultName, + api, + quality, + lyricsManager, + notification, + coverBlob, + type, + metadata + ); + } else if (useZip) { // File System Access API available - use streaming try { const fileHandle = await window.showSaveFilePicker({ diff --git a/public/auth_bridge.html b/public/auth_bridge.html new file mode 100644 index 0000000..308af12 --- /dev/null +++ b/public/auth_bridge.html @@ -0,0 +1,249 @@ + + + +
+ + +Please sign in to continue.
+ + +