BREAK IT: the damn auth login (and fix neutralino downloads too)
This commit is contained in:
parent
6fe2666f63
commit
db66767dde
6 changed files with 749 additions and 5 deletions
|
|
@ -22,6 +22,24 @@ export class AuthManager {
|
||||||
init() {
|
init() {
|
||||||
if (!auth) return;
|
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.unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.updateUI(user);
|
this.updateUI(user);
|
||||||
|
|
@ -50,6 +68,90 @@ export class AuthManager {
|
||||||
return;
|
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 {
|
try {
|
||||||
const result = await signInWithPopup(auth, provider);
|
const result = await signInWithPopup(auth, provider);
|
||||||
|
|
||||||
|
|
|
||||||
48
js/app.js
48
js/app.js
|
|
@ -316,13 +316,55 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initTracker(player);
|
initTracker(player);
|
||||||
|
|
||||||
// Initialize desktop features if in Neutralino mode
|
// 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' &&
|
typeof window !== 'undefined' &&
|
||||||
(window.NL_MODE ||
|
(window.NL_MODE ||
|
||||||
window.location.search.includes('mode=neutralino') ||
|
window.location.search.includes('mode=neutralino') ||
|
||||||
(window.Neutralino && typeof window.Neutralino === 'object'))
|
window.location.search.includes('nl_port='));
|
||||||
) {
|
|
||||||
|
if (isNeutralinoMode) {
|
||||||
window.NL_MODE = true;
|
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));
|
import('./desktop/desktop.js').then((m) => m.initDesktop(player));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export const _window = {
|
||||||
minimize: async () => {
|
minimize: async () => {
|
||||||
if (!isNeutralino) return;
|
if (!isNeutralino) return;
|
||||||
|
|
@ -98,5 +153,7 @@ export default {
|
||||||
events,
|
events,
|
||||||
extensions,
|
extensions,
|
||||||
app,
|
app,
|
||||||
|
os,
|
||||||
|
filesystem,
|
||||||
window: _window,
|
window: _window,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
179
js/downloads.js
179
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(
|
async function startBulkDownload(
|
||||||
tracks,
|
tracks,
|
||||||
defaultName,
|
defaultName,
|
||||||
|
|
@ -551,12 +714,26 @@ async function startBulkDownload(
|
||||||
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const isNeutralino = window.NL_MODE === true;
|
||||||
const hasFileSystemAccess =
|
const hasFileSystemAccess =
|
||||||
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||||
const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
||||||
const useZipBlob = !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
|
// File System Access API available - use streaming
|
||||||
try {
|
try {
|
||||||
const fileHandle = await window.showSaveFilePicker({
|
const fileHandle = await window.showSaveFilePicker({
|
||||||
|
|
|
||||||
249
public/auth_bridge.html
Normal file
249
public/auth_bridge.html
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Monochrome Login</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: #1e1e1e;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #aaaaaa;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff5252;
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #4caf50;
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 id="status-title">Monochrome Login</h1>
|
||||||
|
<p id="status-text">Please sign in to continue.</p>
|
||||||
|
<button id="login-btn" class="btn">Sign in with Google</button>
|
||||||
|
<div id="error-msg" class="error"></div>
|
||||||
|
<div id="success-msg" class="success">Login successful! You can close this window.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js';
|
||||||
|
import {
|
||||||
|
getAuth,
|
||||||
|
GoogleAuthProvider,
|
||||||
|
signInWithPopup,
|
||||||
|
signInWithRedirect,
|
||||||
|
getRedirectResult,
|
||||||
|
} from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js';
|
||||||
|
|
||||||
|
// Duplicate config from config.js - safer to keep standalone here to avoid import issues in external browser
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: 'AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA',
|
||||||
|
authDomain: 'monochrome-database.firebaseapp.com',
|
||||||
|
projectId: 'monochrome-database',
|
||||||
|
storageBucket: 'monochrome-database.firebasestorage.app',
|
||||||
|
messagingSenderId: '895657412760',
|
||||||
|
appId: '1:895657412760:web:e81c5044c7f4e9b799e8ed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = initializeApp(firebaseConfig);
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const provider = new GoogleAuthProvider();
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const NL_PORT = urlParams.get('port');
|
||||||
|
const NL_TOKEN = urlParams.get('token');
|
||||||
|
|
||||||
|
const btn = document.getElementById('login-btn');
|
||||||
|
const errorMsg = document.getElementById('error-msg');
|
||||||
|
const successMsg = document.getElementById('success-msg');
|
||||||
|
const statusTitle = document.getElementById('status-title');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
|
||||||
|
// Handle Redirect Result (if we came back from a redirect login)
|
||||||
|
getRedirectResult(auth)
|
||||||
|
.then((result) => {
|
||||||
|
if (result) {
|
||||||
|
console.log('Redirect Login Successful:', result.user.email);
|
||||||
|
sendAuthToApp(result);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Redirect Login Failed:', error);
|
||||||
|
showError(error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple Neutralino WebSocket Client (just enough to send an event)
|
||||||
|
function sendAuthToApp(result) {
|
||||||
|
if (!NL_PORT || !NL_TOKEN) {
|
||||||
|
showError('Missing connection parameters (port/token). Launch from app.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user and credential from the UserCredential result
|
||||||
|
const user = result.user;
|
||||||
|
const credential = GoogleAuthProvider.credentialFromResult(result);
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
showError('Failed to retrieve Google Auth Credentials.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutralino expects connectToken to be the second part of the NL_TOKEN
|
||||||
|
// NL_TOKEN format: access_token.connect_token
|
||||||
|
const parts = NL_TOKEN.split('.');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
showError('Invalid Token format.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const connectToken = parts[1];
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:${NL_PORT}?connectToken=${connectToken}`);
|
||||||
|
|
||||||
|
ws.onopen = async () => {
|
||||||
|
console.log('Connected to Monochrome App');
|
||||||
|
|
||||||
|
// Payload must follow Neutralino protocol:
|
||||||
|
// { id: uuid, method: "events.broadcast", data: { ... }, accessToken: NL_TOKEN }
|
||||||
|
|
||||||
|
const uuid = crypto.randomUUID ? crypto.randomUUID() : 'auth-' + Date.now();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: uuid,
|
||||||
|
method: 'events.broadcast',
|
||||||
|
accessToken: NL_TOKEN, // Full token required here
|
||||||
|
data: {
|
||||||
|
event: 'externalAuthSuccess',
|
||||||
|
data: {
|
||||||
|
uid: user.uid,
|
||||||
|
email: user.email,
|
||||||
|
// Pass Google OAuth tokens to create a credential in the main app
|
||||||
|
idToken: credential.idToken,
|
||||||
|
accessToken: credential.accessToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
|
||||||
|
showSuccess();
|
||||||
|
statusTitle.textContent = "You're signed in";
|
||||||
|
statusText.textContent = 'Return to Monochrome to see your library.';
|
||||||
|
btn.style.display = 'none';
|
||||||
|
|
||||||
|
// Close after a brief delay
|
||||||
|
setTimeout(() => ws.close(), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
|
||||||
|
showSuccess();
|
||||||
|
statusTitle.textContent = "You're signed in";
|
||||||
|
statusText.textContent = 'Return to Monochrome to see your library.';
|
||||||
|
btn.style.display = 'none';
|
||||||
|
|
||||||
|
// Close after a brief delay
|
||||||
|
setTimeout(() => ws.close(), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error('WebSocket Error:', e);
|
||||||
|
showError('Failed to connect to Monochrome App. Is it running?');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorMsg.textContent = msg;
|
||||||
|
errorMsg.style.display = 'block';
|
||||||
|
successMsg.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess() {
|
||||||
|
successMsg.style.display = 'block';
|
||||||
|
errorMsg.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const result = await signInWithPopup(auth, provider);
|
||||||
|
console.log('User signed in via popup:', result.user.email);
|
||||||
|
sendAuthToApp(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Popup Login failed:', error);
|
||||||
|
|
||||||
|
// Fallback to redirect for almost any error (popup blocked, closed, network error, internal error)
|
||||||
|
// unless it's a specific user-cancellation that we know is intentional?
|
||||||
|
// 'auth/cancelled-popup-request' usually means the user closed it or the browser killed it.
|
||||||
|
// 'auth/internal-error' often happens if scripts are blocked.
|
||||||
|
|
||||||
|
console.log('Falling back to Redirect Login...');
|
||||||
|
showError('Popup failed (' + error.code + '). Redirecting...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signInWithRedirect(auth, provider);
|
||||||
|
} catch (redirectError) {
|
||||||
|
console.error('Redirect Login failed:', redirectError);
|
||||||
|
showError('Login failed completely: ' + redirectError.message + ' (' + redirectError.code + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!NL_PORT || !NL_TOKEN) {
|
||||||
|
showError('Invalid launch parameters. Please open this page from within Monochrome.');
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -92,11 +92,52 @@
|
||||||
// Static Dev Port
|
// Static Dev Port
|
||||||
const DEV_PORT = '5173';
|
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 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 iframe = document.getElementById('app-frame');
|
||||||
|
|
||||||
const targetPort = isDev ? DEV_PORT : port;
|
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) {
|
if (isDev) {
|
||||||
console.log(`[Shell] Dev mode detected via NL_ARGS. Waiting 2s for Vite on port ${targetPort}...`);
|
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('windowFocus', () => forwardEvent('windowFocus'));
|
||||||
Neutralino.events.on('windowBlur', () => forwardEvent('windowBlur'));
|
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)
|
// Handle commands from the Iframe (via Bridge)
|
||||||
window.addEventListener('message', async (event) => {
|
window.addEventListener('message', async (event) => {
|
||||||
const { type, eventName, data, extensionId } = event.data;
|
const { type, eventName, data, extensionId } = event.data;
|
||||||
|
|
@ -192,6 +239,76 @@
|
||||||
console.error('[Shell] Set title failed:', e);
|
console.error('[Shell] Set title failed:', e);
|
||||||
}
|
}
|
||||||
break;
|
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue