diff --git a/README.md b/README.md index a70c8d2..c54cc9a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Firestudio is a **free and open-source desktop application** for managing your c - 👥 **Authentication**: View, create, and manage Firebase Auth users - 🔍 **Querying**: Use simple filters or JavaScript for advanced queries - 📥 **Import/Export**: Bulk operations with JSON files +- 🔧 **Local Emulator Support**: Connect to Firebase Local Emulator Suite - 🎨 **Themes**: Dark/Light mode with customizable settings Perfect for **Firebase developers**, **backend engineers**, **database administrators**, and anyone working with **Google Firebase**. @@ -52,6 +53,7 @@ Perfect for **Firebase developers**, **backend engineers**, **database administr - **Service Account**: Connect using Firebase service account JSON files for full admin access - **Google Sign-In**: OAuth-based authentication using your Google account +- **Local Emulator**: Connect directly to Firebase Local Emulator Suite for local development ### 📊 Firestore Database Management @@ -182,6 +184,12 @@ pnpm run dev See detailed OAuth setup guide in the [Google Sign-In Setup](#google-sign-in-setup) section. +#### Method 3: Local Emulator + +1. Start your Firebase Emulators (`firebase emulators:start`) +2. In Firestudio, click **"Add Project"** → **"Local Emulator"** tab +3. Select a detected emulator from the list and click **"Connect"** + --- ## 🛠️ Tech Stack diff --git a/electron/controllers/authController.js b/electron/controllers/authController.js index 467e278..b9e5ab0 100644 --- a/electron/controllers/authController.js +++ b/electron/controllers/authController.js @@ -1,21 +1,82 @@ /** * Auth Controller - * Handles Firebase Authentication user management via IPC (Admin SDK only) + * Handles Firebase Authentication user management via IPC (Admin SDK / Emulator REST) * Note: Google OAuth auth operations are handled by googleController.js */ const { ipcMain } = require('electron'); +const fetch = require('node-fetch'); let adminRef = null; +let authEmulatorHost = null; function setAdminRef(admin) { adminRef = admin; } +function setAuthEmulatorHost(host) { + authEmulatorHost = host; +} + +function getProjectId() { + if (adminRef?.apps?.length > 0) { + return adminRef.app().options.projectId; + } + return null; +} + +async function getAuthEmulatorBase() { + if (authEmulatorHost) return `http://${authEmulatorHost}/identitytoolkit.googleapis.com/v1`; + return null; +} + +function normalizeUserRecord(user) { + return { + uid: user.localId || user.uid, + email: user.email || null, + emailVerified: user.emailVerified || false, + displayName: user.displayName || null, + photoURL: user.photoUrl || null, + phoneNumber: user.phoneNumber || null, + disabled: user.disabled || false, + metadata: user.createdAt + ? { + creationTime: new Date(Number(user.createdAt)).toISOString(), + lastSignInTime: user.lastLoginAt ? new Date(Number(user.lastLoginAt)).toISOString() : null, + } + : { creationTime: null, lastSignInTime: null }, + providerData: (user.providerUserInfo || []).map((p) => ({ + providerId: p.providerId, + uid: p.rawId || p.federatedId, + email: p.email || null, + displayName: p.displayName || null, + photoURL: p.photoUrl || null, + })), + }; +} + function registerHandlers() { - // List users (Admin SDK) + // List users ipcMain.handle('auth:listUsers', async (event, { maxResults = 1000 } = {}) => { try { + const baseUrl = await getAuthEmulatorBase(); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected to Firebase'); + + if (baseUrl) { + const response = await fetch(`${baseUrl}/projects/${projectId}/accounts:query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner' }, + body: JSON.stringify({ returnUserInfo: true, maxResults }), + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'List users failed' }; + } + const users = (data.userInfo || []).map(normalizeUserRecord); + return { success: true, users }; + } + if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); const listUsersResult = await adminRef.auth().listUsers(maxResults); const users = listUsersResult.users.map((user) => ({ @@ -42,11 +103,37 @@ function registerHandlers() { } }); - // Create user (Admin SDK) + // Create user ipcMain.handle( 'auth:createUser', async (event, { email, password, displayName, phoneNumber, uid, photoURL, disabled, emailVerified }) => { try { + const baseUrl = await getAuthEmulatorBase(); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected to Firebase'); + + if (baseUrl) { + const body = { email, password }; + if (displayName) body.displayName = displayName; + if (phoneNumber) body.phoneNumber = phoneNumber; + if (uid) body.localId = uid; + if (disabled !== undefined) body.disabled = disabled; + if (emailVerified !== undefined) body.emailVerified = emailVerified; + const response = await fetch(`${baseUrl}/projects/${projectId}/accounts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner' }, + body: JSON.stringify(body), + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'Create user failed' }; + } + return { + success: true, + user: { uid: data.localId, email: data.email, displayName: data.displayName || null }, + }; + } + if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); const userData = { email, password }; if (displayName) userData.displayName = displayName; @@ -66,11 +153,38 @@ function registerHandlers() { }, ); - // Update user (Admin SDK) + // Update user ipcMain.handle( 'auth:updateUser', async (event, { uid, email, password, displayName, phoneNumber, disabled, photoURL, emailVerified }) => { try { + const baseUrl = await getAuthEmulatorBase(); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected to Firebase'); + + if (baseUrl) { + const body = { localId: uid }; + if (email !== undefined) body.email = email; + if (password !== undefined) body.password = password; + if (displayName !== undefined) body.displayName = displayName; + if (phoneNumber !== undefined) body.phoneNumber = phoneNumber; + if (disabled !== undefined) body.disableUser = disabled; + const response = await fetch(`${baseUrl}/projects/${projectId}/accounts:update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner' }, + body: JSON.stringify(body), + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'Update user failed' }; + } + const updated = data.localId ? normalizeUserRecord(data) : null; + return { + success: true, + user: updated || { uid, email, displayName, disabled }, + }; + } + if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); const updateData = {}; if (email !== undefined) updateData.email = email; @@ -96,9 +210,26 @@ function registerHandlers() { }, ); - // Delete user (Admin SDK) + // Delete user ipcMain.handle('auth:deleteUser', async (event, { uid }) => { try { + const baseUrl = await getAuthEmulatorBase(); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected to Firebase'); + + if (baseUrl) { + const response = await fetch(`${baseUrl}/projects/${projectId}/accounts:delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner' }, + body: JSON.stringify({ localId: uid }), + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'Delete user failed' }; + } + return { success: true }; + } + if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); await adminRef.auth().deleteUser(uid); return { success: true }; @@ -107,9 +238,29 @@ function registerHandlers() { } }); - // Get user (Admin SDK) + // Get user ipcMain.handle('auth:getUser', async (event, { uid }) => { try { + const baseUrl = await getAuthEmulatorBase(); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected to Firebase'); + + if (baseUrl) { + const response = await fetch(`${baseUrl}/projects/${projectId}/accounts:lookup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner' }, + body: JSON.stringify({ localId: [uid] }), + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'User not found' }; + } + if (!data.users || data.users.length === 0) { + return { success: false, error: 'User not found' }; + } + return { success: true, user: normalizeUserRecord(data.users[0]) }; + } + if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); const userRecord = await adminRef.auth().getUser(uid); return { @@ -135,4 +286,4 @@ function registerHandlers() { }); } -module.exports = { registerHandlers, setAdminRef }; +module.exports = { registerHandlers, setAdminRef, setAuthEmulatorHost }; diff --git a/electron/controllers/emulatorController.js b/electron/controllers/emulatorController.js new file mode 100644 index 0000000..ed8be3d --- /dev/null +++ b/electron/controllers/emulatorController.js @@ -0,0 +1,142 @@ +/** + * Emulator Controller + * Handles scanning for local Firebase Emulators + */ + +const { ipcMain } = require('electron'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const fetch = require('node-fetch'); + +/** + * Reads a JSON file safely + */ +function readJsonSafely(filePath) { + try { + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } + } catch { + // Silently ignore unreadable or unparseable files + } + return null; +} + +/** + * Scans the OS temp directory for running emulator hub files. + * The hub locator file (hub-.json) only contains version, origins, and pid. + * To get the running emulator services, we must fetch GET /emulators from the hub. + */ +async function scanHubFiles() { + const tmpDir = os.tmpdir(); + const runningEmulators = []; + + try { + const files = fs.readdirSync(tmpDir); + const hubFiles = files.filter((f) => f.startsWith('hub-') && f.endsWith('.json')); + + for (const file of hubFiles) { + // Extract projectId from filename: hub-.json + const projectId = file.slice(4, -5); + if (!projectId) continue; + + const hubData = readJsonSafely(path.join(tmpDir, file)); + if (!hubData || !hubData.origins || !hubData.origins.length) continue; + + // Query the hub's /emulators endpoint to get running services + try { + const hubUrl = hubData.origins[0].replace(/\/$/, ''); + const response = await fetch(`${hubUrl}/emulators`, { + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) continue; + + const emulatorsMap = await response.json(); + const firestore = emulatorsMap && emulatorsMap.firestore; + if (firestore) { + // Collect all available emulator services + const services = {}; + for (const [name, info] of Object.entries(emulatorsMap)) { + if (info && typeof info === 'object' && info.host && info.port) { + services[name] = { + host: info.host || 'localhost', + port: info.port, + }; + } + } + + runningEmulators.push({ + projectId, + host: firestore.host || 'localhost', + port: firestore.port, + services, + }); + } + } catch { + // Hub not reachable, skip this one + } + } + } catch (err) { + console.error('Failed to scan for hub files:', err); + } + + return runningEmulators; +} + +/** + * Scans the Firebase CLI configstore to map project IDs to local paths + */ +function scanConfigstore() { + const isMac = process.platform === 'darwin'; + const isWin = process.platform === 'win32'; + + let configstorePath; + if (isMac) { + configstorePath = path.join(os.homedir(), 'Library', 'Preferences', 'configstore', 'firebase-tools.json'); + } else if (isWin) { + // Windows path typically %LOCALAPPDATA%\configstore\firebase-tools.json + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + configstorePath = path.join(localAppData, 'configstore', 'firebase-tools.json'); + } else { + // Linux path + configstorePath = path.join(os.homedir(), '.config', 'configstore', 'firebase-tools.json'); + } + + const configData = readJsonSafely(configstorePath); + if (!configData || !configData.activeProjects) { + return {}; + } + + return configData.activeProjects; +} + +/** + * Registers all Emulator IPC handlers + */ +function registerHandlers() { + // Scans for running emulators via hub files + ipcMain.handle('emulators:scanHub', async () => { + try { + const emulators = await scanHubFiles(); + return { success: true, emulators }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + + // Scans configstore for local project paths + ipcMain.handle('emulators:scanConfig', async () => { + try { + const activeProjects = scanConfigstore(); + return { success: true, activeProjects }; + } catch (err) { + return { success: false, error: err.message }; + } + }); +} + +module.exports = { + registerHandlers, +}; diff --git a/electron/controllers/firebaseController.js b/electron/controllers/firebaseController.js index 84de18f..e16d732 100644 --- a/electron/controllers/firebaseController.js +++ b/electron/controllers/firebaseController.js @@ -9,6 +9,8 @@ const fs = require('fs'); let admin = null; let db = null; let onConnectionChange = null; +let currentAuthEmulatorHost = null; +let currentStorageEmulatorHost = null; function getAdmin() { return admin; @@ -17,6 +19,10 @@ function getDb() { return db; } +function getStorageEmulatorHost() { + return currentStorageEmulatorHost; +} + /** * Sets callback to notify when connection changes */ @@ -34,6 +40,25 @@ function registerHandlers() { // Support both object params and legacy string path const serviceAccountPath = typeof params === 'string' ? params : params.serviceAccountPath; const databaseId = typeof params === 'string' ? undefined : params.databaseId; + const emulatorHost = typeof params === 'string' ? undefined : params.emulatorHost; + const explicitProjectId = typeof params === 'string' ? undefined : params.projectId; + const authEmulatorHost = typeof params === 'string' ? undefined : params.authEmulatorHost; + const storageEmulatorHost = typeof params === 'string' ? undefined : params.storageEmulatorHost; + + if (emulatorHost) { + process.env.FIRESTORE_EMULATOR_HOST = emulatorHost; + } else { + delete process.env.FIRESTORE_EMULATOR_HOST; + } + + if (authEmulatorHost) { + process.env.FIREBASE_AUTH_EMULATOR_HOST = authEmulatorHost; + } else { + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + } + + currentAuthEmulatorHost = authEmulatorHost || null; + currentStorageEmulatorHost = storageEmulatorHost || null; const adminSdk = require('firebase-admin'); @@ -49,11 +74,21 @@ function registerHandlers() { } } - const serviceAccount = JSON.parse(fs.readFileSync(serviceAccountPath, 'utf8')); - - adminSdk.initializeApp({ - credential: adminSdk.credential.cert(serviceAccount), - }); + let projectId = explicitProjectId; + + if (serviceAccountPath) { + const serviceAccount = JSON.parse(fs.readFileSync(serviceAccountPath, 'utf8')); + projectId = serviceAccount.project_id; + adminSdk.initializeApp({ + credential: adminSdk.credential.cert(serviceAccount), + projectId, + }); + } else if (emulatorHost && explicitProjectId) { + // Emulator connection without service account + adminSdk.initializeApp({ projectId: explicitProjectId }); + } else { + throw new Error('Must provide either serviceAccountPath or emulatorHost with projectId'); + } admin = adminSdk; db = adminSdk.firestore(); @@ -64,13 +99,15 @@ function registerHandlers() { // Notify other controllers about the connection change if (onConnectionChange) { - onConnectionChange(admin, db); + onConnectionChange(admin, db, currentAuthEmulatorHost, currentStorageEmulatorHost); } - return { success: true, projectId: serviceAccount.project_id, databaseId }; + return { success: true, projectId, databaseId }; } catch (error) { admin = null; db = null; + currentAuthEmulatorHost = null; + currentStorageEmulatorHost = null; try { const adminSdk = require('firebase-admin'); const leftover = [...adminSdk.apps]; @@ -85,7 +122,7 @@ function registerHandlers() { void e2; } if (onConnectionChange) { - onConnectionChange(null, null); + onConnectionChange(null, null, null, null); } return { success: false, error: error.message }; } @@ -105,9 +142,11 @@ function registerHandlers() { } admin = null; db = null; + currentAuthEmulatorHost = null; + currentStorageEmulatorHost = null; if (onConnectionChange) { - onConnectionChange(null, null); + onConnectionChange(null, null, null, null); } return { success: true }; } catch (error) { @@ -129,5 +168,6 @@ module.exports = { registerHandlers, getAdmin, getDb, + getStorageEmulatorHost, setConnectionChangeCallback, }; diff --git a/electron/controllers/index.js b/electron/controllers/index.js index d051caa..130270d 100644 --- a/electron/controllers/index.js +++ b/electron/controllers/index.js @@ -8,6 +8,7 @@ const firestoreController = require('./firestoreController'); const googleController = require('./googleController'); const storageController = require('./storageController'); const authController = require('./authController'); +const emulatorController = require('./emulatorController'); module.exports = { firebaseController, @@ -15,16 +16,19 @@ module.exports = { googleController, storageController, authController, + emulatorController, /** * Registers all IPC handlers from all controllers */ registerAllHandlers() { // Set up connection change callback to update references in other controllers - firebaseController.setConnectionChangeCallback((admin, db) => { + firebaseController.setConnectionChangeCallback((admin, db, authEmulatorHost, storageEmulatorHost) => { firestoreController.setRefs(admin, db); storageController.setAdminRef(admin); + storageController.setStorageEmulatorHost(storageEmulatorHost); authController.setAdminRef(admin); + authController.setAuthEmulatorHost(authEmulatorHost); }); // Register all handlers @@ -33,5 +37,6 @@ module.exports = { googleController.registerHandlers(); storageController.registerHandlers(); authController.registerHandlers(); + emulatorController.registerHandlers(); }, }; diff --git a/electron/controllers/storageController.js b/electron/controllers/storageController.js index 8579655..297f816 100644 --- a/electron/controllers/storageController.js +++ b/electron/controllers/storageController.js @@ -10,6 +10,7 @@ const fetch = require('node-fetch'); const googleController = require('./googleController'); let adminRef = null; +let storageEmulatorHost = null; // Cache for resolved bucket names per project const bucketNameCache = {}; @@ -18,6 +19,58 @@ function setAdminRef(admin) { adminRef = admin; } +function setStorageEmulatorHost(host) { + storageEmulatorHost = host; +} + +function getEmulatorBaseUrl() { + if (!storageEmulatorHost) return null; + return `http://${storageEmulatorHost}/v0`; +} + +/** + * Detect the correct bucket name for a project on the storage emulator. + * Tries both naming conventions and caches the result. + */ +async function getEmulatorBucketName(projectId) { + if (!storageEmulatorHost) return null; + if (bucketNameCache[projectId]) return bucketNameCache[projectId]; + + const baseUrl = getEmulatorBaseUrl(); + const patterns = [`${projectId}.firebasestorage.app`, `${projectId}.appspot.com`]; + + for (const bucketName of patterns) { + try { + const url = `${baseUrl}/b/${bucketName}/o?maxResults=1`; + const response = await fetch(url, { headers: { Authorization: 'Bearer owner' } }); + if (response.ok) { + bucketNameCache[projectId] = bucketName; + return bucketName; + } + } catch (e) { + void e; + } + } + + // Default to newer convention if no bucket exists yet + bucketNameCache[projectId] = patterns[0]; + return patterns[0]; +} + +/** + * Normalize a Firebase Storage REST API response item to the frontend's StorageFile shape. + */ +function normalizeStorageItem(item, prefix) { + return { + name: item.name ? item.name.replace(prefix || '', '').replace(/\/$/, '') : '', + path: item.name || '', + type: item.name && item.name.endsWith('/') ? 'folder' : 'file', + size: parseInt(item.size || 0, 10), + contentType: item.contentType || 'application/octet-stream', + updated: item.updated || item.timeCreated || null, + }; +} + /** * Get the default storage bucket name for a Firebase project. * Firebase can use different bucket naming conventions: @@ -66,14 +119,38 @@ function getProjectId() { } function registerHandlers() { - // List files (Admin SDK) + // List files (Admin SDK / Emulator REST) ipcMain.handle('storage:listFiles', async (event, { path: storagePath = '' }) => { try { - if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); const projectId = getProjectId(); - const bucket = adminRef.storage().bucket(`${projectId}.appspot.com`); + if (!projectId) throw new Error('Not connected to Firebase'); + const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; const prefix = storagePath ? (storagePath.endsWith('/') ? storagePath : storagePath + '/') : ''; + if (storageEmulatorHost) { + const baseUrl = getEmulatorBaseUrl(); + const url = `${baseUrl}/b/${bucketName}/o?prefix=${encodeURIComponent(prefix)}&delimiter=/`; + const response = await fetch(url, { + headers: { Authorization: 'Bearer owner' }, + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'Storage list failed' }; + } + const folders = (data.prefixes || []).map((p) => ({ + name: p.replace(prefix, '').replace(/\/$/, ''), + path: p, + type: 'folder', + size: 0, + updated: null, + })); + const fileList = (data.items || []) + .filter((f) => f.name !== prefix && !f.name.endsWith('/')) + .map((f) => normalizeStorageItem(f, prefix)); + return { success: true, items: [...folders, ...fileList], currentPath: storagePath }; + } + + const bucket = adminRef.storage().bucket(bucketName); const [files] = await bucket.getFiles({ prefix, delimiter: '/', autoPaginate: false }); const [, , apiResponse] = await bucket.getFiles({ prefix, delimiter: '/', autoPaginate: false }); @@ -101,18 +178,37 @@ function registerHandlers() { } }); - // Upload file (Admin SDK) + // Upload file (Admin SDK / Emulator REST) ipcMain.handle('storage:uploadFile', async (event, { storagePath }) => { try { - if (!adminRef?.apps?.length) throw new Error('Not connected'); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected'); const { filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] }); if (!filePaths?.length) return { success: false, error: 'No file selected' }; const localPath = filePaths[0]; const fileName = path.basename(localPath); - const bucket = adminRef.storage().bucket(`${getProjectId()}.appspot.com`); + const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; const destination = storagePath ? `${storagePath}/${fileName}` : fileName; + if (storageEmulatorHost) { + const baseUrl = getEmulatorBaseUrl(); + const fileContent = fs.readFileSync(localPath); + const mimeType = require('mime-types').lookup(localPath) || 'application/octet-stream'; + const url = `${baseUrl}/b/${bucketName}/o?uploadType=media&name=${encodeURIComponent(destination)}`; + const response = await fetch(url, { + method: 'POST', + headers: { Authorization: 'Bearer owner', 'Content-Type': mimeType }, + body: fileContent, + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'Upload failed' }; + } + return { success: true, fileName, path: destination }; + } + + const bucket = adminRef.storage().bucket(bucketName); await bucket.upload(localPath, { destination, metadata: { contentType: require('mime-types').lookup(localPath) || 'application/octet-stream' }, @@ -123,14 +219,29 @@ function registerHandlers() { } }); - // Download file (Admin SDK) + // Download file (Admin SDK / Emulator REST) ipcMain.handle('storage:downloadFile', async (event, { filePath }) => { try { - if (!adminRef?.apps?.length) throw new Error('Not connected'); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected'); const { filePath: savePath } = await dialog.showSaveDialog({ defaultPath: filePath.split('/').pop() }); if (!savePath) return { success: false, error: 'No save location' }; - const bucket = adminRef.storage().bucket(`${getProjectId()}.appspot.com`); + if (storageEmulatorHost) { + const baseUrl = getEmulatorBaseUrl(); + const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + const url = `${baseUrl}/b/${bucketName}/o/${encodeURIComponent(filePath)}?alt=media`; + const response = await fetch(url, { headers: { Authorization: 'Bearer owner' } }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + return { success: false, error: err.error?.message || 'Download failed' }; + } + const buffer = await response.buffer(); + fs.writeFileSync(savePath, buffer); + return { success: true, savedTo: savePath }; + } + + const bucket = adminRef.storage().bucket(`${projectId}.appspot.com`); await bucket.file(filePath).download({ destination: savePath }); return { success: true, savedTo: savePath }; } catch (error) { @@ -138,11 +249,28 @@ function registerHandlers() { } }); - // Get signed URL (Admin SDK) + // Get download URL (Admin SDK / Emulator REST) ipcMain.handle('storage:getDownloadUrl', async (event, { filePath, expiresInMs }) => { try { - if (!adminRef?.apps?.length) throw new Error('Not connected'); - const bucket = adminRef.storage().bucket(`${getProjectId()}.appspot.com`); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected'); + const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + + if (storageEmulatorHost) { + const baseUrl = getEmulatorBaseUrl(); + // Fetch metadata to check for download tokens + const metaUrl = `${baseUrl}/b/${bucketName}/o/${encodeURIComponent(filePath)}`; + const metaResponse = await fetch(metaUrl, { headers: { Authorization: 'Bearer owner' } }); + const metadata = await metaResponse.json(); + if (metadata.error) { + return { success: false, error: metadata.error.message || 'File not found' }; + } + const token = metadata.downloadTokens ? metadata.downloadTokens.split(',')[0] : 'owner'; + const url = `${baseUrl}/b/${bucketName}/o/${encodeURIComponent(filePath)}?alt=media&token=${token}`; + return { success: true, url }; + } + + const bucket = adminRef.storage().bucket(bucketName); const expiration = expiresInMs || 7 * 24 * 60 * 60 * 1000; const expiresDate = new Date(Date.now() + expiration); const expiresString = `${String(expiresDate.getMonth() + 1).padStart(2, '0')}-${String(expiresDate.getDate()).padStart(2, '0')}-${expiresDate.getFullYear()}`; @@ -153,25 +281,60 @@ function registerHandlers() { } }); - // Delete file (Admin SDK) + // Delete file (Admin SDK / Emulator REST) ipcMain.handle('storage:deleteFile', async (event, { filePath }) => { try { - if (!adminRef?.apps?.length) throw new Error('Not connected'); - await adminRef.storage().bucket(`${getProjectId()}.appspot.com`).file(filePath).delete(); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected'); + const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + + if (storageEmulatorHost) { + const baseUrl = getEmulatorBaseUrl(); + const url = `${baseUrl}/b/${bucketName}/o/${encodeURIComponent(filePath)}`; + const response = await fetch(url, { + method: 'DELETE', + headers: { Authorization: 'Bearer owner' }, + }); + if (!response.ok && response.status !== 204) { + const err = await response.json().catch(() => ({})); + return { success: false, error: err.error?.message || 'Delete failed' }; + } + return { success: true }; + } + + await adminRef.storage().bucket(bucketName).file(filePath).delete(); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); - // Create folder (Admin SDK) + // Create folder (Admin SDK / Emulator REST) ipcMain.handle('storage:createFolder', async (event, { folderPath }) => { try { - if (!adminRef?.apps?.length) throw new Error('Not connected'); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected'); + const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; const placeholderPath = folderPath.endsWith('/') ? folderPath + '.placeholder' : folderPath + '/.placeholder'; + + if (storageEmulatorHost) { + const baseUrl = getEmulatorBaseUrl(); + const url = `${baseUrl}/b/${bucketName}/o?uploadType=media&name=${encodeURIComponent(placeholderPath)}`; + const response = await fetch(url, { + method: 'POST', + headers: { Authorization: 'Bearer owner', 'Content-Type': 'application/x-empty' }, + body: '', + }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'Folder creation failed' }; + } + return { success: true, folderPath }; + } + await adminRef .storage() - .bucket(`${getProjectId()}.appspot.com`) + .bucket(bucketName) .file(placeholderPath) .save('', { metadata: { contentType: 'application/x-empty' } }); return { success: true, folderPath }; @@ -180,11 +343,36 @@ function registerHandlers() { } }); - // Get file metadata (Admin SDK) + // Get file metadata (Admin SDK / Emulator REST) ipcMain.handle('storage:getFileMetadata', async (event, { filePath }) => { try { - if (!adminRef?.apps?.length) throw new Error('Not connected'); - const [metadata] = await adminRef.storage().bucket(`${getProjectId()}.appspot.com`).file(filePath).getMetadata(); + const projectId = getProjectId(); + if (!projectId) throw new Error('Not connected'); + const bucketName = storageEmulatorHost ? await getEmulatorBucketName(projectId) : `${projectId}.appspot.com`; + + if (storageEmulatorHost) { + const baseUrl = getEmulatorBaseUrl(); + const url = `${baseUrl}/b/${bucketName}/o/${encodeURIComponent(filePath)}`; + const response = await fetch(url, { headers: { Authorization: 'Bearer owner' } }); + const data = await response.json(); + if (data.error) { + return { success: false, error: data.error.message || 'File not found' }; + } + return { + success: true, + metadata: { + name: data.name, + size: parseInt(data.size || 0, 10), + contentType: data.contentType || 'application/octet-stream', + created: data.timeCreated || null, + updated: data.updated || null, + generation: data.generation || null, + md5Hash: data.md5Hash || null, + }, + }; + } + + const [metadata] = await adminRef.storage().bucket(bucketName).file(filePath).getMetadata(); return { success: true, metadata: { @@ -372,4 +560,4 @@ function registerHandlers() { }); } -module.exports = { registerHandlers, setAdminRef }; +module.exports = { registerHandlers, setAdminRef, setStorageEmulatorHost }; diff --git a/electron/preload.js b/electron/preload.js index 7900ca6..e96c87d 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -9,6 +9,10 @@ ipcRenderer.on('open-settings-dialog', () => { window.dispatchEvent(new CustomEvent('open-settings-dialog')); }); +ipcRenderer.on('scan-emulators', () => { + window.dispatchEvent(new CustomEvent('scan-emulators')); +}); + // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object contextBridge.exposeInMainWorld('electronAPI', { @@ -16,6 +20,10 @@ contextBridge.exposeInMainWorld('electronAPI', { connectFirebase: (params) => ipcRenderer.invoke('firebase:connect', params), disconnectFirebase: () => ipcRenderer.invoke('firebase:disconnect'), + // Emulators + scanEmulatorsHub: () => ipcRenderer.invoke('emulators:scanHub'), + scanEmulatorsConfig: () => ipcRenderer.invoke('emulators:scanConfig'), + // Firestore operations getCollections: () => ipcRenderer.invoke('firestore:getCollections'), getDocuments: (params) => ipcRenderer.invoke('firestore:getDocuments', params), diff --git a/screenshots/1.png b/screenshots/1.png index 25af94c..cb6045b 100644 Binary files a/screenshots/1.png and b/screenshots/1.png differ diff --git a/src/App.tsx b/src/App.tsx index 9cc2b24..00ceae0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { getActiveFirestoreDatabase, getFirestoreDatabaseDisplay, } from './features/projects/utils/firestoreDatabaseUtils'; +import { getConsoleUrl } from './features/projects/utils/projectConsoleUrl'; import { selectOpenTabs, selectActiveTabId, @@ -503,13 +504,13 @@ function FirestudioApp() { onRevealInFirebaseConsole: (item: Project | GoogleAccount) => { const project = ensureProject(item); if (!project) return; - const url = `https://console.firebase.google.com/project/${project.projectId}/firestore`; + const url = getConsoleUrl(project, 'firestore'); electronService.openExternal(url); }, onRevealCollectionInConsole: (item: Project | GoogleAccount, collection: string) => { const project = ensureProject(item); if (!project) return; - const url = `https://console.firebase.google.com/project/${project.projectId}/firestore/data/${collection}`; + const url = getConsoleUrl(project, 'firestore', collection); electronService.openExternal(url); }, diff --git a/src/app/store/index.ts b/src/app/store/index.ts index 463c083..3c910dc 100644 --- a/src/app/store/index.ts +++ b/src/app/store/index.ts @@ -26,6 +26,7 @@ import projectsReducer, { updateProject, addGoogleAccount, connectServiceAccount, + connectEmulatorProject, refreshCollections, Project, GoogleAccount, @@ -54,6 +55,7 @@ projectsPersistenceListener.startListening({ updateProject, addGoogleAccount, connectServiceAccount.fulfilled, + connectEmulatorProject.fulfilled, refreshCollections.fulfilled, ), effect: (_, api) => { diff --git a/src/features/auth/components/AuthTab.tsx b/src/features/auth/components/AuthTab.tsx index e74b889..19ea569 100644 --- a/src/features/auth/components/AuthTab.tsx +++ b/src/features/auth/components/AuthTab.tsx @@ -71,6 +71,7 @@ interface AuthUser { } import { Project } from '../../projects/store/projectsSlice'; +import { getConsoleUrl } from '../../projects/utils/projectConsoleUrl'; interface AuthTabProps { project: Project; @@ -120,6 +121,7 @@ function AuthTab({ project, addLog, showMessage }: AuthTabProps) { const [editEmailVerified, setEditEmailVerified] = useState(false); const isGoogle = project?.authMethod === 'google'; + const isEmulator = project?.authMethod === 'emulator'; const loadingRef = useRef(false); const loadUsers = useCallback(async () => { @@ -130,14 +132,32 @@ function AuthTab({ project, addLog, showMessage }: AuthTabProps) { setLoading(true); setAuthError(null); try { - const result = isGoogle - ? await electron.googleListAuthUsers({ projectId: project.projectId, maxResults: 1000 }) - : (await electron.disconnectFirebase(), - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId(project), - }), - await electron.listAuthUsers({ limit: 1000 })); + let result; + if (isGoogle) { + result = await electron.googleListAuthUsers({ projectId: project.projectId, maxResults: 1000 }); + } else if (isEmulator) { + const authHost = project.emulatorServices?.auth + ? `${project.emulatorServices.auth.host}:${project.emulatorServices.auth.port}` + : undefined; + const storageHost = project.emulatorServices?.storage + ? `${project.emulatorServices.storage.host}:${project.emulatorServices.storage.port}` + : undefined; + await electron.disconnectFirebase(); + await electron.connectFirebase({ + projectId: project.projectId, + emulatorHost: project.emulatorHost, + authEmulatorHost: authHost, + storageEmulatorHost: storageHost, + }); + result = await electron.listAuthUsers({ limit: 1000 }); + } else { + await electron.disconnectFirebase(); + await electron.connectFirebase({ + serviceAccountPath: project.serviceAccountPath!, + databaseId: getServiceAccountConnectDatabaseId(project), + }); + result = await electron.listAuthUsers({ limit: 1000 }); + } if (result.success) { setUsers(result.users || []); @@ -163,14 +183,14 @@ function AuthTab({ project, addLog, showMessage }: AuthTabProps) { loadingRef.current = false; setLoading(false); } - }, [project, isGoogle, addLog, showMessage, electron]); + }, [project, isGoogle, isEmulator, addLog, showMessage, electron]); useEffect(() => { if (project) loadUsers(); }, [project, loadUsers]); const openFirebaseConsole = () => { - const url = `https://console.firebase.google.com/project/${project.projectId}/authentication/users`; + const url = getConsoleUrl(project, 'authentication'); electronService.openExternal(url); }; diff --git a/src/features/collections/store/collectionSlice.ts b/src/features/collections/store/collectionSlice.ts index f4e692a..cb77914 100644 --- a/src/features/collections/store/collectionSlice.ts +++ b/src/features/collections/store/collectionSlice.ts @@ -57,6 +57,33 @@ const createAppAsyncThunk = createAsyncThunk.withTypes<{ extra: ThunkExtra; }>(); +/** + * Reconnect to Firebase appropriate for the project type. + * Service account projects use credentials; emulator projects use host override. + */ +async function connectForProject(electron: ElectronAPI, project: Project, firestoreDatabaseId?: string): Promise { + await electron.disconnectFirebase(); + if (project.authMethod === 'emulator') { + const authHost = project.emulatorServices?.auth + ? `${project.emulatorServices.auth.host}:${project.emulatorServices.auth.port}` + : undefined; + const storageHost = project.emulatorServices?.storage + ? `${project.emulatorServices.storage.host}:${project.emulatorServices.storage.port}` + : undefined; + await electron.connectFirebase({ + projectId: project.projectId, + emulatorHost: project.emulatorHost, + authEmulatorHost: authHost, + storageEmulatorHost: storageHost, + }); + } else { + await electron.connectFirebase({ + serviceAccountPath: project.serviceAccountPath!, + databaseId: getServiceAccountConnectDatabaseId(project, firestoreDatabaseId), + }); + } +} + // Thunks interface ImportResult { @@ -86,11 +113,7 @@ export const importCollection = createAppAsyncThunk< } return { count }; } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId(project, firestoreDatabaseId), - }); + await connectForProject(electron, project, firestoreDatabaseId); const result = await electron.importDocuments(collection); if (!result.success && result.error !== 'Import cancelled') throw new Error(result.error); @@ -124,11 +147,7 @@ export const exportCollection = createAppAsyncThunk< // Component handles SA export (since it's server side, but initiated here). return rejectWithValue('Google export handled by component'); } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId(project, firestoreDatabaseId), - }); + await connectForProject(electron, project, firestoreDatabaseId); const result = await electron.exportCollection(collection); if (!result.success && result.error !== 'Export cancelled') { @@ -184,12 +203,8 @@ export const fetchDocuments = createAppAsyncThunk< parseFirestoreFields, ) as unknown as Document[]; } else { - // Service Account JS Query - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId(project, firestoreDatabaseId), - }); + // Service Account / Emulator JS Query + await connectForProject(electron, project, firestoreDatabaseId); const result = await electron.executeJsQuery({ collectionPath: collection, jsQuery }); if (!result.success) throw new Error(result.error); @@ -242,11 +257,7 @@ export const fetchDocuments = createAppAsyncThunk< // Google documents might need parsing if raw? checks suggest they are usually parsed by main process or clean. // googleGetDocuments usually returns formatted docs. } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId(project, firestoreDatabaseId), - }); + await connectForProject(electron, project, firestoreDatabaseId); const res = await electron.getDocuments({ collectionPath: collection, @@ -299,14 +310,7 @@ export const createCollection = createAppAsyncThunk< databaseId: getGoogleApiDatabaseId(project, firestoreDatabaseId), }); } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); result = await electron.createDocument({ collectionPath: trimmedName, documentId: finalDocId, @@ -364,14 +368,7 @@ export const addDocument = createAppAsyncThunk< databaseId: getGoogleApiDatabaseId(project, firestoreDatabaseId), }); } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); result = await electron.createDocument({ collectionPath: collection, documentId: finalDocId, @@ -423,14 +420,7 @@ export const updateDocument = createAppAsyncThunk< databaseId: getGoogleApiDatabaseId(project, firestoreDatabaseId), }); } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); result = await electron.setDocument({ documentPath: `${collection}/${docId}`, data: docData, @@ -464,14 +454,7 @@ export const deleteDocument = createAppAsyncThunk< databaseId: getGoogleApiDatabaseId(project, firestoreDatabaseId), }); } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); result = await electron.deleteDocument(`${collection}/${docId}`); } @@ -507,14 +490,7 @@ export const renameCollection = createAppAsyncThunk< }); documents = res.success ? ((res.documents || []) as Document[]) : []; } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); const res = await electron.getDocuments({ collectionPath: currentPath, limit: 10000, @@ -630,14 +606,7 @@ export const estimateDocCount = createAppAsyncThunk< } } } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); const res = await electron.query({ collectionPath: collection, limit: 100000, @@ -696,14 +665,7 @@ export const exportSingleCollection = createAppAsyncThunk< downloadJson(res.documents, `${collection.replace(/\//g, '_')}_export.json`); return { count: res.count }; } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); const res = await electron.exportCollection(collection); if (!res.success) throw new Error(res.error || 'Export failed'); return res; @@ -738,14 +700,7 @@ export const exportAllCollections = createAppAsyncThunk< downloadJson(res.data, `${project.projectId}_firestore_export.json`); return { collectionsCount: res.collectionsCount }; } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); const res = await electron.exportCollections(); if (!res.success) throw new Error(res.error || 'Export failed'); return res; @@ -772,14 +727,7 @@ export const deleteCollection = createAppAsyncThunk< }); documents = res.success ? ((res.documents || []) as Document[]) : []; } else { - await electron.disconnectFirebase(); - await electron.connectFirebase({ - serviceAccountPath: project.serviceAccountPath!, - databaseId: getServiceAccountConnectDatabaseId( - project, - firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id, - ), - }); + await connectForProject(electron, project, firestoreDatabaseId ?? getActiveFirestoreDatabase(project)?.id); const res = await electron.getDocuments({ collectionPath: collection, limit: 10000, diff --git a/src/features/console/components/ConsolePanel.tsx b/src/features/console/components/ConsolePanel.tsx index c5ca1d3..8291a6d 100644 --- a/src/features/console/components/ConsolePanel.tsx +++ b/src/features/console/components/ConsolePanel.tsx @@ -546,7 +546,7 @@ Examples: {allProjects.map((p) => ( setSelectedProject(p)} color={selectedProject?.id === p.id ? 'primary' : 'default'} diff --git a/src/features/projects/components/ConnectionDialog.tsx b/src/features/projects/components/ConnectionDialog.tsx index c6037a4..2d10432 100644 --- a/src/features/projects/components/ConnectionDialog.tsx +++ b/src/features/projects/components/ConnectionDialog.tsx @@ -20,8 +20,15 @@ import { Close as CloseIcon, Key as KeyIcon, Google as GoogleIcon, + Dns as DnsIcon, + Refresh as RefreshIcon, } from '@mui/icons-material'; import { electronService } from '../../../shared/services/electronService'; +import { useDispatch } from 'react-redux'; +import { AppDispatch } from '../../../app/store'; +import { scanEmulators, connectEmulatorProject } from '../store/projectsSlice'; +import { addLog } from '../../../app/store/slices/logsSlice'; +import { getErrorMessage } from '../../../shared/utils/commonUtils'; interface ConnectionDialogProps { open: boolean; @@ -34,10 +41,52 @@ interface ConnectionDialogProps { function ConnectionDialog({ open, onClose, onConnect, onGoogleSignIn, loading }: ConnectionDialogProps) { const theme = useTheme(); - // isDark removed, use theme.palette directly + const dispatch = useDispatch(); + const [tabIndex, setTabIndex] = useState(0); const [serviceAccountPath, setServiceAccountPath] = useState(''); + const [emulators, setEmulators] = useState< + Array<{ projectId: string; host: string; port: number; services?: Record }> + >([]); + const [emulatorsLoading, setEmulatorsLoading] = useState(false); + const [connectingEmulatorId, setConnectingEmulatorId] = useState(null); + + const fetchEmulators = async () => { + setEmulatorsLoading(true); + try { + const result = await dispatch(scanEmulators()).unwrap(); + setEmulators(result.emulators || []); + } catch (err) { + dispatch(addLog({ type: 'error', message: 'Failed to scan for emulators: ' + getErrorMessage(err) })); + } finally { + setEmulatorsLoading(false); + } + }; + + const handleConnectEmulator = async (emulator: { + projectId: string; + host: string; + port: number; + services?: Record; + }) => { + const id = `${emulator.projectId}-${emulator.port}`; + setConnectingEmulatorId(id); + try { + const result = await dispatch(connectEmulatorProject(emulator)).unwrap(); + if (result.mode === 'create') { + dispatch(addLog({ type: 'success', message: `Connected to emulator for project ${emulator.projectId}` })); + } else { + dispatch(addLog({ type: 'success', message: `Emulator ${emulator.projectId} already connected` })); + } + onClose(); + } catch (err) { + dispatch(addLog({ type: 'error', message: 'Failed to connect emulator: ' + getErrorMessage(err) })); + } finally { + setConnectingEmulatorId(null); + } + }; + const handleBrowse = async () => { const path = await electronService.api.openFileDialog(); if (path) { @@ -58,9 +107,10 @@ function ConnectionDialog({ open, onClose, onConnect, onGoogleSignIn, loading }: }; const handleClose = () => { - if (!loading) { + if (!loading && !connectingEmulatorId) { setServiceAccountPath(''); setTabIndex(0); + setEmulators([]); onClose(); } }; @@ -78,6 +128,7 @@ function ConnectionDialog({ open, onClose, onConnect, onGoogleSignIn, loading }: setTabIndex(v)}> } iconPosition="start" label="Google Sign-In" /> } iconPosition="start" label="Service Account" /> + } iconPosition="start" label="Local Emulator" /> @@ -141,7 +192,7 @@ function ConnectionDialog({ open, onClose, onConnect, onGoogleSignIn, loading }: - ) : ( + ) : tabIndex === 1 ? ( /* Service Account Tab */ @@ -198,6 +249,79 @@ function ConnectionDialog({ open, onClose, onConnect, onGoogleSignIn, loading }: + ) : ( + /* Local Emulator Tab */ + + + Connect to a locally running Firebase Emulator Suite instance. + + + + + + + {emulators.length > 0 && ( + + + Discovered Emulators + + {emulators.map((emulator) => { + const id = `${emulator.projectId}-${emulator.port}`; + const isConnecting = connectingEmulatorId === id; + return ( + + + + + + {emulator.projectId} + + + Host: {emulator.host}:{emulator.port} + + + + + + ); + })} + + )} + + {emulatorsLoading === false && emulators.length === 0 && ( + + No running Firebase emulators detected. + + )} + )} diff --git a/src/features/projects/components/ProjectSidebar.tsx b/src/features/projects/components/ProjectSidebar.tsx index 9df5435..004f8a9 100644 --- a/src/features/projects/components/ProjectSidebar.tsx +++ b/src/features/projects/components/ProjectSidebar.tsx @@ -191,6 +191,7 @@ function ProjectSidebar({ const serviceAccountProjects = projects.filter( (p) => !isGoogleAccount(p) && p.authMethod === 'serviceAccount', ) as Project[]; + const emulatorProjects = projects.filter((p) => !isGoogleAccount(p) && p.authMethod === 'emulator') as Project[]; return ( - Reveal in Firebase Console + {getConsoleLabel(menuTarget)} handleAction(onCopyProjectId, menuTarget)}> @@ -294,7 +295,7 @@ function SidebarMenu({ - Reveal in Firebase Console + {getConsoleLabel(menuTarget.project)} ) : menuTarget ? ( @@ -323,7 +324,7 @@ function SidebarMenu({ - Reveal in Firebase Console + {getConsoleLabel(menuTarget)} handleAction(onCopyProjectId, menuTarget)}> diff --git a/src/features/projects/components/sidebar/SidebarProjectsList.tsx b/src/features/projects/components/sidebar/SidebarProjectsList.tsx index b434613..8d67afc 100644 --- a/src/features/projects/components/sidebar/SidebarProjectsList.tsx +++ b/src/features/projects/components/sidebar/SidebarProjectsList.tsx @@ -14,6 +14,7 @@ import { LinkOff as LinkOffIcon, Add as AddIcon, Dns as DatabaseIcon, + Computer as ComputerIcon, } from '@mui/icons-material'; import { useDispatch } from 'react-redux'; import { setSidebarItemExpanded, Tab } from '../../../../app/store/slices/uiSlice'; @@ -24,6 +25,7 @@ import { getFirestoreDatabaseDisplay } from '../../utils/firestoreDatabaseUtils' interface SidebarProjectsListProps { googleAccounts: GoogleAccount[]; serviceAccountProjects: Project[]; + emulatorProjects: Project[]; searchQuery: string; setSearchQuery: (query: string) => void; expandedItems: Record; @@ -64,6 +66,7 @@ interface SidebarProjectsListProps { function SidebarProjectsList({ googleAccounts, serviceAccountProjects, + emulatorProjects, searchQuery, setSearchQuery, expandedItems, @@ -96,7 +99,7 @@ function SidebarProjectsList({ return ( - {googleAccounts.length === 0 && serviceAccountProjects.length === 0 ? ( + {googleAccounts.length === 0 && serviceAccountProjects.length === 0 && emulatorProjects.length === 0 ? ( No projects connected @@ -1168,6 +1171,471 @@ function SidebarProjectsList({ ); })} + { + e.stopPropagation(); + onAddFirestoreDatabase(project); + }} + sx={{ + display: 'flex', + alignItems: 'center', + px: 1, + py: 0.5, + cursor: 'pointer', + color: 'primary.main', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + Add database + + + + + ); + })()} + + + + ); + })} + + {/* Emulator Projects */} + {emulatorProjects + .filter((project) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + project.projectId.toLowerCase().includes(query) || + project.firestoreDatabases?.some( + (fd) => + getFirestoreDatabaseDisplay(fd).toLowerCase().includes(query) || + fd.databaseId.toLowerCase().includes(query) || + fd.collections?.some((c) => collectionMatchesQuery(c, query)), + ) || + project.collections?.some((c) => collectionMatchesQuery(c, query)) + ); + }) + .map((project) => { + const isExpanded = expandedItems[project.id] !== undefined ? expandedItems[project.id] : true; + + return ( + + toggleExpanded(project.id, isExpanded)} + onContextMenu={(e) => handleContextMenu(e, project, 'project')} + sx={{ + display: 'flex', + alignItems: 'center', + px: 1, + py: 0.5, + cursor: 'pointer', + bgcolor: selectedProject?.id === project.id ? 'action.selected' : 'transparent', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + {isExpanded ? ( + + ) : ( + + )} + + + + {project.projectId} + + handleMenu(e, project, 'project')} + sx={{ + p: 0.25, + opacity: 0.5, + '&:hover': { + opacity: 1, + backgroundColor: 'action.hover', + }, + color: 'text.secondary', + borderRadius: 1, + }} + > + + + + + + + {(() => { + const isAuthActive = activeTab?.type === 'auth' && activeTab?.projectId === project.id; + return ( + onOpenAuth?.(project)} + sx={{ + display: 'flex', + alignItems: 'center', + px: 1, + py: 0.5, + cursor: 'pointer', + bgcolor: isAuthActive ? 'action.selected' : 'transparent', + borderLeft: isAuthActive ? '2px solid' : '2px solid transparent', + borderColor: isAuthActive ? 'secondary.main' : 'transparent', + '&:hover': { + bgcolor: isAuthActive ? 'action.selected' : 'action.hover', + }, + }} + > + + + Authentication + + + ); + })()} + + {(() => { + const isStorageActive = activeTab?.type === 'storage' && activeTab?.projectId === project.id; + return ( + onOpenStorage?.(project)} + sx={{ + display: 'flex', + alignItems: 'center', + px: 1, + py: 0.5, + mt: 0.25, + cursor: 'pointer', + borderTop: 1, + borderTopColor: 'divider', + bgcolor: isStorageActive ? 'action.selected' : 'transparent', + borderLeft: isStorageActive ? '2px solid' : '2px solid transparent', + borderLeftColor: isStorageActive ? 'primary.main' : 'transparent', + '&:hover': { + bgcolor: isStorageActive ? 'action.selected' : 'action.hover', + }, + }} + > + + + Storage + + + ); + })()} + + {(() => { + const firestoreSectionId = `${project.id}:firestore`; + const isFirestoreSectionExpanded = + expandedItems[firestoreSectionId] !== undefined ? expandedItems[firestoreSectionId] : true; + + return ( + + + { + e.stopPropagation(); + toggleExpanded(firestoreSectionId, isFirestoreSectionExpanded); + }} + > + {isFirestoreSectionExpanded ? ( + + ) : ( + + )} + + + + Firestore + + + { + e.stopPropagation(); + onAddFirestoreDatabase(project); + }} + sx={{ p: 0.25, color: 'primary.main' }} + > + + + + { + e.stopPropagation(); + handleContextMenu(e, { menuType: 'firestoreRoot', project }, 'firestoreRoot'); + }} + sx={{ p: 0.25, color: 'text.secondary' }} + > + + + + + + + {(project.firestoreDatabases || []).map((fd) => { + const fdExpandedKey = `${project.id}:fd:${fd.id}`; + const isFdExpanded = + expandedItems[fdExpandedKey] !== undefined ? expandedItems[fdExpandedKey] : true; + const dbLabel = getFirestoreDatabaseDisplay(fd); + const isDbSelected = + selectedProject?.id === project.id && project.activeFirestoreDatabaseId === fd.id; + + return ( + + onSetActiveFirestoreDatabase(project.id, fd.id)} + > + { + e.stopPropagation(); + toggleExpanded(fdExpandedKey, isFdExpanded); + }} + > + {isFdExpanded ? ( + + ) : ( + + )} + + + + + {dbLabel} + + + {fd.databaseId} + + + { + e.stopPropagation(); + handleContextMenu( + e, + { menuType: 'firestoreDatabase', project, firestoreDatabase: fd }, + 'firestoreDatabase', + ); + }} + sx={{ p: 0.2, color: 'text.secondary' }} + > + + + + + + + {(fd.collections || []) + .filter( + (collection) => + !searchQuery || collectionMatchesQuery(collection, searchQuery), + ) + .map((collection) => { + const collectionPath = + getCollectionPath(collection) || getCollectionLabel(collection); + const collectionLabel = getCollectionLabel(collection); + const collectionKey = `${project.id}:${fd.id}:${collectionPath}`; + const isActive = + activeTab?.type === 'collection' && + activeTab?.projectId === project.id && + activeTab?.collectionPath === collectionPath && + activeTab?.firestoreDatabaseId === fd.id; + const isMenuTarget = + isMenuOpen && + menuTarget?.menuType === 'collection' && + menuTarget?.project?.id === project.id && + menuTarget?.collection === collectionPath && + (menuTarget as { firestoreDatabaseId?: string }).firestoreDatabaseId === + fd.id; + return ( + { + onSetActiveFirestoreDatabase(project.id, fd.id); + onOpenCollection( + project, + collectionPath, + fd.id, + getFirestoreDatabaseDisplay(fd), + ); + dispatch( + setSidebarItemExpanded({ + id: project.id, + expanded: true, + }), + ); + dispatch( + setSidebarItemExpanded({ + id: `${project.id}:firestore`, + expanded: true, + }), + ); + dispatch( + setSidebarItemExpanded({ + id: `${project.id}:fd:${fd.id}`, + expanded: true, + }), + ); + setSearchQuery(''); + }} + onContextMenu={(e) => + handleContextMenu( + e, + { + project, + collection: collectionPath, + firestoreDatabaseId: fd.id, + }, + 'collection', + ) + } + sx={{ + display: 'flex', + alignItems: 'center', + px: 1, + py: 0.35, + cursor: 'pointer', + bgcolor: isMenuTarget + ? 'action.hover' + : isActive + ? 'action.selected' + : 'transparent', + borderLeft: isActive ? '2px solid' : '2px solid transparent', + borderColor: isActive ? 'primary.main' : 'transparent', + '&:hover': { + bgcolor: isActive ? 'action.selected' : 'action.hover', + }, + }} + > + + + {collectionLabel} + + + ); + })} + {(!fd.collections || fd.collections.length === 0) && ( + + No collections + + )} + + { + e.stopPropagation(); + onSetActiveFirestoreDatabase(project.id, fd.id); + onAddCollection?.(project); + }} + sx={{ + display: 'flex', + alignItems: 'center', + px: 1, + py: 0.35, + cursor: 'pointer', + color: 'primary.main', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + Add collection + + + + + ); + })} + { e.stopPropagation(); diff --git a/src/features/projects/store/projectsPersistence.ts b/src/features/projects/store/projectsPersistence.ts index 155db26..3ade5a2 100644 --- a/src/features/projects/store/projectsPersistence.ts +++ b/src/features/projects/store/projectsPersistence.ts @@ -50,6 +50,18 @@ export const saveProjectsToStorage = (projects: (Project | GoogleAccount)[]) => collections: proj.collections || [], })), }; + } else if (isProject(p) && p.authMethod === 'emulator') { + return { + id: p.id, + projectId: p.projectId, + authMethod: p.authMethod, + emulatorHost: p.emulatorHost, + emulatorPort: p.emulatorPort, + emulatorServices: p.emulatorServices, + collections: p.collections || [], + firestoreDatabases: p.firestoreDatabases || [], + activeFirestoreDatabaseId: p.activeFirestoreDatabaseId, + }; } else if (isProject(p) && p.authMethod === 'google') { return { id: p.id, diff --git a/src/features/projects/store/projectsSlice.ts b/src/features/projects/store/projectsSlice.ts index 13a461a..d219f05 100644 --- a/src/features/projects/store/projectsSlice.ts +++ b/src/features/projects/store/projectsSlice.ts @@ -21,8 +21,11 @@ export interface FirestoreCollection { export interface Project { id: string; projectId: string; - authMethod: 'serviceAccount' | 'google'; + authMethod: 'serviceAccount' | 'google' | 'emulator'; serviceAccountPath?: string; + emulatorHost?: string; + emulatorPort?: number; + emulatorServices?: Record; /** @deprecated Prefer firestoreDatabases; kept for persisted legacy */ databaseId?: string; /** @deprecated Prefer firestoreDatabases[].collections */ @@ -273,6 +276,75 @@ export const addFirestoreDatabase = createAppAsyncThunk( }, ); +export const scanEmulators = createAppAsyncThunk('projects/scanEmulators', async (_, { extra, rejectWithValue }) => { + const electron = extra.electron.api; + const hubRes = await electron.scanEmulatorsHub(); + if (!hubRes?.success) return rejectWithValue(hubRes?.error || 'Failed to scan emulators hub'); + + return { emulators: hubRes.emulators || [] }; +}); + +export const connectEmulatorProject = createAppAsyncThunk( + 'projects/connectEmulatorProject', + async ( + { + projectId, + host, + port, + services, + }: { projectId: string; host: string; port: number; services?: Record }, + { extra, getState }, + ) => { + const electron = extra.electron.api; + const state = getState() as RootState; + const emulatorId = `emulator-${projectId}-${port}`; + + const existing = state.projects.items.find((i): i is Project => !isGoogleAccount(i) && i.id === emulatorId); + if (existing) { + return { mode: 'existing' as const, projectId: existing.id }; + } + + const authEmulatorHost = services?.auth ? `${services.auth.host}:${services.auth.port}` : undefined; + const storageEmulatorHost = services?.storage ? `${services.storage.host}:${services.storage.port}` : undefined; + + await electron.disconnectFirebase(); + const result = await electron.connectFirebase({ + projectId, + emulatorHost: `${host}:${port}`, + authEmulatorHost, + storageEmulatorHost, + }); + + if (!result?.success) throw new Error(result?.error || 'Emulator connection failed'); + + const collectionsResult = await electron.getCollections(); + const collections = collectionsResult?.success ? normalizeCollections(collectionsResult.collections) : []; + + const newFd: FirestoreDatabase = { + id: `fd-${Date.now()}`, + databaseId: '(default)', + label: 'Emulator (default)', + collections, + }; + + return { + mode: 'create' as const, + project: { + id: emulatorId, + projectId, + authMethod: 'emulator' as const, + emulatorHost: `${host}:${port}`, + emulatorPort: port, + emulatorServices: services, + firestoreDatabases: [newFd], + activeFirestoreDatabaseId: newFd.id, + expanded: true, + connected: true, + } satisfies Project, + }; + }, +); + export const addGoogleFirestoreDatabase = createAppAsyncThunk( 'projects/addGoogleFirestoreDatabase', async ( @@ -359,10 +431,19 @@ export const refreshCollections = createAppAsyncThunk( if (!fd) { return rejectWithValue('No Firestore database configured for this project.'); } - await electron.disconnectFirebase(); + const authHost = project.emulatorServices?.auth + ? `${project.emulatorServices.auth.host}:${project.emulatorServices.auth.port}` + : undefined; + const storageHost = project.emulatorServices?.storage + ? `${project.emulatorServices.storage.host}:${project.emulatorServices.storage.port}` + : undefined; const connectResult = await electron.connectFirebase({ serviceAccountPath: project.serviceAccountPath || '', databaseId: databaseIdToConnectParam(fd.databaseId), + emulatorHost: project.emulatorHost, + projectId: project.projectId, + authEmulatorHost: authHost, + storageEmulatorHost: storageHost, }); if (!connectResult?.success) { throw new Error(connectResult?.error || 'Failed to connect to Firebase'); @@ -662,6 +743,23 @@ const projectsSlice = createSlice({ .addCase(addFirestoreDatabase.rejected, (state) => { state.loading = false; }) + .addCase(connectEmulatorProject.pending, (state) => { + state.loading = true; + }) + .addCase(connectEmulatorProject.fulfilled, (state, action) => { + const payload = action.payload; + if (payload.mode === 'create') { + state.items.push(payload.project); + state.selectedProjectId = payload.project.id; + } else { + state.selectedProjectId = payload.projectId; + } + state.loading = false; + }) + .addCase(connectEmulatorProject.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || null; + }) .addCase(addGoogleFirestoreDatabase.pending, (state) => { state.loading = true; }) @@ -690,6 +788,7 @@ const projectsSlice = createSlice({ .addCase(addGoogleFirestoreDatabase.rejected, (state) => { state.loading = false; }) + .addCase(refreshGoogleAccountProjects.fulfilled, (state, action) => { const { accountId, accessToken, projects } = action.payload; state.items = state.items.map((item) => { diff --git a/src/features/projects/utils/firestoreDatabaseUtils.ts b/src/features/projects/utils/firestoreDatabaseUtils.ts index 04238ab..223de43 100644 --- a/src/features/projects/utils/firestoreDatabaseUtils.ts +++ b/src/features/projects/utils/firestoreDatabaseUtils.ts @@ -4,7 +4,7 @@ import type { FirestoreDatabase } from './firestoreDatabaseTypes'; export type ServiceAccountProjectLike = { id: string; projectId: string; - authMethod: 'serviceAccount' | 'google'; + authMethod: 'serviceAccount' | 'google' | 'emulator'; databaseId?: string; collections?: { id: string; path: string }[]; firestoreDatabases?: FirestoreDatabase[]; diff --git a/src/features/projects/utils/projectConsoleUrl.ts b/src/features/projects/utils/projectConsoleUrl.ts new file mode 100644 index 0000000..461d40a --- /dev/null +++ b/src/features/projects/utils/projectConsoleUrl.ts @@ -0,0 +1,36 @@ +import { Project, GoogleAccount } from '../store/projectsSlice'; + +export type ConsoleService = 'firestore' | 'storage' | 'authentication'; + +export function getConsoleUrl( + project: Pick, + service: ConsoleService, + ...args: string[] +): string { + if (project.authMethod === 'emulator') { + const ui = project.emulatorServices?.ui; + const host = ui?.host || project.emulatorHost?.split(':')[0] || 'localhost'; + const port = ui?.port ?? 4000; + + return `http://${host}:${port}/${service}`; + } + + switch (service) { + case 'firestore': { + const collection = args[0]; + + return `https://console.firebase.google.com/project/${project.projectId}/firestore${collection ? `/data/${collection}` : ''}`; + } + case 'storage': + return `https://console.firebase.google.com/project/${project.projectId}/storage`; + case 'authentication': + return `https://console.firebase.google.com/project/${project.projectId}/authentication/users`; + } +} + +export function getConsoleLabel(project: Pick | GoogleAccount): string { + if ('authMethod' in project) { + return project.authMethod === 'emulator' ? 'Open in Emulator UI' : 'Reveal in Firebase Console'; + } + return 'Reveal in Firebase Console'; +} diff --git a/src/features/storage/components/StorageTab.tsx b/src/features/storage/components/StorageTab.tsx index b426088..a3a9f24 100644 --- a/src/features/storage/components/StorageTab.tsx +++ b/src/features/storage/components/StorageTab.tsx @@ -55,6 +55,7 @@ import { formatFileSize, copyToClipboard, confirmAction } from '../../../shared/ import { electronService } from '../../../shared/services/electronService'; import { getServiceAccountConnectDatabaseId } from '../../projects/utils/firestoreDatabaseUtils'; import type { Project as FirebaseProject } from '../../projects/store/projectsSlice'; +import { getConsoleUrl } from '../../projects/utils/projectConsoleUrl'; // Types interface StorageItem { @@ -127,6 +128,7 @@ function StorageTab({ project, addLog, showMessage }: StorageTabProps) { const [bucketError, setBucketError] = useState(null); const isGoogle = project?.authMethod === 'google'; + const isEmulator = project?.authMethod === 'emulator'; const loadingRef = useRef(false); const loadFiles = useCallback(async () => { @@ -140,6 +142,21 @@ function StorageTab({ project, addLog, showMessage }: StorageTabProps) { let result: { success: boolean; items?: StorageItem[]; error?: string }; if (isGoogle) { result = await electron.googleStorageListFiles({ projectId: project.projectId, path: currentPath }); + } else if (isEmulator) { + const storageHost = project.emulatorServices?.storage + ? `${project.emulatorServices.storage.host}:${project.emulatorServices.storage.port}` + : undefined; + const authHost = project.emulatorServices?.auth + ? `${project.emulatorServices.auth.host}:${project.emulatorServices.auth.port}` + : undefined; + await electron.disconnectFirebase(); + await electron.connectFirebase({ + projectId: project.projectId, + emulatorHost: project.emulatorHost, + authEmulatorHost: authHost, + storageEmulatorHost: storageHost, + }); + result = await electron.storageListFiles({ path: currentPath }); } else { await electron.disconnectFirebase(); await electron.connectFirebase({ @@ -169,7 +186,7 @@ function StorageTab({ project, addLog, showMessage }: StorageTabProps) { loadingRef.current = false; setLoading(false); } - }, [project, currentPath, isGoogle, addLog, showMessage, electron]); + }, [project, currentPath, isGoogle, isEmulator, addLog, showMessage, electron]); useEffect(() => { if (project) loadFiles(); @@ -177,7 +194,7 @@ function StorageTab({ project, addLog, showMessage }: StorageTabProps) { const openFirebaseConsole = () => { if (!project) return; - const url = `https://console.firebase.google.com/project/${project.projectId}/storage`; + const url = getConsoleUrl(project, 'storage'); electronService.openExternal(url); }; diff --git a/src/shared/utils/electronMock.ts b/src/shared/utils/electronMock.ts index 1d01f85..7557081 100644 --- a/src/shared/utils/electronMock.ts +++ b/src/shared/utils/electronMock.ts @@ -55,6 +55,16 @@ const mockElectronAPI: ElectronAPI = { return { success: false, error: 'Requires Electron mode' }; }, + // Emulators + scanEmulatorsHub: async () => { + console.warn('[Mock] scanEmulatorsHub requires Electron'); + return { success: false, error: 'Requires Electron mode' }; + }, + scanEmulatorsConfig: async () => { + console.warn('[Mock] scanEmulatorsConfig requires Electron'); + return { success: false, error: 'Requires Electron mode' }; + }, + // Dialog openFileDialog: async () => { console.warn('[Mock] File dialog requires Electron'); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e4e4588..cbbef32 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -159,8 +159,12 @@ interface GoogleSignInResult { } interface ConnectFirebaseParams { - serviceAccountPath: string; + serviceAccountPath?: string; databaseId?: string; + emulatorHost?: string; + projectId?: string; + authEmulatorHost?: string; + storageEmulatorHost?: string; } interface ConnectFirebaseResult { @@ -177,11 +181,39 @@ interface GoogleSetRefreshTokenResult { error?: string; } +interface EmulatorServiceInfo { + host: string; + port: number; +} + +interface EmulatorHubCandidate { + projectId: string; + host: string; + port: number; + services: Record; +} + +interface EmulatorHubResult { + success: boolean; + emulators?: EmulatorHubCandidate[]; + error?: string; +} + +interface EmulatorConfigResult { + success: boolean; + activeProjects?: Record; + error?: string; +} + interface ElectronAPI { // Firebase connectFirebase: (params: ConnectFirebaseParams) => Promise; disconnectFirebase: () => Promise<{ success: boolean }>; + // Emulators + scanEmulatorsHub: () => Promise; + scanEmulatorsConfig: () => Promise; + // Firestore getCollections: () => Promise<{ success: boolean; collections?: FirestoreCollection[]; error?: string }>; getDocuments: (