From db66767dde7ac6f08c7f263c6a5e73f0e070202a Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sun, 15 Feb 2026 18:04:16 +0100 Subject: [PATCH] BREAK IT: the damn auth login (and fix neutralino downloads too) --- js/accounts/auth.js | 102 +++++++++++++ js/app.js | 48 +++++- js/desktop/neutralino-bridge.js | 57 ++++++++ js/downloads.js | 179 ++++++++++++++++++++++- public/auth_bridge.html | 249 ++++++++++++++++++++++++++++++++ public/neutralino_loader.html | 119 ++++++++++++++- 6 files changed, 749 insertions(+), 5 deletions(-) create mode 100644 public/auth_bridge.html 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 @@ + + + + + + + Monochrome Login + + + + +
+

Monochrome Login

+

Please sign in to continue.

+ +
+
Login successful! You can close this window.
+
+ + + + + \ No newline at end of file diff --git a/public/neutralino_loader.html b/public/neutralino_loader.html index 5bfe527..ae70469 100644 --- a/public/neutralino_loader.html +++ b/public/neutralino_loader.html @@ -92,11 +92,52 @@ // Static Dev Port const DEV_PORT = '5173'; + // NL_PORT and NL_TOKEN are populated by neutralino.js upon init (or pre-init) + // We should ensure they are present. let port = window.NL_PORT || sessionStorage.getItem('NL_PORT') || '5050'; + let token = window.NL_TOKEN || sessionStorage.getItem('NL_TOKEN') || ''; + + console.log('[Shell] Neutralino Globals - Port:', port, 'Token:', token ? '***' : 'Missing'); + const iframe = document.getElementById('app-frame'); const targetPort = isDev ? DEV_PORT : port; - const targetUrl = `http://localhost:${targetPort}/?mode=neutralino`; + let targetUrl = `http://localhost:${targetPort}/?mode=neutralino`; + + // Pass global args to app for bridge links + if (port) targetUrl += `&nl_port=${port}`; + if (token) targetUrl += `&nl_token=${token}`; + + try { + const envs = await Neutralino.os.getEnvs(); + console.log('[Shell] Envs:', envs); + + // Heuristic for Linux: + // If XDG_SESSION_TYPE or SHELL is present and generic OS isn't 'Windows_NT' + let detectedOS = null; + if (envs.OS) + detectedOS = envs.OS; // Windows usually sets 'OS' env param to 'Windows_NT' + else if (envs.XDG_SESSION_TYPE || envs.SHELL) detectedOS = 'Linux'; // Rough guess + + if (detectedOS) { + console.log('[Shell] Detected OS (Env):', detectedOS); + targetUrl += `&os=${encodeURIComponent(detectedOS)}`; + } else { + // Fallback: try computer.getOSInfo again with better error logging? + // Or just assume non-Linux if we can't tell. + try { + const osInfo = await Neutralino.computer.getOSInfo(); + if (osInfo && osInfo.name) { + console.log('[Shell] Detected OS (API):', osInfo.name); + targetUrl += `&os=${encodeURIComponent(osInfo.name)}`; + } + } catch (err) { + console.log('[Shell] OS Detection failed completley.'); + } + } + } catch (e) { + console.error('[Shell] Failed to get envs:', e); + } if (isDev) { console.log(`[Shell] Dev mode detected via NL_ARGS. Waiting 2s for Vite on port ${targetPort}...`); @@ -135,6 +176,12 @@ Neutralino.events.on('windowFocus', () => forwardEvent('windowFocus')); Neutralino.events.on('windowBlur', () => forwardEvent('windowBlur')); + // Forward the external auth success event to the app + Neutralino.events.on('externalAuthSuccess', (event) => { + console.log('[Shell] Received external auth success', event.detail); + forwardEvent('externalAuthSuccess', event.detail); + }); + // Handle commands from the Iframe (via Bridge) window.addEventListener('message', async (event) => { const { type, eventName, data, extensionId } = event.data; @@ -192,6 +239,76 @@ console.error('[Shell] Set title failed:', e); } break; + + case 'NL_OS_OPEN': + try { + console.log('[Shell] Opening external URL:', event.data.url); + await Neutralino.os.open(event.data.url); + } catch (e) { + console.error('[Shell] Failed to open URL:', e); + } + break; + + case 'NL_OS_SHOW_SAVE_DIALOG': + try { + const result = await Neutralino.os.showSaveDialog(event.data.title, event.data.options); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage( + { type: 'NL_RESPONSE', id: event.data.id, result }, + '*' + ); + } + } catch (e) { + console.error('[Shell] Show Save Dialog failed:', e); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage( + { type: 'NL_RESPONSE', id: event.data.id, error: e }, + '*' + ); + } + } + break; + + case 'NL_FS_WRITE_BINARY': + try { + // buffer comes as ArrayBuffer in event.data.buffer (if transferred) or event.data.buffer + await Neutralino.filesystem.writeBinaryFile(event.data.path, event.data.buffer); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage( + { type: 'NL_RESPONSE', id: event.data.id, result: 'success' }, + '*' + ); + } + } catch (e) { + console.error('[Shell] Write Binary File failed:', e); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage( + { type: 'NL_RESPONSE', id: event.data.id, error: e }, + '*' + ); + } + } + break; + + case 'NL_FS_APPEND_BINARY': + try { + await Neutralino.filesystem.appendBinaryFile(event.data.path, event.data.buffer); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage( + { type: 'NL_RESPONSE', id: event.data.id, result: 'success' }, + '*' + ); + } + } catch (e) { + console.error('[Shell] Append Binary File failed:', e); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage( + { type: 'NL_RESPONSE', id: event.data.id, error: e }, + '*' + ); + } + } + break; } });