diff --git a/apps/electron-app/src/main/browser/dialog-manager.ts b/apps/electron-app/src/main/browser/dialog-manager.ts new file mode 100644 index 0000000..551e00c --- /dev/null +++ b/apps/electron-app/src/main/browser/dialog-manager.ts @@ -0,0 +1,1064 @@ +/** + * Dialog Manager for native Electron dialogs + * Manages downloads and settings dialogs as child windows + * PRUNED VERSION: Chrome extraction moved to ChromeDataExtractionService + */ + +import { BaseWindow, BrowserWindow, ipcMain, WebContentsView } from "electron"; +import { EventEmitter } from "events"; +import path from "path"; +import { createLogger } from "@vibe/shared-types"; +import { chromeDataExtraction } from "@/services/chrome-data-extraction"; +import { DEFAULT_USER_AGENT } from "../constants/user-agent"; + +const logger = createLogger("dialog-manager"); + +interface DialogOptions { + width: number; + height: number; + title: string; + resizable?: boolean; + minimizable?: boolean; + maximizable?: boolean; +} + +export class DialogManager extends EventEmitter { + private static instances: Map = new Map(); + private static ipcHandlersRegistered = false; + + private parentWindow: BrowserWindow; + private activeDialogs: Map = new Map(); + private pendingOperations: Map> = new Map(); + private loadingTimeouts: Map = new Map(); + + constructor(parentWindow: BrowserWindow) { + super(); + this.parentWindow = parentWindow; + + // Register this instance + DialogManager.instances.set(parentWindow.id, this); + + // Register IPC handlers only once, globally + if (!DialogManager.ipcHandlersRegistered) { + DialogManager.registerGlobalHandlers(); + DialogManager.ipcHandlersRegistered = true; + logger.info("DialogManager IPC handlers registered"); + } + } + + // NOTE: File path validation removed - add back when needed for actual file operations + + private static getManagerForWindow( + webContents: Electron.WebContents, + ): DialogManager | undefined { + const window = BrowserWindow.fromWebContents(webContents); + if (!window) return undefined; + + // First, check if this window has a DialogManager + let manager = DialogManager.instances.get(window.id); + if (manager) return manager; + + // If not, check if this is a dialog window by looking for its parent + const parent = window.getParentWindow(); + if (parent) { + manager = DialogManager.instances.get(parent.id); + if (manager) return manager; + } + + // As a fallback, return the first available DialogManager (usually from main window) + if (DialogManager.instances.size > 0) { + return DialogManager.instances.values().next().value; + } + + return undefined; + } + + private static registerGlobalHandlers(): void { + logger.info("Setting up DialogManager IPC handlers"); + + // Dialog management handlers + ipcMain.handle("dialog:show-downloads", async event => { + const manager = DialogManager.getManagerForWindow(event.sender); + if (!manager) + return { success: false, error: "No dialog manager for window" }; + return manager.showDownloadsDialog(); + }); + + ipcMain.handle("dialog:show-settings", async event => { + const manager = DialogManager.getManagerForWindow(event.sender); + if (!manager) + return { success: false, error: "No dialog manager for window" }; + return manager.showSettingsDialog(); + }); + + ipcMain.handle("dialog:close", async (event, dialogType: string) => { + logger.info(`IPC handler: dialog:close called for ${dialogType}`); + const manager = DialogManager.getManagerForWindow(event.sender); + if (!manager) + return { success: false, error: "No dialog manager for window" }; + return manager.closeDialog(dialogType); + }); + + ipcMain.handle("dialog:force-close", async (event, dialogType: string) => { + logger.info(`IPC handler: dialog:force-close called for ${dialogType}`); + const manager = DialogManager.getManagerForWindow(event.sender); + if (!manager) + return { success: false, error: "No dialog manager for window" }; + return manager.forceCloseDialog(dialogType); + }); + + // REFACTORED: Chrome data extraction handlers now use ChromeDataExtractionService + ipcMain.handle("password:extract-chrome", async () => { + return chromeDataExtraction.extractPasswords(); + }); + + ipcMain.handle( + "passwords:import-chrome", + async (event, windowId?: number) => { + try { + logger.info("passwords:import-chrome IPC handler called"); + + // Get the window for progress bar + let targetWindow: BrowserWindow | null = null; + if (windowId) { + targetWindow = BrowserWindow.fromId(windowId); + } + + // Find main window if not provided + if (!targetWindow) { + const allWindows = BrowserWindow.getAllWindows(); + for (const win of allWindows) { + if (!win.getParentWindow()) { + targetWindow = win; + break; + } + } + } + + // Set initial progress + if (targetWindow && !targetWindow.isDestroyed()) { + targetWindow.setProgressBar(0.1); + } + + const result = await chromeDataExtraction.extractPasswords( + undefined, + progress => { + if (targetWindow && !targetWindow.isDestroyed()) { + targetWindow.setProgressBar(progress / 100); + } + + // Send progress to renderer + if (!event.sender.isDestroyed()) { + event.sender.send("chrome-import-progress", { + progress, + message: "Extracting Chrome passwords...", + }); + } + }, + ); + + logger.info("Chrome extraction result:", result); + + if (!result.success) { + logger.warn("Chrome extraction failed:", result.error); + return result; + } + + const { useUserProfileStore } = await import( + "@/store/user-profile-store" + ); + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + logger.warn("No active profile found"); + return { success: false, error: "No active profile" }; + } + + if (result.data && result.data.length > 0) { + logger.info( + `Storing ${result.data.length} passwords for profile ${activeProfile.id}`, + ); + await userProfileStore.storeImportedPasswords( + activeProfile.id, + "chrome", + result.data, + ); + } + + logger.info( + `Chrome import completed successfully with ${result.data?.length || 0} passwords`, + ); + + // Clear progress bar + if (targetWindow && !targetWindow.isDestroyed()) { + targetWindow.setProgressBar(-1); + } + + return { success: true, count: result.data?.length || 0 }; + } catch (error) { + logger.error("Failed to import Chrome passwords:", error); + + // Clear progress bar on error + if (windowId) { + const targetWindow = BrowserWindow.fromId(windowId); + if (targetWindow && !targetWindow.isDestroyed()) { + targetWindow.setProgressBar(-1); + } + } + + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); + + ipcMain.handle("passwords:import-safari", async () => { + // Safari import not implemented for security reasons + return { + success: false, + error: "Safari import not supported for security reasons", + }; + }); + + ipcMain.handle( + "passwords:import-csv", + async (_event, { filename, content }) => { + try { + const { useUserProfileStore } = await import( + "@/store/user-profile-store" + ); + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, error: "No active profile" }; + } + + // Parse CSV content (basic implementation) + const lines = content.split("\n").filter(line => line.trim()); + const headers = lines[0].split(",").map(h => h.trim().toLowerCase()); + + const urlIndex = headers.findIndex( + h => h.includes("url") || h.includes("site"), + ); + const usernameIndex = headers.findIndex( + h => + h.includes("username") || + h.includes("email") || + h.includes("user"), + ); + const passwordIndex = headers.findIndex( + h => h.includes("password") || h.includes("pass"), + ); + + if (urlIndex === -1 || usernameIndex === -1 || passwordIndex === -1) { + return { + success: false, + error: "CSV must contain URL, Username, and Password columns", + }; + } + + const passwords = lines + .slice(1) + .map((line, index) => { + const columns = line + .split(",") + .map(c => c.trim().replace(/^"|"$/g, "")); + return { + id: `csv_${filename}_${index}`, + url: columns[urlIndex] || "", + username: columns[usernameIndex] || "", + password: columns[passwordIndex] || "", + source: "csv" as const, + dateCreated: new Date(), + lastModified: new Date(), + }; + }) + .filter(p => p.url && p.username && p.password); + + if (passwords.length > 0) { + await userProfileStore.storeImportedPasswords( + activeProfile.id, + `csv_${filename}`, + passwords, + ); + } + + return { success: true, count: passwords.length }; + } catch (error) { + logger.error("Failed to import CSV passwords:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); + + // REFACTORED: Chrome comprehensive import handlers using ChromeDataExtractionService + ipcMain.handle("chrome:import-comprehensive", async () => { + try { + logger.info("Starting comprehensive Chrome profile import"); + const result = await chromeDataExtraction.extractAllData(); + + if (!result.success) { + logger.warn("Chrome comprehensive extraction failed:", result.error); + return result; + } + + const { useUserProfileStore } = await import( + "@/store/user-profile-store" + ); + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + logger.warn("No active profile found"); + return { success: false, error: "No active profile" }; + } + + const data = result.data; + let totalSaved = 0; + + // Save passwords if extracted + if (data?.passwords && data.passwords.length > 0) { + logger.info( + `Storing ${data.passwords.length} passwords for profile ${activeProfile.id}`, + ); + await userProfileStore.storeImportedPasswords( + activeProfile.id, + "chrome", + data.passwords, + ); + totalSaved += data.passwords.length; + } + + // Save bookmarks if extracted + if (data?.bookmarks && data.bookmarks.length > 0) { + logger.info( + `Storing ${data.bookmarks.length} bookmarks for profile ${activeProfile.id}`, + ); + await userProfileStore.storeImportedBookmarks( + activeProfile.id, + "chrome", + data.bookmarks, + ); + totalSaved += data.bookmarks.length; + } + + // Save history if extracted + if (data?.history && data.history.length > 0) { + logger.info( + `Storing ${data.history.length} history entries for profile ${activeProfile.id}`, + ); + await userProfileStore.storeImportedHistory( + activeProfile.id, + "chrome", + data.history, + ); + totalSaved += data.history.length; + } + + logger.info( + `Comprehensive Chrome import completed successfully with ${totalSaved} total items saved`, + ); + return { + success: true, + data: { + ...data, + totalSaved, + }, + passwordCount: data?.passwords?.length || 0, + bookmarkCount: data?.bookmarks?.length || 0, + historyCount: data?.history?.length || 0, + autofillCount: data?.autofill?.length || 0, + searchEngineCount: data?.searchEngines?.length || 0, + }; + } catch (error) { + logger.error("Comprehensive Chrome import failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + // REFACTORED: Individual Chrome import handlers using ChromeDataExtractionService + ipcMain.handle("chrome:import-bookmarks", async () => { + try { + logger.info("Starting Chrome bookmarks import with progress"); + return await chromeDataExtraction.extractBookmarks(); + } catch (error) { + logger.error("Chrome bookmarks import failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipcMain.handle("chrome:import-history", async () => { + try { + logger.info("Starting Chrome history import with progress"); + return await chromeDataExtraction.extractHistory(); + } catch (error) { + logger.error("Chrome history import failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipcMain.handle("chrome:import-autofill", async () => { + try { + logger.info("Starting Chrome autofill import with progress"); + // TODO: Implement in ChromeDataExtractionService + return { + success: false, + error: "Autofill extraction not implemented yet", + }; + } catch (error) { + logger.error("Chrome autofill import failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipcMain.handle("chrome:import-search-engines", async () => { + try { + logger.info("Starting Chrome search engines import"); + // TODO: Implement in ChromeDataExtractionService + return { + success: false, + error: "Search engines extraction not implemented yet", + }; + } catch (error) { + logger.error("Chrome search engines import failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipcMain.handle( + "chrome:import-all-profiles", + async (event, windowId?: number) => { + // Get the window to show progress on + let targetWindow: BrowserWindow | null = null; + + try { + logger.info("Starting Chrome all profiles import"); + + if (windowId) { + targetWindow = BrowserWindow.fromId(windowId); + } + + // Get all windows + const allWindows = BrowserWindow.getAllWindows(); + + // Find the main window (not the settings dialog) + if (!targetWindow && allWindows.length > 0) { + // Find the largest window that is not a modal/child window + for (const win of allWindows) { + // Skip child windows (like the settings dialog) + if (!win.getParentWindow()) { + // This is a top-level window, likely the main window + targetWindow = win; + break; + } + } + + // Fallback to largest window if no parent-less window found + if (!targetWindow) { + targetWindow = allWindows.reduce((largest, current) => { + const largestSize = largest.getBounds(); + const currentSize = current.getBounds(); + return currentSize.width * currentSize.height > + largestSize.width * largestSize.height + ? current + : largest; + }); + } + } + + logger.info( + `Target window for progress: ${targetWindow?.id || "none"}, title: ${targetWindow?.getTitle() || "N/A"}`, + ); + + const profiles = await chromeDataExtraction.getChromeProfiles(); + if (!profiles || profiles.length === 0) { + return { success: false, error: "No Chrome profiles found" }; + } + + logger.info(`Found ${profiles.length} Chrome profiles`); + + // Import data from all profiles + const allData = { + passwords: [] as any[], + bookmarks: [] as any[], + history: [] as any[], + autofill: [] as any[], + searchEngines: [] as any[], + }; + + let totalProgress = 0; + const progressPerProfile = 100 / profiles.length; + + for (let i = 0; i < profiles.length; i++) { + const profile = profiles[i]; + logger.info( + `Processing profile ${i + 1}/${profiles.length}: ${profile.name}`, + ); + + // Send progress update + if (targetWindow && !targetWindow.isDestroyed()) { + const progressValue = + (totalProgress + progressPerProfile * 0.1) / 100; + logger.info( + `Setting progress bar to ${progressValue} on main window ${targetWindow.id}`, + ); + targetWindow.setProgressBar(progressValue); + } else { + logger.warn("No target window for progress bar"); + } + + // Always send progress to the Settings dialog + if (!event.sender.isDestroyed()) { + event.sender.send("chrome-import-progress", { + progress: totalProgress + progressPerProfile * 0.1, + message: `Processing profile: ${profile.name}`, + }); + } + + const profileResult = await chromeDataExtraction.extractAllData( + profile, + progress => { + if (targetWindow && !targetWindow.isDestroyed()) { + const overallProgress = + totalProgress + progress * progressPerProfile; + targetWindow.setProgressBar(overallProgress / 100); + } + + // Always send progress to the Settings dialog + if (!event.sender.isDestroyed()) { + const overallProgress = + totalProgress + progress * progressPerProfile; + event.sender.send("chrome-import-progress", { + progress: overallProgress, + message: `Extracting data from ${profile.name}...`, + }); + } + }, + ); + + if (profileResult.success && profileResult.data) { + // Aggregate data from all profiles + allData.passwords.push(...(profileResult.data.passwords || [])); + allData.bookmarks.push(...(profileResult.data.bookmarks || [])); + allData.history.push(...(profileResult.data.history || [])); + allData.autofill.push(...(profileResult.data.autofill || [])); + allData.searchEngines.push( + ...(profileResult.data.searchEngines || []), + ); + } + + totalProgress += progressPerProfile; + } + + // Save all imported data + const userProfileStore = await import( + "@/store/user-profile-store" + ).then(m => m.useUserProfileStore.getState()); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, error: "No active user profile" }; + } + + let totalSaved = 0; + + // Save passwords + if (allData.passwords.length > 0) { + await userProfileStore.storeImportedPasswords( + activeProfile.id, + "chrome-all-profiles", + allData.passwords, + ); + totalSaved += allData.passwords.length; + } + + // Save bookmarks + if (allData.bookmarks.length > 0) { + await userProfileStore.storeImportedBookmarks( + activeProfile.id, + "chrome-all-profiles", + allData.bookmarks, + ); + totalSaved += allData.bookmarks.length; + } + + logger.info( + `All Chrome profiles import completed successfully with ${totalSaved} total items saved`, + ); + + // Clear progress bar + if (targetWindow && !targetWindow.isDestroyed()) { + targetWindow.setProgressBar(-1); // -1 removes the progress bar + logger.info("Progress bar cleared on main window"); + } + + return { + success: true, + data: allData, + passwordCount: allData.passwords.length, + bookmarkCount: allData.bookmarks.length, + historyCount: allData.history.length, + autofillCount: allData.autofill.length, + searchEngineCount: allData.searchEngines.length, + totalSaved, + }; + } catch (error) { + logger.error("Chrome all profiles import failed:", error); + + // Clear progress bar on error + if (targetWindow && !targetWindow.isDestroyed()) { + targetWindow.setProgressBar(-1); + logger.info("Progress bar cleared due to error"); + } + + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + ); + } + + private async loadContentWithTimeout( + webContents: Electron.WebContents, + url: string, + dialogType: string, + timeout: number = 10000, + ): Promise { + return new Promise((resolve, reject) => { + if (webContents.isDestroyed()) { + reject(new Error("WebContents destroyed before loading")); + return; + } + + const timeoutId = setTimeout(() => { + this.loadingTimeouts.delete(dialogType); + reject(new Error(`Loading timeout after ${timeout}ms`)); + }, timeout); + + this.loadingTimeouts.set(dialogType, timeoutId); + + webContents + .loadURL(url) + .then(() => { + clearTimeout(timeoutId); + this.loadingTimeouts.delete(dialogType); + resolve(); + }) + .catch(error => { + clearTimeout(timeoutId); + this.loadingTimeouts.delete(dialogType); + reject(error); + }); + }); + } + + private validateDialogState(dialog: BaseWindow, dialogType: string): boolean { + if (!dialog || dialog.isDestroyed()) { + logger.warn(`Dialog ${dialogType} is destroyed or invalid`); + this.activeDialogs.delete(dialogType); + return false; + } + return true; + } + + private createDialog(type: string, options: DialogOptions): BaseWindow { + const dialog = new BaseWindow({ + width: options.width, + height: options.height, + resizable: options.resizable ?? false, + minimizable: options.minimizable ?? false, + maximizable: options.maximizable ?? false, + movable: true, + show: false, + modal: false, // Enable moving by making it non-modal + parent: this.parentWindow, + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 16 }, + }); + + // Create WebContentsView for the dialog content + const preloadPath = path.join(__dirname, "../preload/index.js"); + logger.debug(`Creating dialog with preload path: ${preloadPath}`); + + const view = new WebContentsView({ + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + preload: preloadPath, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + + // Set browser user agent + view.webContents.setUserAgent(DEFAULT_USER_AGENT); + + // Set view bounds to fill the dialog + const updateViewBounds = () => { + const [width, height] = dialog.getContentSize(); + view.setBounds({ + x: 0, + y: 0, + width: width, + height: height, + }); + }; + + // Set initial bounds + updateViewBounds(); + dialog.setContentView(view); + + // Update bounds when window is resized + dialog.on("resize", () => { + updateViewBounds(); + }); + + // Position dialog as a side panel from the right edge + const parentBounds = this.parentWindow.getBounds(); + const x = parentBounds.x + parentBounds.width - options.width - 20; // 20px margin from right edge + const y = parentBounds.y + 40; // Top margin + dialog.setPosition(x, y); + + // Handle dialog lifecycle + dialog.on("closed", () => { + this.activeDialogs.delete(type); + this.emit("dialog-closed", type); + }); + + // Handle escape key after content is loaded + view.webContents.once("did-finish-load", () => { + logger.debug(`Dialog ${type} finished loading`); + + view.webContents.on("before-input-event", (_event, input) => { + if (input.key === "Escape" && input.type === "keyDown") { + logger.info(`Escape key pressed, closing dialog: ${type}`); + this.closeDialog(type); + } + }); + }); + + // Store view reference for content loading + (dialog as any).contentView = view; + + return dialog; + } + + public async showDownloadsDialog(): Promise { + // Check for existing dialog first + if (this.activeDialogs.has("downloads")) { + const existingDialog = this.activeDialogs.get("downloads"); + if ( + existingDialog && + this.validateDialogState(existingDialog, "downloads") + ) { + existingDialog.focus(); + return; + } + } + + // Prevent race conditions by checking for pending operations + if (this.pendingOperations.has("downloads")) { + logger.debug("Downloads dialog already being created, waiting..."); + return this.pendingOperations.get("downloads"); + } + + const operation = this.createDownloadsDialog(); + this.pendingOperations.set("downloads", operation); + + try { + await operation; + } finally { + this.pendingOperations.delete("downloads"); + } + } + + private async createDownloadsDialog(): Promise { + let dialog: BaseWindow | null = null; + let view: WebContentsView | null = null; + + try { + dialog = this.createDialog("downloads", { + width: 880, + height: 560, + title: "Downloads", + resizable: true, + }); + + view = (dialog as any).contentView as WebContentsView; + + // Validate WebContents before loading + if (!view || !view.webContents || view.webContents.isDestroyed()) { + throw new Error("Invalid WebContents for downloads dialog"); + } + + // Load the React downloads app instead of HTML template + let downloadsUrl: string; + if (process.env.NODE_ENV === "development") { + // In development, use the dev server + downloadsUrl = "http://localhost:5173/downloads.html"; + } else { + // In production, use the built files + downloadsUrl = `file://${path.join(__dirname, "../renderer/downloads.html")}`; + } + + await this.loadContentWithTimeout( + view.webContents, + downloadsUrl, + "downloads", + ); + + dialog.show(); + this.activeDialogs.set("downloads", dialog); + + logger.info("Downloads dialog opened successfully with React app"); + } catch (error) { + logger.error("Failed to create downloads dialog:", error); + + // Clean up on error + if (dialog && !dialog.isDestroyed()) { + try { + dialog.close(); + } catch (closeError) { + logger.error("Error closing failed downloads dialog:", closeError); + } + } + + // Clean up any pending timeouts + if (this.loadingTimeouts.has("downloads")) { + clearTimeout(this.loadingTimeouts.get("downloads")!); + this.loadingTimeouts.delete("downloads"); + } + + throw error; + } + } + + public async showSettingsDialog(): Promise { + // Check for existing dialog first + if (this.activeDialogs.has("settings")) { + const existingDialog = this.activeDialogs.get("settings"); + if ( + existingDialog && + this.validateDialogState(existingDialog, "settings") + ) { + existingDialog.focus(); + return; + } + } + + // Prevent race conditions by checking for pending operations + if (this.pendingOperations.has("settings")) { + logger.debug("Settings dialog already being created, waiting..."); + return this.pendingOperations.get("settings"); + } + + const operation = this.createSettingsDialog(); + this.pendingOperations.set("settings", operation); + + try { + await operation; + } finally { + this.pendingOperations.delete("settings"); + } + } + + private async createSettingsDialog(): Promise { + let dialog: BaseWindow | null = null; + let view: WebContentsView | null = null; + + try { + dialog = this.createDialog("settings", { + width: 800, + height: 600, + title: "Settings", + resizable: true, + maximizable: true, + }); + + view = (dialog as any).contentView as WebContentsView; + + // Validate WebContents before loading + if (!view || !view.webContents || view.webContents.isDestroyed()) { + throw new Error("Invalid WebContents for settings dialog"); + } + + // Load the React settings app instead of HTML template + let settingsUrl: string; + if (process.env.NODE_ENV === "development") { + // In development, use the dev server + settingsUrl = "http://localhost:5173/settings.html"; + } else { + // In production, use the built files + settingsUrl = `file://${path.join(__dirname, "../renderer/settings.html")}`; + } + + await this.loadContentWithTimeout( + view.webContents, + settingsUrl, + "settings", + ); + + dialog.show(); + this.activeDialogs.set("settings", dialog); + + logger.info("Settings dialog opened successfully with React app"); + } catch (error) { + logger.error("Failed to create settings dialog:", error); + + // Clean up on error + if (dialog && !dialog.isDestroyed()) { + try { + dialog.close(); + } catch (closeError) { + logger.error("Error closing failed settings dialog:", closeError); + } + } + + // Clean up any pending timeouts + if (this.loadingTimeouts.has("settings")) { + clearTimeout(this.loadingTimeouts.get("settings")!); + this.loadingTimeouts.delete("settings"); + } + + throw error; + } + } + + public closeDialog(_type: string): boolean { + logger.info(`Attempting to close dialog: ${_type}`); + try { + const dialog = this.activeDialogs.get(_type); + if (dialog && this.validateDialogState(dialog, _type)) { + logger.info(`Closing dialog window: ${_type}`); + dialog.close(); + return true; + } + + logger.warn(`Dialog ${_type} not found or invalid`); + + // Clean up tracking even if dialog is invalid + this.activeDialogs.delete(_type); + + // Clean up any pending timeouts + if (this.loadingTimeouts.has(_type)) { + clearTimeout(this.loadingTimeouts.get(_type)!); + this.loadingTimeouts.delete(_type); + } + + return false; + } catch (error) { + logger.error(`Error closing dialog ${_type}:`, error); + + // Force cleanup on error + this.activeDialogs.delete(_type); + if (this.loadingTimeouts.has(_type)) { + clearTimeout(this.loadingTimeouts.get(_type)!); + this.loadingTimeouts.delete(_type); + } + + return false; + } + } + + public forceCloseDialog(_type: string): boolean { + logger.info(`Force closing dialog: ${_type}`); + try { + const dialog = this.activeDialogs.get(_type); + if (dialog) { + if (!dialog.isDestroyed()) { + logger.info(`Force destroying dialog window: ${_type}`); + dialog.destroy(); + } + this.activeDialogs.delete(_type); + + // Clean up any pending timeouts + if (this.loadingTimeouts.has(_type)) { + clearTimeout(this.loadingTimeouts.get(_type)!); + this.loadingTimeouts.delete(_type); + } + + return true; + } + + logger.warn(`Dialog ${_type} not found for force close`); + return false; + } catch (error) { + logger.error(`Error force closing dialog ${_type}:`, error); + + // Force cleanup on error + this.activeDialogs.delete(_type); + if (this.loadingTimeouts.has(_type)) { + clearTimeout(this.loadingTimeouts.get(_type)!); + this.loadingTimeouts.delete(_type); + } + + return false; + } + } + + public closeAllDialogs(): void { + const dialogTypes = Array.from(this.activeDialogs.keys()); + + for (const dialogType of dialogTypes) { + try { + const dialog = this.activeDialogs.get(dialogType); + if (dialog && !dialog.isDestroyed()) { + dialog.close(); + } + } catch (error) { + logger.error(`Error closing dialog ${dialogType}:`, error); + } + } + + // Force cleanup of all state + this.activeDialogs.clear(); + + // Clean up all pending timeouts + for (const [dialogType, timeout] of this.loadingTimeouts.entries()) { + try { + clearTimeout(timeout); + } catch (error) { + logger.error(`Error clearing timeout for ${dialogType}:`, error); + } + } + this.loadingTimeouts.clear(); + } + + public destroy(): void { + // Close all dialogs + this.closeAllDialogs(); + + // Remove from instances map + DialogManager.instances.delete(this.parentWindow.id); + + // Remove all listeners + this.removeAllListeners(); + + logger.info("DialogManager destroyed"); + } +} diff --git a/apps/electron-app/src/main/browser/templates/settings-dialog.html b/apps/electron-app/src/main/browser/templates/settings-dialog.html new file mode 100644 index 0000000..b51904c --- /dev/null +++ b/apps/electron-app/src/main/browser/templates/settings-dialog.html @@ -0,0 +1,1724 @@ + + + + + + Settings + + + +
+
+

Settings

+ +
+ +
+ + +
+ + + +
+

API Keys

+

+ Manage your API keys for external services. Keys are stored + securely and encrypted. +

+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ + + +
+ +
+
+ + +
+

Password Management

+

+ Manage your imported passwords from browsers and other sources. + All passwords are stored securely and encrypted. +

+ +
+ +
+ + + +
+

+ Import passwords from other browsers or CSV files. Passwords are + encrypted before storage. +

+
+ +
+ + +
+ +
+ +
+
+ Loading passwords... +
+
+ No passwords stored. Import passwords from your browser or add + them manually. +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+
+ + +
+ +
+
+ + +
+

General Settings

+

Configure general application behavior and preferences.

+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ +
+
+ + +
+

Appearance

+

Customize the look and feel of the application.

+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+ + +
+

Notifications

+

+ Configure local and push notification settings, including Apple + Push Notification Service (APNS). +

+ + +
+ + +
+ +
+ + +
+ + +

+ Apple Push Notifications (APNS) +

+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ + +
+

+ Upload your APNS authentication key file (AuthKey_XXXXXXXXXX.p8) +

+
+ + +
+ +
+
Not configured
+ +
+
+ + +

+ Registered Devices +

+ +
+
+
+ No devices registered +
+
+
+ +
+
+ + +
+ +
+
+ + +
+

Privacy & Security

+

Manage your privacy and security preferences.

+ +
+ + +
+ +
+
+ +
+
+ + +
+

Advanced Settings

+

Advanced configuration options for power users.

+ +
+ + +
+ +
+
+ +
+ +
+
+
+
+
+ + +
+
+

Confirm Action

+

Are you sure you want to proceed?

+
+ + +
+
+
+ + + + diff --git a/apps/electron-app/src/main/hotkey-manager.ts b/apps/electron-app/src/main/hotkey-manager.ts new file mode 100644 index 0000000..a7e2259 --- /dev/null +++ b/apps/electron-app/src/main/hotkey-manager.ts @@ -0,0 +1,180 @@ +import { globalShortcut } from "electron"; +import { createLogger } from "@vibe/shared-types"; +import { useUserProfileStore } from "@/store/user-profile-store"; + +const logger = createLogger("hotkey-manager"); + +// Default hotkey for password paste +const DEFAULT_PASSWORD_PASTE_HOTKEY = "CommandOrControl+Shift+P"; + +// Currently registered hotkeys +const registeredHotkeys = new Map(); + +/** + * Register a global hotkey + */ +export function registerHotkey(hotkey: string, action: () => void): boolean { + try { + // Unregister existing hotkey if it exists + if (registeredHotkeys.has(hotkey)) { + globalShortcut.unregister(hotkey); + registeredHotkeys.delete(hotkey); + } + + // Register new hotkey + const success = globalShortcut.register(hotkey, action); + if (success) { + registeredHotkeys.set(hotkey, action.name); + logger.info(`Registered hotkey: ${hotkey}`); + } else { + logger.error(`Failed to register hotkey: ${hotkey}`); + } + return success; + } catch (error) { + logger.error(`Error registering hotkey ${hotkey}:`, error); + return false; + } +} + +/** + * Unregister a global hotkey + */ +export function unregisterHotkey(hotkey: string): boolean { + try { + globalShortcut.unregister(hotkey); + registeredHotkeys.delete(hotkey); + logger.info(`Unregistered hotkey: ${hotkey}`); + return true; + } catch (error) { + logger.error(`Error unregistering hotkey ${hotkey}:`, error); + return false; + } +} + +/** + * Get the current password paste hotkey from settings + */ +export function getPasswordPasteHotkey(): string { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + return ( + activeProfile?.settings?.hotkeys?.passwordPaste || + DEFAULT_PASSWORD_PASTE_HOTKEY + ); + } catch (error) { + logger.error("Failed to get password paste hotkey from settings:", error); + return DEFAULT_PASSWORD_PASTE_HOTKEY; + } +} + +/** + * Set the password paste hotkey in settings + */ +export function setPasswordPasteHotkey(hotkey: string): boolean { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + logger.error("No active profile found"); + return false; + } + + // Update profile settings + const updatedSettings = { + ...activeProfile.settings, + hotkeys: { + ...activeProfile.settings?.hotkeys, + passwordPaste: hotkey, + }, + }; + + userProfileStore.updateProfile(activeProfile.id, { + settings: updatedSettings, + }); + + logger.info(`Password paste hotkey updated to: ${hotkey}`); + return true; + } catch (error) { + logger.error("Failed to set password paste hotkey:", error); + return false; + } +} + +/** + * Initialize password paste hotkey + */ +export function initializePasswordPasteHotkey(): boolean { + try { + const hotkey = getPasswordPasteHotkey(); + + const action = async () => { + try { + // Import the password paste function directly + const { pastePasswordForActiveTab } = await import( + "./password-paste-handler" + ); + const result = await pastePasswordForActiveTab(); + if (result.success) { + logger.info("Password pasted successfully via hotkey"); + } else { + logger.warn("Failed to paste password via hotkey:", result.error); + } + } catch (error) { + logger.error("Error in password paste hotkey action:", error); + } + }; + + return registerHotkey(hotkey, action); + } catch (error) { + logger.error("Failed to initialize password paste hotkey:", error); + return false; + } +} + +/** + * Update password paste hotkey + */ +export function updatePasswordPasteHotkey(newHotkey: string): boolean { + try { + const oldHotkey = getPasswordPasteHotkey(); + + // Unregister old hotkey + unregisterHotkey(oldHotkey); + + // Set new hotkey in settings + const success = setPasswordPasteHotkey(newHotkey); + if (!success) { + return false; + } + + // Register new hotkey + return initializePasswordPasteHotkey(); + } catch (error) { + logger.error("Failed to update password paste hotkey:", error); + return false; + } +} + +/** + * Get all registered hotkeys + */ +export function getRegisteredHotkeys(): Map { + return new Map(registeredHotkeys); +} + +/** + * Cleanup all registered hotkeys + */ +export function cleanupHotkeys(): void { + try { + registeredHotkeys.forEach((_actionName, hotkey) => { + globalShortcut.unregister(hotkey); + logger.info(`Cleaned up hotkey: ${hotkey}`); + }); + registeredHotkeys.clear(); + } catch (error) { + logger.error("Error cleaning up hotkeys:", error); + } +} diff --git a/apps/electron-app/src/main/ipc/app/hotkey-control.ts b/apps/electron-app/src/main/ipc/app/hotkey-control.ts new file mode 100644 index 0000000..a11f6c4 --- /dev/null +++ b/apps/electron-app/src/main/ipc/app/hotkey-control.ts @@ -0,0 +1,43 @@ +import { ipcMain } from "electron"; +import { createLogger } from "@vibe/shared-types"; +import { + getPasswordPasteHotkey, + updatePasswordPasteHotkey, + getRegisteredHotkeys, +} from "@/hotkey-manager"; + +const logger = createLogger("hotkey-control"); + +/** + * Hotkey management IPC handlers + */ + +ipcMain.handle("hotkeys:get-password-paste", async () => { + try { + const hotkey = getPasswordPasteHotkey(); + return { success: true, hotkey }; + } catch (error) { + logger.error("Failed to get password paste hotkey:", error); + return { success: false, error: "Failed to get hotkey" }; + } +}); + +ipcMain.handle("hotkeys:set-password-paste", async (_event, hotkey: string) => { + try { + const success = updatePasswordPasteHotkey(hotkey); + return { success }; + } catch (error) { + logger.error("Failed to set password paste hotkey:", error); + return { success: false, error: "Failed to set hotkey" }; + } +}); + +ipcMain.handle("hotkeys:get-registered", async () => { + try { + const hotkeys = getRegisteredHotkeys(); + return { success: true, hotkeys: Object.fromEntries(hotkeys) }; + } catch (error) { + logger.error("Failed to get registered hotkeys:", error); + return { success: false, error: "Failed to get hotkeys" }; + } +}); diff --git a/apps/electron-app/src/main/ipc/app/modals.ts b/apps/electron-app/src/main/ipc/app/modals.ts new file mode 100644 index 0000000..382a3fc --- /dev/null +++ b/apps/electron-app/src/main/ipc/app/modals.ts @@ -0,0 +1,30 @@ +/** + * Modal IPC handlers + * Handles modal-related IPC events that are not handled by DialogManager + */ + +import { ipcMain } from "electron"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("ipc-modals"); + +// Note: dialog:show-settings is handled by DialogManager directly +// We only handle the modal closed events here + +// Handle settings modal closed event +ipcMain.on("app:settings-modal-closed", () => { + logger.debug("Settings modal closed"); + + // Optional: You could add any cleanup logic here + // For now, just acknowledge the event +}); + +// Handle downloads modal closed event +ipcMain.on("app:downloads-modal-closed", () => { + logger.debug("Downloads modal closed"); + + // Optional: You could add any cleanup logic here + // For now, just acknowledge the event +}); + +logger.info("Modal IPC handlers registered"); diff --git a/apps/electron-app/src/main/ipc/app/password-paste.ts b/apps/electron-app/src/main/ipc/app/password-paste.ts new file mode 100644 index 0000000..e7e80bb --- /dev/null +++ b/apps/electron-app/src/main/ipc/app/password-paste.ts @@ -0,0 +1,17 @@ +import { ipcMain } from "electron"; +import { + pastePasswordForDomain, + pastePasswordForActiveTab, +} from "@/password-paste-handler"; + +/** + * Password paste IPC handlers + */ + +ipcMain.handle("password:paste-for-domain", async (_event, domain: string) => { + return await pastePasswordForDomain(domain); +}); + +ipcMain.handle("password:paste-for-active-tab", async _event => { + return await pastePasswordForActiveTab(); +}); diff --git a/apps/electron-app/src/main/ipc/browser/password-autofill.ts b/apps/electron-app/src/main/ipc/browser/password-autofill.ts new file mode 100644 index 0000000..7fcb946 --- /dev/null +++ b/apps/electron-app/src/main/ipc/browser/password-autofill.ts @@ -0,0 +1,222 @@ +/** + * Password autofill IPC handlers for web content integration + * Handles finding and filling passwords for input fields + */ + +import { ipcMain } from "electron"; +import { useUserProfileStore } from "@/store/user-profile-store"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("password-autofill"); + +export function registerPasswordAutofillHandlers(): void { + /** + * Find the most recent password for a domain and fill form fields + */ + ipcMain.handle( + "autofill:find-and-fill-password", + async (_event, pageUrl: string) => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + logger.warn("No active profile found for password autofill"); + return { success: false, error: "No active profile" }; + } + + // Extract domain from page URL + let domain: string; + try { + const url = new URL(pageUrl); + domain = url.hostname; + } catch { + logger.error("Invalid page URL for autofill:", pageUrl); + return { success: false, error: "Invalid page URL" }; + } + + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + + // Fuzzy match domain against stored URLs + const normalizedDomain = domain.toLowerCase().replace(/^www\./, ""); + + const matchingPasswords = passwords.filter(p => { + try { + const url = new URL(p.url); + const passwordDomain = url.hostname + .toLowerCase() + .replace(/^www\./, ""); + + // Exact domain match or subdomain match + return ( + passwordDomain === normalizedDomain || + passwordDomain.endsWith("." + normalizedDomain) || + normalizedDomain.endsWith("." + passwordDomain) + ); + } catch { + // If URL parsing fails, try simple string matching + return p.url.toLowerCase().includes(normalizedDomain); + } + }); + + // Sort by most recently modified first + matchingPasswords.sort((a, b) => { + const dateA = a.lastModified || a.dateCreated || new Date(0); + const dateB = b.lastModified || b.dateCreated || new Date(0); + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + + const mostRecentPassword = matchingPasswords[0]; + + if (!mostRecentPassword) { + logger.info(`No password found for domain: ${domain}`); + return { success: false, error: "No password found for this domain" }; + } + + logger.info(`Found password for autofill on ${domain}:`, { + url: mostRecentPassword.url, + username: mostRecentPassword.username, + source: mostRecentPassword.source, + }); + + // Return the password data for filling + return { + success: true, + credentials: { + username: mostRecentPassword.username, + password: mostRecentPassword.password, + url: mostRecentPassword.url, + source: mostRecentPassword.source, + }, + }; + } catch (error) { + logger.error("Failed to find password for autofill:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + ); + + /** + * Execute password autofill on the current page + */ + ipcMain.handle( + "autofill:execute-fill", + async ( + _event, + webContentsId: number, + credentials: { username: string; password: string }, + ) => { + try { + const { webContents } = await import("electron"); + const targetWebContents = webContents.fromId(webContentsId); + + if (!targetWebContents || targetWebContents.isDestroyed()) { + return { success: false, error: "WebContents not found" }; + } + + // JavaScript code to fill form fields with fuzzy matching + const fillScript = ` + (() => { + try { + const username = ${JSON.stringify(credentials.username)}; + const password = ${JSON.stringify(credentials.password)}; + + // Common username field selectors (fuzzy matching) + const usernameSelectors = [ + 'input[type="text"][name*="user"]', + 'input[type="text"][name*="email"]', + 'input[type="text"][name*="login"]', + 'input[type="email"]', + 'input[name="username"]', + 'input[name="email"]', + 'input[name="user"]', + 'input[name="login"]', + 'input[id*="user"]', + 'input[id*="email"]', + 'input[id*="login"]', + 'input[placeholder*="username" i]', + 'input[placeholder*="email" i]', + 'input[placeholder*="user" i]', + 'input[class*="user"]', + 'input[class*="email"]', + 'input[class*="login"]' + ]; + + // Common password field selectors + const passwordSelectors = [ + 'input[type="password"]', + 'input[name="password"]', + 'input[name="pass"]', + 'input[id*="password"]', + 'input[id*="pass"]', + 'input[placeholder*="password" i]', + 'input[class*="password"]', + 'input[class*="pass"]' + ]; + + let filled = { username: false, password: false }; + + // Find and fill username field + for (const selector of usernameSelectors) { + const usernameField = document.querySelector(selector); + if (usernameField) { + usernameField.value = username; + usernameField.dispatchEvent(new Event('input', { bubbles: true })); + usernameField.dispatchEvent(new Event('change', { bubbles: true })); + filled.username = true; + break; + } + } + + // Find and fill password field + for (const selector of passwordSelectors) { + const passwordField = document.querySelector(selector); + if (passwordField) { + passwordField.value = password; + passwordField.dispatchEvent(new Event('input', { bubbles: true })); + passwordField.dispatchEvent(new Event('change', { bubbles: true })); + filled.password = true; + break; + } + } + + return filled; + } catch (error) { + return { error: error.message }; + } + })(); + `; + + const result = await targetWebContents.executeJavaScript(fillScript); + + if (result.error) { + logger.error( + "JavaScript execution error during autofill:", + result.error, + ); + return { success: false, error: result.error }; + } + + logger.info("Password autofill executed:", result); + return { + success: true, + filled: result, + message: `Filled ${result.username ? "username" : "no username"} and ${result.password ? "password" : "no password"}`, + }; + } catch (error) { + logger.error("Failed to execute password autofill:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + ); + + logger.info("Password autofill handlers registered"); +} diff --git a/apps/electron-app/src/main/ipc/settings/password-handlers.ts b/apps/electron-app/src/main/ipc/settings/password-handlers.ts new file mode 100644 index 0000000..39186ba --- /dev/null +++ b/apps/electron-app/src/main/ipc/settings/password-handlers.ts @@ -0,0 +1,362 @@ +/** + * Password management IPC handlers for settings dialog + * Maps settings dialog expectations to profile store functionality + */ + +import { ipcMain } from "electron"; +import { useUserProfileStore } from "@/store/user-profile-store"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("password-handlers"); + +export function registerPasswordHandlers(): void { + /** + * Get all passwords for the active profile + */ + ipcMain.handle("passwords:get-all", async () => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, passwords: [] }; + } + + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + + return { + success: true, + passwords: passwords.map(p => ({ + id: p.id, + url: p.url, + username: p.username, + password: "••••••••", // Never send actual passwords to renderer + source: p.source || "manual", + dateCreated: p.dateCreated, + lastModified: p.lastModified, + })), + }; + } catch (error) { + logger.error("Failed to get passwords:", error); + return { success: false, passwords: [] }; + } + }); + + /** + * Get password import sources + */ + ipcMain.handle("passwords:get-sources", async () => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, sources: [] }; + } + + const sources = await userProfileStore.getPasswordImportSources( + activeProfile.id, + ); + return { success: true, sources }; + } catch (error) { + logger.error("Failed to get password sources:", error); + return { success: false, sources: [] }; + } + }); + + /** + * Find the most recent password for a domain (fuzzy matching) + */ + ipcMain.handle( + "passwords:find-for-domain", + async (_event, domain: string) => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, password: null }; + } + + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + + // Fuzzy match domain against stored URLs + const normalizedDomain = domain.toLowerCase().replace(/^www\./, ""); + + const matchingPasswords = passwords.filter(p => { + try { + const url = new URL(p.url); + const passwordDomain = url.hostname + .toLowerCase() + .replace(/^www\./, ""); + + // Exact domain match or subdomain match + return ( + passwordDomain === normalizedDomain || + passwordDomain.endsWith("." + normalizedDomain) || + normalizedDomain.endsWith("." + passwordDomain) + ); + } catch { + // If URL parsing fails, try simple string matching + return p.url.toLowerCase().includes(normalizedDomain); + } + }); + + // Sort by most recently modified first + matchingPasswords.sort((a, b) => { + const dateA = a.lastModified || a.dateCreated || new Date(0); + const dateB = b.lastModified || b.dateCreated || new Date(0); + return new Date(dateB).getTime() - new Date(dateA).getTime(); + }); + + const mostRecentPassword = matchingPasswords[0]; + + if (mostRecentPassword) { + logger.info(`Found password for domain ${domain}:`, { + url: mostRecentPassword.url, + username: mostRecentPassword.username, + source: mostRecentPassword.source, + }); + + return { + success: true, + password: { + id: mostRecentPassword.id, + url: mostRecentPassword.url, + username: mostRecentPassword.username, + password: "••••••••", // Never send actual passwords to renderer + source: mostRecentPassword.source, + }, + }; + } + + return { success: false, password: null }; + } catch (error) { + logger.error("Failed to find password for domain:", error); + return { success: false, password: null }; + } + }, + ); + + /** + * Decrypt a password - requires additional security verification + */ + ipcMain.handle("passwords:decrypt", async (event, passwordId: string) => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, error: "No active profile" }; + } + + // Verify the request is coming from a trusted source + const webContents = event.sender; + const url = webContents.getURL(); + + // Only allow decryption from the main app window + if (!url.startsWith("file://") && !url.includes("localhost")) { + logger.error( + "Password decryption attempted from untrusted source:", + url, + ); + return { success: false, error: "Unauthorized request" }; + } + + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + const password = passwords.find(p => p.id === passwordId); + + if (!password) { + return { success: false, error: "Password not found" }; + } + + // Log password access for security auditing + logger.info(`Password accessed for ${password.url} by user action`); + + return { + success: true, + decryptedPassword: password.password, + }; + } catch (error) { + logger.error("Failed to decrypt password:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + /** + * Delete a specific password + */ + ipcMain.handle("passwords:delete", async (_event, passwordId: string) => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, error: "No active profile" }; + } + + // Get all passwords and filter out the one to delete + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + const filteredPasswords = passwords.filter(p => p.id !== passwordId); + + // Clear all and re-store the filtered list + // Note: This is a workaround until proper delete method is implemented + await userProfileStore.storeImportedPasswords( + activeProfile.id, + "filtered", + filteredPasswords, + ); + + return { success: true }; + } catch (error) { + logger.error("Failed to delete password:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + /** + * Clear all passwords + */ + ipcMain.handle("passwords:clear-all", async () => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, error: "No active profile" }; + } + + // Clear all passwords by storing an empty array + // Note: This is a workaround until proper clear method is implemented + await userProfileStore.storeImportedPasswords( + activeProfile.id, + "cleared", + [], + ); + return { success: true }; + } catch (error) { + logger.error("Failed to clear all passwords:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + /** + * Remove passwords from a specific source + */ + ipcMain.handle("passwords:remove-source", async (_event, source: string) => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, error: "No active profile" }; + } + + // Get all passwords and filter out ones from the specified source + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + const filteredPasswords = passwords.filter(p => p.source !== source); + + // Clear all and re-store the filtered list + // Note: This is a workaround until proper removeBySource method is implemented + await userProfileStore.storeImportedPasswords( + activeProfile.id, + "filtered", + filteredPasswords, + ); + + return { success: true }; + } catch (error) { + logger.error("Failed to remove password source:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + /** + * Export passwords to CSV + */ + ipcMain.handle("passwords:export", async () => { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + return { success: false, error: "No active profile" }; + } + + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + + // Create CSV content + const csvHeader = + "url,username,password,source,date_created,last_modified\\n"; + const csvRows = passwords + .map(p => { + const url = p.url.replace(/"/g, '""'); + const username = p.username.replace(/"/g, '""'); + const password = "••••••••"; // Never export actual passwords in plain text + const source = (p.source || "manual").replace(/"/g, '""'); + const dateCreated = p.dateCreated + ? new Date(p.dateCreated).toISOString() + : ""; + const lastModified = p.lastModified + ? new Date(p.lastModified).toISOString() + : ""; + + return `"${url}","${username}","${password}","${source}","${dateCreated}","${lastModified}"`; + }) + .join("\\n"); + + const csvContent = csvHeader + csvRows; + + // Use Electron's dialog to save file + const { dialog } = await import("electron"); + const { filePath } = await dialog.showSaveDialog({ + defaultPath: `passwords_export_${new Date().toISOString().split("T")[0]}.csv`, + filters: [ + { name: "CSV Files", extensions: ["csv"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + + if (filePath) { + const { writeFileSync } = await import("fs"); + writeFileSync(filePath, csvContent, "utf8"); + return { success: true }; + } + + return { success: false, error: "Export cancelled" }; + } catch (error) { + logger.error("Failed to export passwords:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }); + + // Note: passwords:import-chrome is already handled by DialogManager + // which has the actual Chrome extraction logic +} diff --git a/apps/electron-app/src/main/password-paste-handler.ts b/apps/electron-app/src/main/password-paste-handler.ts new file mode 100644 index 0000000..4453840 --- /dev/null +++ b/apps/electron-app/src/main/password-paste-handler.ts @@ -0,0 +1,159 @@ +import { clipboard } from "electron"; +import { createLogger } from "@vibe/shared-types"; +import { useUserProfileStore } from "@/store/user-profile-store"; + +const logger = createLogger("password-paste-handler"); + +/** + * Paste password for a specific domain + */ +export async function pastePasswordForDomain(domain: string) { + try { + const userProfileStore = useUserProfileStore.getState(); + const activeProfile = userProfileStore.getActiveProfile(); + + if (!activeProfile) { + logger.error("No active profile found"); + return { success: false, error: "No active profile" }; + } + + // Get all passwords for the active profile + const passwords = await userProfileStore.getImportedPasswords( + activeProfile.id, + ); + + if (!passwords || passwords.length === 0) { + logger.info("No passwords found for profile"); + return { success: false, error: "No passwords found" }; + } + + // Find matching passwords for the domain + const normalizedDomain = domain.toLowerCase().replace(/^www\./, ""); + const matchingPasswords = passwords.filter(p => { + try { + const url = new URL(p.url); + const passwordDomain = url.hostname.toLowerCase().replace(/^www\./, ""); + + return ( + passwordDomain === normalizedDomain || + passwordDomain.endsWith("." + normalizedDomain) || + normalizedDomain.endsWith("." + passwordDomain) + ); + } catch { + return false; + } + }); + + if (matchingPasswords.length === 0) { + logger.info(`No passwords found for domain: ${domain}`); + return { success: false, error: "No password found for this domain" }; + } + + // Sort by most recent and get the first one + matchingPasswords.sort((a, b) => { + const dateA = new Date(a.lastModified || a.dateCreated || 0); + const dateB = new Date(b.lastModified || b.dateCreated || 0); + return dateB.getTime() - dateA.getTime(); + }); + + const mostRecentPassword = matchingPasswords[0]; + + if (mostRecentPassword) { + logger.info(`Found password for domain ${domain}:`, { + url: mostRecentPassword.url, + username: mostRecentPassword.username, + source: mostRecentPassword.source, + }); + + // Copy password to clipboard + clipboard.writeText(mostRecentPassword.password); + + // Show notification + try { + const { NotificationService } = await import( + "@/services/notification-service" + ); + const notificationService = NotificationService.getInstance(); + if (notificationService) { + notificationService.showLocalNotification({ + title: "Password Pasted", + body: `Password for ${domain} copied to clipboard`, + icon: "🔐", + }); + } + } catch (error) { + logger.warn("Failed to show notification:", error); + } + + return { + success: true, + password: { + id: mostRecentPassword.id, + url: mostRecentPassword.url, + username: mostRecentPassword.username, + source: mostRecentPassword.source, + }, + }; + } + + return { success: false, error: "No password found for this domain" }; + } catch (error) { + logger.error("Failed to paste password for domain:", error); + return { success: false, error: "Failed to retrieve password" }; + } +} + +/** + * Paste password for the active tab + */ +export async function pastePasswordForActiveTab() { + try { + // Get the active tab from the browser + const { browser } = await import("@/index"); + + if (!browser) { + logger.error("Browser instance not available"); + return { success: false, error: "Browser not available" }; + } + + const mainWindow = browser.getMainWindow(); + if (!mainWindow) { + logger.error("Main window not available"); + return { success: false, error: "Main window not available" }; + } + + const appWindow = browser.getApplicationWindow(mainWindow.webContents.id); + if (!appWindow) { + logger.error("Application window not available"); + return { success: false, error: "Application window not available" }; + } + + const activeTab = appWindow.tabManager.getActiveTab(); + if (!activeTab) { + logger.error("No active tab found"); + return { success: false, error: "No active tab found" }; + } + + const url = activeTab.url; + if (!url) { + logger.error("Active tab has no URL"); + return { success: false, error: "Active tab has no URL" }; + } + + // Extract domain from URL + let domain: string; + try { + const urlObj = new URL(url); + domain = urlObj.hostname; + } catch { + logger.error("Invalid URL in active tab"); + return { success: false, error: "Invalid URL in active tab" }; + } + + // Use the domain-specific handler + return await pastePasswordForDomain(domain); + } catch (error) { + logger.error("Failed to paste password for active tab:", error); + return { success: false, error: "Failed to get active tab" }; + } +} diff --git a/apps/electron-app/src/main/services/chrome-data-extraction.ts b/apps/electron-app/src/main/services/chrome-data-extraction.ts new file mode 100644 index 0000000..14dda50 --- /dev/null +++ b/apps/electron-app/src/main/services/chrome-data-extraction.ts @@ -0,0 +1,686 @@ +/** + * Chrome Data Extraction Service + * Handles extraction of passwords, bookmarks, history, autofill, and search engines from Chrome + * Extracted from DialogManager to follow Single Responsibility Principle + */ + +import * as fsSync from "fs"; +import * as os from "os"; +import * as path from "path"; +import { createLogger } from "@vibe/shared-types"; + +import * as sqlite3 from "sqlite3"; +import { pbkdf2, createDecipheriv } from "crypto"; +import { promisify } from "util"; + +const pbkdf2Async = promisify(pbkdf2); + +const logger = createLogger("chrome-data-extraction"); + +export interface ChromeExtractionResult { + success: boolean; + data?: T; + error?: string; +} + +export interface ChromeProfile { + path: string; + name: string; + isDefault: boolean; +} + +export interface ProgressCallback { + (progress: number, message?: string): void; +} + +export class ChromeDataExtractionService { + private static instance: ChromeDataExtractionService; + + public static getInstance(): ChromeDataExtractionService { + if (!ChromeDataExtractionService.instance) { + ChromeDataExtractionService.instance = new ChromeDataExtractionService(); + } + return ChromeDataExtractionService.instance; + } + + private constructor() {} + + /** + * Get available Chrome profiles + */ + public async getChromeProfiles(): Promise { + try { + const chromeConfigPath = this.getChromeConfigPath(); + if (!chromeConfigPath || !fsSync.existsSync(chromeConfigPath)) { + return []; + } + + const profiles: ChromeProfile[] = []; + const localStatePath = path.join(chromeConfigPath, "Local State"); + + if (fsSync.existsSync(localStatePath)) { + let profilesInfo = {}; + try { + const localStateContent = fsSync.readFileSync(localStatePath, "utf8"); + const localState = JSON.parse(localStateContent); + profilesInfo = localState.profile?.info_cache || {}; + } catch (parseError) { + logger.warn("Failed to parse Chrome Local State file", parseError); + // Continue with empty profiles info + } + + for (const [profileDir, info] of Object.entries(profilesInfo as any)) { + const profilePath = path.join(chromeConfigPath, profileDir); + if (fsSync.existsSync(profilePath)) { + profiles.push({ + path: profilePath, + name: (info as any).name || profileDir, + isDefault: profileDir === "Default", + }); + } + } + } + + // Fallback to default profile if no profiles found + if (profiles.length === 0) { + const defaultPath = path.join(chromeConfigPath, "Default"); + if (fsSync.existsSync(defaultPath)) { + profiles.push({ + path: defaultPath, + name: "Default", + isDefault: true, + }); + } + } + + return profiles; + } catch (error) { + logger.error("Failed to get Chrome profiles:", error); + return []; + } + } + + /** + * Extract passwords from Chrome with progress tracking + */ + public async extractPasswords( + profile?: ChromeProfile, + onProgress?: ProgressCallback, + ): Promise> { + try { + onProgress?.(0.1, "Locating Chrome profile..."); + + const chromeProfile = profile || (await this.getDefaultProfile()); + if (!chromeProfile) { + return { success: false, error: "No Chrome profile found" }; + } + + onProgress?.(0.3, "Reading password database..."); + + const passwords = await this.extractPasswordsFromProfile(chromeProfile); + + onProgress?.(1.0, "Password extraction complete"); + + return { + success: true, + data: passwords, + }; + } catch (error) { + logger.error("Password extraction failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Extract bookmarks from Chrome with progress tracking + */ + public async extractBookmarks( + profile?: ChromeProfile, + onProgress?: ProgressCallback, + ): Promise> { + try { + onProgress?.(0.1, "Locating Chrome profile..."); + + const chromeProfile = profile || (await this.getDefaultProfile()); + if (!chromeProfile) { + return { success: false, error: "No Chrome profile found" }; + } + + onProgress?.(0.3, "Reading bookmarks file..."); + + const bookmarksPath = path.join(chromeProfile.path, "Bookmarks"); + if (!fsSync.existsSync(bookmarksPath)) { + return { success: false, error: "Bookmarks file not found" }; + } + + let bookmarksData; + try { + const bookmarksContent = fsSync.readFileSync(bookmarksPath, "utf8"); + bookmarksData = JSON.parse(bookmarksContent); + } catch (parseError) { + logger.error("Failed to parse Chrome bookmarks file", parseError); + return { + success: false, + error: "Failed to parse bookmarks file", + }; + } + const bookmarks = this.parseBookmarksRecursive(bookmarksData.roots); + + onProgress?.(1.0, "Bookmarks extraction complete"); + + return { + success: true, + data: bookmarks.map((bookmark, index) => ({ + ...bookmark, + id: bookmark.id || `chrome_bookmark_${index}`, + source: "chrome", + })), + }; + } catch (error) { + logger.error("Bookmarks extraction failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Extract browsing history from Chrome + */ + public async extractHistory( + profile?: ChromeProfile, + onProgress?: ProgressCallback, + ): Promise> { + try { + onProgress?.(0.1, "Locating Chrome profile..."); + + const chromeProfile = profile || (await this.getDefaultProfile()); + if (!chromeProfile) { + return { success: false, error: "No Chrome profile found" }; + } + + onProgress?.(0.3, "Reading history database..."); + + const historyPath = path.join(chromeProfile.path, "History"); + if (!fsSync.existsSync(historyPath)) { + return { success: false, error: "History database not found" }; + } + + const history = await this.extractHistoryFromDatabase(historyPath); + + onProgress?.(1.0, "History extraction complete"); + + return { + success: true, + data: history, + }; + } catch (error) { + logger.error("History extraction failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Extract all Chrome data from a profile + */ + public async extractAllData( + profile?: ChromeProfile, + onProgress?: ProgressCallback, + ): Promise< + ChromeExtractionResult<{ + passwords: any[]; + bookmarks: any[]; + history: any[]; + autofill: any[]; + searchEngines: any[]; + }> + > { + try { + const chromeProfile = profile || (await this.getDefaultProfile()); + if (!chromeProfile) { + return { success: false, error: "No Chrome profile found" }; + } + + onProgress?.(0.1, "Starting comprehensive data extraction..."); + + const [passwordsResult, bookmarksResult, historyResult] = + await Promise.allSettled([ + this.extractPasswords(chromeProfile, p => + onProgress?.(0.1 + p * 0.3, "Extracting passwords..."), + ), + this.extractBookmarks(chromeProfile, p => + onProgress?.(0.4 + p * 0.3, "Extracting bookmarks..."), + ), + this.extractHistory(chromeProfile, p => + onProgress?.(0.7 + p * 0.3, "Extracting history..."), + ), + ]); + + onProgress?.(1.0, "Data extraction complete"); + + const result = { + passwords: + passwordsResult.status === "fulfilled" && + passwordsResult.value.success + ? passwordsResult.value.data || [] + : [], + bookmarks: + bookmarksResult.status === "fulfilled" && + bookmarksResult.value.success + ? bookmarksResult.value.data || [] + : [], + history: + historyResult.status === "fulfilled" && historyResult.value.success + ? historyResult.value.data || [] + : [], + autofill: [], // TODO: Implement autofill extraction + searchEngines: [], // TODO: Implement search engines extraction + }; + + return { + success: true, + data: result, + }; + } catch (error) { + logger.error("Comprehensive data extraction failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + // PRIVATE HELPER METHODS + + private getChromeConfigPath(): string | null { + const platform = os.platform(); + const homeDir = os.homedir(); + + switch (platform) { + case "darwin": // macOS + return path.join( + homeDir, + "Library", + "Application Support", + "Google", + "Chrome", + ); + case "win32": // Windows + return path.join( + homeDir, + "AppData", + "Local", + "Google", + "Chrome", + "User Data", + ); + case "linux": // Linux + return path.join(homeDir, ".config", "google-chrome"); + default: + return null; + } + } + + private async getDefaultProfile(): Promise { + const profiles = await this.getChromeProfiles(); + return profiles.find(p => p.isDefault) || profiles[0] || null; + } + + private async extractPasswordsFromProfile( + profile: ChromeProfile, + ): Promise { + const loginDataPath = path.join(profile.path, "Login Data"); + if (!fsSync.existsSync(loginDataPath)) { + throw new Error("Login Data file not found"); + } + + // Create a temporary copy of the database (Chrome locks the original) + const tempPath = path.join( + os.tmpdir(), + `chrome_login_data_${Date.now()}.db`, + ); + try { + fsSync.copyFileSync(loginDataPath, tempPath); + + // Get the encryption key + const encryptionKey = await this.getChromeEncryptionKey(); + if (!encryptionKey) { + throw new Error("Failed to retrieve Chrome encryption key"); + } + + // Query the SQLite database + const passwords = await new Promise((resolve, reject) => { + const db = new sqlite3.Database(tempPath, sqlite3.OPEN_READONLY); + const results: any[] = []; + let totalRows = 0; + let decryptedCount = 0; + + db.serialize(() => { + db.each( + `SELECT origin_url, username_value, password_value, date_created, date_last_used + FROM logins + WHERE blacklisted_by_user = 0`, + (err, row: any) => { + totalRows++; + if (err) { + logger.error("Error reading password row:", err); + return; + } + + try { + // Decrypt the password + const decryptedPassword = this.decryptChromePassword( + row.password_value, + encryptionKey, + ); + + if (decryptedPassword) { + decryptedCount++; + results.push({ + id: `chrome_${profile.name}_${results.length}`, + url: row.origin_url, + username: row.username_value, + password: decryptedPassword, + source: "chrome", + dateCreated: new Date( + row.date_created / 1000 - 11644473600000, + ), // Chrome epoch to JS epoch + lastModified: row.date_last_used + ? new Date(row.date_last_used / 1000 - 11644473600000) + : undefined, + }); + } else if (decryptedPassword === "") { + logger.debug( + `Empty password for ${row.origin_url}, skipping`, + ); + } else { + logger.warn( + `Failed to decrypt password for ${row.origin_url}`, + ); + } + } catch (decryptError) { + logger.warn( + `Failed to decrypt password for ${row.origin_url}:`, + decryptError, + ); + } + }, + err => { + db.close(); + logger.info( + `Chrome password extraction completed for ${profile.name}: ${decryptedCount}/${totalRows} passwords decrypted`, + ); + if (err) { + reject(err); + } else { + resolve(results); + } + }, + ); + }); + + db.on("error", err => { + logger.error("Database error:", err); + reject(err); + }); + }); + + return passwords; + } finally { + // Clean up temp file + try { + fsSync.unlinkSync(tempPath); + } catch (e) { + logger.warn("Failed to clean up temp file:", e); + } + } + } + + private async getChromeEncryptionKey(): Promise { + const platform = os.platform(); + + if (platform === "darwin") { + // macOS: Get key from Keychain using security command + try { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + const { stdout } = await execAsync( + 'security find-generic-password -w -s "Chrome Safe Storage" -a "Chrome"', + ); + + const password = stdout.trim(); + + if (!password) { + logger.error("Chrome Safe Storage password not found in Keychain"); + return null; + } + + // Derive key using PBKDF2 + const salt = Buffer.from("saltysalt"); + const iterations = 1003; + const keyLength = 16; + + return await pbkdf2Async(password, salt, iterations, keyLength, "sha1"); + } catch (error) { + logger.error( + "Failed to get Chrome encryption key from Keychain:", + error, + ); + return null; + } + } else if (platform === "win32") { + // Windows: Key is stored in Local State file + try { + const localStatePath = path.join( + os.homedir(), + "AppData", + "Local", + "Google", + "Chrome", + "User Data", + "Local State", + ); + + if (!fsSync.existsSync(localStatePath)) { + return null; + } + + let localState; + try { + const localStateContent = fsSync.readFileSync(localStatePath, "utf8"); + localState = JSON.parse(localStateContent); + } catch (parseError) { + logger.error( + "Failed to parse Chrome Local State file for encryption key", + parseError, + ); + return null; + } + const encryptedKey = localState.os_crypt?.encrypted_key; + + if (!encryptedKey) { + return null; + } + + // Decode base64 and remove DPAPI prefix + // const encryptedKeyBuf = Buffer.from(encryptedKey, "base64"); + // const encryptedKeyData = encryptedKeyBuf.slice(5); // Remove "DPAPI" prefix + + // Use Windows DPAPI to decrypt (would need native module) + // For now, return null - would need win32-dpapi or similar + logger.warn("Windows DPAPI decryption not implemented"); + return null; + } catch (error) { + logger.error( + "Failed to get Chrome encryption key from Local State:", + error, + ); + return null; + } + } else if (platform === "linux") { + // Linux: Chrome uses a well-known default password for local encryption + // This is not a security vulnerability - it's Chrome's documented behavior + // See: https://chromium.googlesource.com/chromium/src/+/master/docs/linux/password_storage.md + // The actual security comes from OS-level file permissions, not the encryption key + const salt = Buffer.from("saltysalt"); + const iterations = 1; + const keyLength = 16; + const password = "peanuts"; // Chrome's default password on Linux (not a secret) + + return await pbkdf2Async(password, salt, iterations, keyLength, "sha1"); + } + + return null; + } + + private decryptChromePassword( + encryptedPassword: Buffer, + key: Buffer, + ): string | null { + try { + // Chrome password format: "v10" prefix + encrypted data on macOS + const passwordBuffer = Buffer.isBuffer(encryptedPassword) + ? encryptedPassword + : Buffer.from(encryptedPassword); + + if (!passwordBuffer || passwordBuffer.length === 0) { + return ""; + } + + // Check for v10 prefix (Chrome 80+ on macOS) + if (passwordBuffer.slice(0, 3).toString("utf8") === "v10") { + // AES-128-CBC decryption (not GCM!) + const iv = Buffer.alloc(16, " "); // Fixed IV of spaces + const encryptedData = passwordBuffer.slice(3); // Skip "v10" prefix + + const decipher = createDecipheriv("aes-128-cbc", key, iv); + decipher.setAutoPadding(false); // We'll handle padding manually + + let decrypted = decipher.update(encryptedData); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + // Remove PKCS7 padding + const paddingLength = decrypted[decrypted.length - 1]; + if (paddingLength > 0 && paddingLength <= 16) { + const unpadded = decrypted.slice(0, decrypted.length - paddingLength); + return unpadded.toString("utf8"); + } + + return decrypted.toString("utf8"); + } else { + // Non-encrypted or older format + logger.warn("Password is not v10 encrypted, returning as-is"); + return passwordBuffer.toString("utf8"); + } + } catch (error) { + logger.error("Password decryption failed:", error); + return null; + } + } + + private parseBookmarksRecursive(root: any): any[] { + const bookmarks: any[] = []; + + for (const [key, folder] of Object.entries(root as Record)) { + if (folder.type === "folder" && folder.children) { + bookmarks.push( + ...this.parseBookmarksRecursive({ [key]: folder.children }), + ); + } else if (folder.children) { + for (const child of folder.children) { + if (child.type === "url") { + bookmarks.push({ + id: child.id, + name: child.name, + url: child.url, + folder: key, + dateAdded: child.date_added + ? new Date(parseInt(child.date_added) / 1000) + : new Date(), + }); + } else if (child.type === "folder") { + bookmarks.push( + ...this.parseBookmarksRecursive({ [child.name]: child }), + ); + } + } + } + } + + return bookmarks; + } + + private async extractHistoryFromDatabase( + historyPath: string, + ): Promise { + // Create a temporary copy of the database (Chrome locks the original) + const tempPath = path.join(os.tmpdir(), `chrome_history_${Date.now()}.db`); + try { + fsSync.copyFileSync(historyPath, tempPath); + + const history = await new Promise((resolve, reject) => { + const db = new sqlite3.Database(tempPath, sqlite3.OPEN_READONLY); + const results: any[] = []; + + db.serialize(() => { + db.each( + `SELECT url, title, visit_count, last_visit_time + FROM urls + ORDER BY last_visit_time DESC + LIMIT 1000`, + (err, row: any) => { + if (err) { + logger.error("Error reading history row:", err); + return; + } + + results.push({ + id: `chrome_history_${results.length}`, + url: row.url, + title: row.title || row.url, + visitCount: row.visit_count, + lastVisit: new Date( + row.last_visit_time / 1000 - 11644473600000, + ), // Chrome epoch to JS epoch + source: "chrome", + }); + }, + err => { + db.close(); + if (err) { + reject(err); + } else { + resolve(results); + } + }, + ); + }); + + db.on("error", err => { + logger.error("Database error:", err); + reject(err); + }); + }); + + return history; + } finally { + // Clean up temp file + try { + fsSync.unlinkSync(tempPath); + } catch (e) { + logger.warn("Failed to clean up temp file:", e); + } + } + } +} + +export const chromeDataExtraction = ChromeDataExtractionService.getInstance(); diff --git a/apps/electron-app/src/renderer/settings.html b/apps/electron-app/src/renderer/settings.html new file mode 100644 index 0000000..e5953cc --- /dev/null +++ b/apps/electron-app/src/renderer/settings.html @@ -0,0 +1,25 @@ + + + + + Settings + + + + + + + + + + +
+ + + diff --git a/apps/electron-app/src/renderer/src/Settings.tsx b/apps/electron-app/src/renderer/src/Settings.tsx new file mode 100644 index 0000000..f341d5f --- /dev/null +++ b/apps/electron-app/src/renderer/src/Settings.tsx @@ -0,0 +1,1387 @@ +import { useState, useEffect, lazy, Suspense, useCallback } from "react"; +import React from "react"; +import { usePasswords } from "./hooks/usePasswords"; +import { + User, + Sparkles, + Bell, + Command, + Puzzle, + Lock, + ChevronLeft, + ChevronRight, + Download, + Search, + Eye, + EyeOff, + Copy, + FileDown, + X, + Loader2, + Wallet, + CheckCircle, + AlertCircle, + Info, + Key, +} from "lucide-react"; +import { ProgressBar } from "./components/common/ProgressBar"; +import { usePrivyAuth } from "./hooks/usePrivyAuth"; +import { UserPill } from "./components/ui/UserPill"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("settings"); + +// Type declaration for webkit properties +declare module "react" { + interface CSSProperties { + "-webkit-corner-smoothing"?: string; + "-webkit-app-region"?: string; + "-webkit-user-select"?: string; + } +} + +// Loading spinner component +const LoadingSpinner = () => ( +
+ +
+); + +// Floating Toast component using Lucide icons +const FloatingToast = ({ + message, + type, + onClose, +}: { + message: string; + type: "success" | "error" | "info"; + onClose: () => void; +}) => { + const getIcon = () => { + switch (type) { + case "success": + return ; + case "error": + return ; + case "info": + return ; + default: + return ; + } + }; + + const getColors = () => { + switch (type) { + case "success": + return "bg-green-50 border-green-200 text-green-800"; + case "error": + return "bg-red-50 border-red-200 text-red-800"; + case "info": + return "bg-blue-50 border-blue-200 text-blue-800"; + default: + return "bg-blue-50 border-blue-200 text-blue-800"; + } + }; + + useEffect(() => { + const timer = setTimeout(onClose, 5000); + return () => clearTimeout(timer); + }, [onClose]); + + return ( +
+
+ {getIcon()} + {message} + +
+
+ ); +}; + +// Lazy load all settings components +const AppleAccountsSettings = lazy(() => + Promise.resolve({ default: AppleAccountsSettingsComponent }), +); +const PasswordsSettings = lazy(() => + Promise.resolve({ + default: (props: { preloadedData?: any }) => ( + + ), + }), +); +const NotificationsSettings = lazy(() => + Promise.resolve({ default: NotificationsSettingsComponent }), +); +const ShortcutsSettings = lazy(() => + Promise.resolve({ default: ShortcutsSettingsComponent }), +); +const ComponentsSettings = lazy(() => + Promise.resolve({ default: ComponentsSettingsComponent }), +); +const APIKeysSettings = lazy(() => + Promise.resolve({ default: APIKeysSettingsComponent }), +); + +// Main App Component +export default function Settings() { + const [activeTab, setActiveTab] = useState("apple-accounts"); + + // Preload password data in background regardless of active tab + // This ensures instant switching to passwords tab + const passwordsData = usePasswords(true); + + const handleTabChange = useCallback( + (newTab: string) => { + if (newTab === activeTab) return; + + // Use startTransition for non-urgent updates + React.startTransition(() => { + setActiveTab(newTab); + }); + }, + [activeTab], + ); + + const toolbarButtons = [ + { id: "apple-accounts", label: "Accounts", icon: User }, + { + id: "passwords", + label: "Passwords", + icon: Lock, + // Show loading indicator if passwords are still loading + loading: + passwordsData.loading && passwordsData.filteredPasswords.length === 0, + }, + { id: "intelligence", label: "Agents", icon: Sparkles }, + { id: "behaviors", label: "API Keys", icon: Key }, + { id: "notifications", label: "Notifications", icon: Bell }, + { id: "shortcuts", label: "Shortcuts", icon: Command }, + { id: "components", label: "Marketplace", icon: Puzzle }, + ]; + + const activeLabel = + toolbarButtons.find(b => b.id === activeTab)?.label || "Settings"; + + const handleCloseDialog = () => { + if (window.electron?.ipcRenderer) { + window.electron.ipcRenderer.invoke("dialog:close", "settings"); + } + }; + + const renderContent = () => { + // Wrap components in Suspense for lazy loading + const content = (() => { + switch (activeTab) { + case "apple-accounts": + return ; + case "passwords": + return ; + case "notifications": + return ; + case "shortcuts": + return ; + case "components": + return ; + case "behaviors": + return ; + default: + return ; + } + })(); + + return }>{content}; + }; + + return ( +
+
+ {/* Draggable title bar */} +
+ + {/* Close button */} + + + {/* Sidebar Column */} +
+ {/* Sidebar's top bar section */} +
+ {/* Empty space for native traffic lights */} +
+ {/* The actual list of tabs */} +
+ {toolbarButtons.map(({ id, label, icon: Icon, loading }) => ( + + ))} +
+
+ + {/* Content Column */} +
+ {/* Content's Title Bar */} +
+ {/* Forward/backward buttons */} +
+
+ +
+ +
+
+

+ {activeLabel} +

+
+ {/* The actual content panel */} +
{renderContent()}
+
+
+
+ ); +} + +// Settings Components +const AppleAccountsSettingsComponent = () => { + const { isAuthenticated, user, login, isLoading } = usePrivyAuth(); + const [components, setComponents] = useState({ + adBlocker: true, + bluetooth: false, + }); + const [componentsLoading, setComponentsLoading] = useState(true); + + const handleAddFunds = () => { + if (!isAuthenticated) { + login(); + } else { + // Handle add funds action + logger.info("Add funds clicked"); + // This would open a payment modal or redirect to payment page + } + }; + + const handleToggle = async (component: keyof typeof components) => { + const newValue = !components[component]; + setComponents(prev => ({ ...prev, [component]: newValue })); + + // Save to backend + if (window.electron?.ipcRenderer) { + await window.electron.ipcRenderer.invoke("settings:update-components", { + [component]: newValue, + }); + } + }; + + useEffect(() => { + // Load saved settings + const loadSettings = async () => { + setComponentsLoading(true); + try { + if (window.electron?.ipcRenderer) { + const result = await window.electron.ipcRenderer.invoke( + "settings:get-components", + ); + if (result?.success) { + setComponents(result.settings); + } + } + } catch (error) { + logger.error("Failed to load component settings:", error); + } finally { + setComponentsLoading(false); + } + }; + + // Delay load to improve perceived performance + const timer = setTimeout(loadSettings, 100); + return () => clearTimeout(timer); + }, []); + + return ( +
+ {/* User Account Section */} + {isAuthenticated && user && ( +
+
+
+
+
+ +
+
+

Wallet

+

Connected via Privy

+
+
+ +
+
+
+ )} + + {/* Add Funds Button */} +
+ {!isAuthenticated && !isLoading && ( +

Login with Privy to add funds

+ )} + +
+ + {/* Browser Components Section */} +
+

+ Browser Components +

+ + {componentsLoading ? ( + + ) : ( +
+
+
+

Ad Blocker

+

+ Block ads and trackers for faster, cleaner browsing +

+
+ +
+ +
+
+

Bluetooth Support

+

+ Enable web pages to connect to Bluetooth devices +

+
+ +
+
+ )} +
+
+ ); +}; + +// URL display logic +const getDisplayUrl = (url: string): string => { + // Check if URL has a TLD pattern + const tldPattern = /\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?(?:\/|$)/; + const hasTLD = tldPattern.test(url); + + if (!hasTLD && url.length > 25) { + // Truncate non-TLD URLs at 25 characters + return url.substring(0, 25) + "..."; + } + + if (hasTLD) { + // For URLs with TLD, truncate at the domain level + const match = url.match(/^(https?:\/\/)?([^/]+)/); + if (match) { + const domain = match[2]; + return (match[1] || "") + domain; + } + } + + return url; +}; + +const PasswordsSettingsComponent = ({ + preloadedData, +}: { + preloadedData?: any; +}) => { + // Always call the hook, but conditionally load data + const hookData = usePasswords(!preloadedData); + + // Use preloaded data if available, otherwise use hook data + const { + passwords, + filteredPasswords, + searchQuery, + setSearchQuery, + isPasswordModalVisible, + setIsPasswordModalVisible, + selectedPassword, + showPassword, + setShowPassword, + loading, + statusMessage, + statusType, + isImporting, + importedSources, + progressValue, + progressText, + handleComprehensiveImportFromChrome, + handleExportPasswords, + handleViewPassword, + copyToClipboard, + clearMessage, + } = preloadedData || hookData; + + // Show loading state for initial load + if (loading && filteredPasswords.length === 0) { + return ; + } + + return ( + <> + {/* Floating Toast - positioned absolutely outside main content */} + {statusMessage && ( + + )} + +
+ {/* Progress Bar */} + {isImporting && ( +
+ +
+ )} + + {/* Import Section */} +
+
+ +
+

+ Password Manager +

+

+ {passwords.length === 0 + ? "Import your passwords from Chrome to get started. All data is encrypted and stored securely." + : "Search and manage your imported passwords. Quick copy username and password with one click."} +

+ {passwords.length === 0 && ( + + )} +
+ + {/* Quick Search & Copy Area */} + {passwords.length > 0 && ( +
+
+

+ Local Storage +

+
+ + +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 bg-input border border-border focus:ring-2 focus:ring-ring focus:border-transparent focus:bg-background outline-none transition-all" + style={{ + borderRadius: "6px", + "-webkit-corner-smoothing": "subpixel", + }} + /> +
+ + {/* Quick Copy Cards */} +
+ {filteredPasswords.length === 0 ? ( +
+

+ No passwords found matching "{searchQuery}" +

+

Try a different search term

+
+ ) : ( + filteredPasswords.map((password: any) => { + const displayUrl = getDisplayUrl(password.url); + + return ( +
+
+
+ +
+
+

+ {displayUrl} +

+

+ {password.username} +

+
+
+
+ + + +
+
+ ); + }) + )} +
+
+ )} + + {/* Password Detail Modal */} + {isPasswordModalVisible && selectedPassword && ( +
+
+
+

+ Password Details +

+ +
+ +
+
+ +
+ {selectedPassword.url} +
+
+ +
+ +
+ {selectedPassword.username} +
+
+ +
+ +
+
+ {showPassword + ? selectedPassword.password + : "••••••••••••"} +
+ +
+
+
+ +
+ + +
+
+
+ )} +
+ + ); +}; + +const PlaceholderContent = ({ title }) => ( +
+
+

{title}

+

Settings for {title} would be displayed here.

+
+
+); + +// Notifications Settings Component +const NotificationsSettingsComponent = () => { + const [notifications, setNotifications] = useState({ + enabled: true, + sound: true, + badge: true, + preview: false, + }); + const [loading, setLoading] = useState(true); + + const handleToggle = async (key: keyof typeof notifications) => { + const newValue = !notifications[key]; + setNotifications(prev => ({ ...prev, [key]: newValue })); + + // Save to backend + if (window.electron?.ipcRenderer) { + await window.electron.ipcRenderer.invoke( + "settings:update-notifications", + { + [key]: newValue, + }, + ); + } + }; + + useEffect(() => { + // Load saved settings + const loadSettings = async () => { + setLoading(true); + try { + if (window.electron?.ipcRenderer) { + const result = await window.electron.ipcRenderer.invoke( + "settings:get-notifications", + ); + if (result?.success) { + setNotifications(result.settings); + } + } + } catch (error) { + logger.error("Failed to load notification settings:", error); + } finally { + setLoading(false); + } + }; + + // Delay load to improve perceived performance + const timer = setTimeout(loadSettings, 100); + return () => clearTimeout(timer); + }, []); + + if (loading) { + return ; + } + + return ( +
+
+

+ Notification Preferences +

+ +
+
+
+

+ Enable Notifications +

+

+ Show desktop notifications for important events +

+
+ +
+ +
+
+

Notification Sound

+

+ Play a sound when notifications appear +

+
+ +
+ +
+
+

Show Badge Count

+

+ Display unread count on app icon +

+
+ +
+ +
+
+

Message Preview

+

+ Show message content in notifications +

+
+ +
+
+
+ + {/* Notification History Console */} +
+
+

+ Notification History +

+ +
+ +
+
+
+ [2024-01-08 10:23:45] System notification sent: "Download + completed" +
+
+ [2024-01-08 10:22:12] Agent notification: "Analysis complete for + current tab" +
+
+ [2024-01-08 10:20:03] Update notification: "New version available" +
+
+ [2024-01-08 10:15:30] System notification sent: "Password import + successful" +
+
+ — End of notification history — +
+
+
+
+
+ ); +}; + +// Shortcuts Settings Component +const ShortcutsSettingsComponent = () => { + const shortcuts = [ + { action: "Open Omnibox", keys: ["⌘", "K"] }, + { action: "New Tab", keys: ["⌘", "T"] }, + { action: "Close Tab", keys: ["⌘", "W"] }, + { action: "Switch Tab", keys: ["⌘", "1-9"] }, + { action: "Reload Page", keys: ["⌘", "R"] }, + { action: "Go Back", keys: ["⌘", "["] }, + { action: "Go Forward", keys: ["⌘", "]"] }, + { action: "Find in Page", keys: ["⌘", "F"] }, + { action: "Downloads", keys: ["⌘", "Shift", "J"] }, + { action: "Settings", keys: ["⌘", ","] }, + ]; + + return ( +
+
+

+ Keyboard Shortcuts +

+ +
+ {shortcuts.map((shortcut, index) => ( +
+ {shortcut.action} +
+ {shortcut.keys.map((key, keyIndex) => ( + + {key} + + ))} +
+
+ ))} +
+ +

+ Keyboard shortcuts cannot be customized at this time. +

+
+
+ ); +}; + +// Components Settings Component - Now just a placeholder for marketplace +const ComponentsSettingsComponent = () => { + return ( +
+
+

+ Marketplace +

+ +
+
+
+ {/* Simulated blurry eBay-style interface */} +
+
+
+
+
+
+
+
+
+
+
+
+
+ Early Preview +
+
+
+

+ Browser extension marketplace coming soon +

+
+
+
+
+ ); +}; + +// API Keys Settings Component +const APIKeysSettingsComponent = () => { + const [apiKeys, setApiKeys] = useState({ openai: "", turbopuffer: "" }); + const [savedKeys, setSavedKeys] = useState({ + openai: false, + turbopuffer: false, + }); + const [loading, setLoading] = useState(true); + const [toastMessage, setToastMessage] = useState<{ + message: string; + type: "success" | "error" | "info"; + } | null>(null); + + // Load API keys from profile on mount + useEffect(() => { + loadApiKeys(); + }, []); + + const loadApiKeys = async () => { + try { + setLoading(true); + const [openaiKey, turbopufferKey] = await Promise.all([ + window.apiKeys?.get("openai"), + window.apiKeys?.get("turbopuffer"), + ]); + setApiKeys({ + openai: openaiKey || "", + turbopuffer: turbopufferKey || "", + }); + // Mark as saved if keys exist + setSavedKeys({ + openai: !!openaiKey, + turbopuffer: !!turbopufferKey, + }); + } catch (error) { + logger.error("Failed to load API keys:", error); + setToastMessage({ message: "Failed to load API keys", type: "error" }); + } finally { + setLoading(false); + } + }; + + const handleApiKeyChange = (key: "openai" | "turbopuffer", value: string) => { + setApiKeys({ ...apiKeys, [key]: value }); + }; + + const saveApiKey = async (key: "openai" | "turbopuffer") => { + try { + const value = apiKeys[key]; + if (value) { + const result = await window.apiKeys?.set(key, value); + if (result) { + setSavedKeys(prev => ({ ...prev, [key]: true })); + setToastMessage({ + message: `${key === "openai" ? "OpenAI" : "TurboPuffer"} API key saved successfully`, + type: "success", + }); + } else { + setSavedKeys(prev => ({ ...prev, [key]: false })); + setToastMessage({ + message: `Failed to save ${key === "openai" ? "OpenAI" : "TurboPuffer"} API key`, + type: "error", + }); + } + } else { + // Clear the key if empty + await window.apiKeys?.set(key, ""); + setSavedKeys(prev => ({ ...prev, [key]: false })); + } + } catch (error) { + logger.error(`Failed to save ${key} API key:`, error); + setSavedKeys(prev => ({ ...prev, [key]: false })); + setToastMessage({ + message: `Failed to save ${key === "openai" ? "OpenAI" : "TurboPuffer"} API key`, + type: "error", + }); + } + }; + + if (loading) { + return ; + } + + return ( + <> + {/* Floating Toast - positioned absolutely outside main content */} + {toastMessage && ( + setToastMessage(null)} + /> + )} + +
+
+

+ API Keys Management +

+ +
+ {/* OpenAI API Key */} +
+
+ +
+
+

+ Used for AI-powered features and intelligent assistance +

+
+ handleApiKeyChange("openai", e.target.value)} + onBlur={() => saveApiKey("openai")} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm" + /> +
+
+ + {/* TurboPuffer API Key */} +
+
+ +
+
+

+ Used for vector search and embeddings storage +

+
+ + handleApiKeyChange("turbopuffer", e.target.value) + } + onBlur={() => saveApiKey("turbopuffer")} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm" + /> +
+
+
+ +
+

+ Note: API keys are encrypted and stored securely + in your profile. They are never transmitted to our servers. +

+
+
+
+ + ); +}; diff --git a/apps/electron-app/src/renderer/src/components/modals/SettingsModal.css b/apps/electron-app/src/renderer/src/components/modals/SettingsModal.css new file mode 100644 index 0000000..e9667cb --- /dev/null +++ b/apps/electron-app/src/renderer/src/components/modals/SettingsModal.css @@ -0,0 +1,352 @@ +/** + * Settings Modal Styles + * Apple-inspired design with glassmorphism effects + */ + +.settings-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease-out; +} + +.settings-modal { + width: 900px; + height: 600px; + max-width: 95vw; + max-height: 90vh; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-radius: 16px; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.2), + 0 8px 24px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(255, 255, 255, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Header */ +.settings-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 32px 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +.settings-modal-header h2 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: #1a1a1a; + letter-spacing: -0.02em; +} + +.settings-modal-close { + background: none; + border: none; + font-size: 20px; + color: #666; + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.settings-modal-close:hover { + background: rgba(0, 0, 0, 0.05); + color: #333; +} + +/* Content */ +.settings-modal-content { + display: flex; + flex: 1; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.settings-tabs { + width: 200px; + background: rgba(0, 0, 0, 0.02); + border-right: 1px solid rgba(0, 0, 0, 0.08); + padding: 16px 0; + flex-shrink: 0; +} + +.settings-tab { + display: block; + width: 100%; + padding: 12px 24px; + background: none; + border: none; + text-align: left; + font-size: 14px; + font-weight: 500; + color: #666; + cursor: pointer; + transition: all 0.15s ease; + border-radius: 0; +} + +.settings-tab:hover { + background: rgba(0, 0, 0, 0.04); + color: #333; +} + +.settings-tab.active { + background: rgba(59, 130, 246, 0.1); + color: #2563eb; + font-weight: 600; +} + +.settings-content { + flex: 1; + padding: 24px 32px; + overflow-y: auto; +} + +.settings-section { + max-width: 500px; +} + +.settings-section h3 { + margin: 0 0 24px 0; + font-size: 20px; + font-weight: 600; + color: #1a1a1a; + letter-spacing: -0.01em; +} + +.settings-group { + margin-bottom: 20px; +} + +.settings-label { + display: block; + font-size: 14px; + font-weight: 500; + color: #333; + margin-bottom: 8px; +} + +.settings-input, +.settings-select { + width: 100%; + padding: 12px 16px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + font-size: 14px; + background: rgba(255, 255, 255, 0.8); + transition: all 0.15s ease; + margin-top: 8px; +} + +.settings-input:focus, +.settings-select:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + background: rgba(255, 255, 255, 0.95); +} + +.settings-checkbox { + display: flex; + align-items: center; + font-size: 14px; + color: #333; + cursor: pointer; + user-select: none; +} + +.settings-checkbox input[type="checkbox"] { + margin-right: 12px; + width: 18px; + height: 18px; + accent-color: #2563eb; +} + +.settings-button { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.15s ease; +} + +.settings-button.primary { + background: #2563eb; + color: white; +} + +.settings-button.primary:hover { + background: #1d4ed8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); +} + +.settings-button.secondary { + background: rgba(0, 0, 0, 0.04); + color: #666; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.settings-button.secondary:hover { + background: rgba(0, 0, 0, 0.06); + color: #333; +} + +/* Footer */ +.settings-modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 32px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.08); +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .settings-modal { + background: rgba(40, 40, 40, 0.95); + color: #e5e5e5; + } + + .settings-modal-header h2 { + color: #e5e5e5; + } + + .settings-modal-close { + color: #a1a1a1; + } + + .settings-modal-close:hover { + background: rgba(255, 255, 255, 0.1); + color: #e5e5e5; + } + + .settings-tabs { + background: rgba(255, 255, 255, 0.03); + border-right-color: rgba(255, 255, 255, 0.1); + } + + .settings-tab { + color: #a1a1a1; + } + + .settings-tab:hover { + background: rgba(255, 255, 255, 0.05); + color: #e5e5e5; + } + + .settings-section h3 { + color: #e5e5e5; + } + + .settings-label { + color: #d1d1d1; + } + + .settings-input, + .settings-select { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); + color: #e5e5e5; + } + + .settings-input:focus, + .settings-select:focus { + background: rgba(255, 255, 255, 0.08); + } + + .settings-checkbox { + color: #d1d1d1; + } + + .settings-button.secondary { + background: rgba(255, 255, 255, 0.05); + color: #a1a1a1; + border-color: rgba(255, 255, 255, 0.1); + } + + .settings-button.secondary:hover { + background: rgba(255, 255, 255, 0.1); + color: #e5e5e5; + } + + .settings-modal-footer { + border-top-color: rgba(255, 255, 255, 0.1); + } +} + +/* Responsive design */ +@media (max-width: 768px) { + .settings-modal { + width: 95vw; + height: 90vh; + } + + .settings-modal-content { + flex-direction: column; + } + + .settings-tabs { + width: 100%; + display: flex; + overflow-x: auto; + border-right: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + padding: 8px 16px; + } + + .settings-tab { + white-space: nowrap; + padding: 8px 16px; + margin-right: 8px; + border-radius: 8px; + } + + .settings-content { + padding: 16px 24px; + } +} diff --git a/apps/electron-app/src/renderer/src/components/modals/SettingsModal.tsx b/apps/electron-app/src/renderer/src/components/modals/SettingsModal.tsx new file mode 100644 index 0000000..6cc37d6 --- /dev/null +++ b/apps/electron-app/src/renderer/src/components/modals/SettingsModal.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from "react"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("SettingsModal"); + +interface SettingsModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const SettingsModal: React.FC = ({ + isOpen, + onClose, +}) => { + // Show/hide dialog based on isOpen state + useEffect(() => { + if (isOpen) { + window.electron?.ipcRenderer + .invoke("dialog:show-settings") + .catch(error => { + logger.error("Failed to show settings dialog:", error); + }); + } + }, [isOpen]); + + // Listen for dialog close events + useEffect(() => { + const handleDialogClosed = (_event: any, dialogType: string) => { + if (dialogType === "settings") { + onClose(); + } + }; + + window.electron?.ipcRenderer.on("dialog-closed", handleDialogClosed); + + return () => { + window.electron?.ipcRenderer.removeListener( + "dialog-closed", + handleDialogClosed, + ); + }; + }, [onClose]); + + // Don't render anything in React tree - dialog is handled by main process + return null; +}; diff --git a/apps/electron-app/src/renderer/src/hooks/useOnlineStatus.ts b/apps/electron-app/src/renderer/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..ccfe6f9 --- /dev/null +++ b/apps/electron-app/src/renderer/src/hooks/useOnlineStatus.ts @@ -0,0 +1,60 @@ +import { useState, useEffect } from "react"; + +/** + * Hook to monitor online/offline status + * @returns {boolean} Whether the browser is online + */ +export function useOnlineStatus(): boolean { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const updateOnlineStatus = () => { + setIsOnline(navigator.onLine); + }; + + window.addEventListener("online", updateOnlineStatus); + window.addEventListener("offline", updateOnlineStatus); + + // Check initial status + updateOnlineStatus(); + + return () => { + window.removeEventListener("online", updateOnlineStatus); + window.removeEventListener("offline", updateOnlineStatus); + }; + }, []); + + return isOnline; +} + +/** + * Utility function to check online status imperatively + */ +export function checkOnlineStatus(): boolean { + return navigator.onLine; +} + +/** + * Subscribe to online status changes + * @param callback Function to call when online status changes + * @returns Cleanup function to unsubscribe + */ +export function subscribeToOnlineStatus( + callback: (isOnline: boolean) => void, +): () => void { + const updateStatus = () => { + callback(navigator.onLine); + }; + + window.addEventListener("online", updateStatus); + window.addEventListener("offline", updateStatus); + + // Call immediately with current status + updateStatus(); + + // Return cleanup function + return () => { + window.removeEventListener("online", updateStatus); + window.removeEventListener("offline", updateStatus); + }; +} diff --git a/apps/electron-app/src/renderer/src/hooks/usePasswords.ts b/apps/electron-app/src/renderer/src/hooks/usePasswords.ts new file mode 100644 index 0000000..6f012b5 --- /dev/null +++ b/apps/electron-app/src/renderer/src/hooks/usePasswords.ts @@ -0,0 +1,474 @@ +import { useState, useEffect, useCallback } from "react"; +import type { PasswordEntry } from "../types/passwords"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("passwords-hook"); + +export function usePasswords(loadOnMount: boolean = true) { + const [passwords, setPasswords] = useState([]); + const [filteredPasswords, setFilteredPasswords] = useState( + [], + ); + const [searchQuery, setSearchQuery] = useState(""); + const [isPasswordModalVisible, setIsPasswordModalVisible] = useState(false); + const [selectedPassword, setSelectedPassword] = + useState(null); + const [showPassword, setShowPassword] = useState(false); + const [importSources, setImportSources] = useState([]); + const [loading, setLoading] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + const [statusType, setStatusType] = useState<"success" | "error" | "info">( + "info", + ); + const [isImporting, setIsImporting] = useState(false); + const [importedSources, setImportedSources] = useState>( + new Set(), + ); + const [progressValue, setProgressValue] = useState(0); + const [progressText, setProgressText] = useState(""); + + const showMessage = useCallback( + (message: string, type: "success" | "error" | "info" = "success") => { + setStatusMessage(message); + setStatusType(type); + setTimeout(() => setStatusMessage(""), 5000); + }, + [], + ); + + const clearMessage = useCallback(() => { + setStatusMessage(""); + }, []); + + const loadPasswords = useCallback(async () => { + try { + setLoading(true); + if (window.electron?.ipcRenderer) { + const result = + await window.electron.ipcRenderer.invoke("passwords:get-all"); + if (result.success) { + console.log( + "[usePasswords] Loaded passwords:", + result.passwords?.length || 0, + ); + setPasswords(result.passwords || []); + } else { + console.error("[usePasswords] Failed to load passwords:", result); + showMessage("Failed to load passwords", "error"); + } + } + } catch { + showMessage("Failed to load passwords", "error"); + } finally { + setLoading(false); + } + }, [showMessage]); + + const loadImportSources = useCallback(async () => { + try { + if (window.electron?.ipcRenderer) { + const result = await window.electron.ipcRenderer.invoke( + "passwords:get-sources", + ); + if (result.success) { + setImportSources(result.sources || []); + } + } + } catch { + logger.error("Failed to load import sources"); + } + }, []); + + const handleImportFromChrome = useCallback(async () => { + if (importedSources.has("chrome")) { + showMessage( + 'Chrome passwords already imported. Use "Clear All" to re-import.', + "info", + ); + return; + } + + try { + setIsImporting(true); + setProgressValue(20); + setProgressText("Connecting to Chrome database..."); + + // Get window ID for progress bar + const windowId = await window.electron.ipcRenderer.invoke( + "interface:get-window-id", + ); + + // Listen for progress updates + const progressHandler = ( + _event: any, + data: { progress: number; message: string }, + ) => { + setProgressValue(Math.min(data.progress, 94)); + setProgressText(data.message); + }; + + window.electron.ipcRenderer.on("chrome-import-progress", progressHandler); + + const result = await window.electron.ipcRenderer.invoke( + "passwords:import-chrome", + windowId, + ); + + // Remove listener + window.electron.ipcRenderer.removeListener( + "chrome-import-progress", + progressHandler, + ); + + if (result && result.success) { + // Animate to 100% completion + await new Promise(resolve => { + const animateToComplete = () => { + setProgressValue(prev => { + if (prev >= 100) { + setProgressText("Import complete!"); + resolve(); + return 100; + } + return Math.min(prev + 5, 100); + }); + }; + + const interval = setInterval(() => { + animateToComplete(); + }, 20); + + // Safety timeout + setTimeout(() => { + clearInterval(interval); + setProgressValue(100); + setProgressText("Import complete!"); + resolve(); + }, 1000); + }); + + showMessage( + `Successfully imported ${result.count || 0} passwords from Chrome`, + ); + setImportedSources(prev => new Set(prev).add("chrome")); + await loadPasswords(); + await loadImportSources(); + } else { + showMessage(result?.error || "Failed to import from Chrome", "error"); + } + } catch (error) { + showMessage( + `Failed to import from Chrome: ${error instanceof Error ? error.message : "Unknown error"}`, + "error", + ); + } finally { + setIsImporting(false); + setProgressValue(0); + setProgressText(""); + } + }, [importedSources, loadPasswords, loadImportSources, showMessage]); + + useEffect(() => { + if (loadOnMount) { + loadPasswords(); + loadImportSources(); + } + }, [loadPasswords, loadImportSources, loadOnMount]); + + useEffect(() => { + if (!searchQuery) { + setFilteredPasswords(passwords); + } else { + const lowercasedQuery = searchQuery.toLowerCase(); + const filtered = passwords.filter(p => { + try { + const urlMatch = + p.url && p.url.toLowerCase().includes(lowercasedQuery); + const usernameMatch = + p.username && p.username.toLowerCase().includes(lowercasedQuery); + return urlMatch || usernameMatch; + } catch (error) { + console.error("[usePasswords] Error filtering password:", error, p); + return false; + } + }); + console.log( + `[usePasswords] Filtered ${filtered.length} passwords from ${passwords.length} total`, + ); + setFilteredPasswords(filtered); + } + }, [passwords, searchQuery]); + + useEffect(() => { + const handleChromeImportTrigger = () => handleImportFromChrome(); + window.electron?.ipcRenderer.on( + "trigger-chrome-import", + handleChromeImportTrigger, + ); + return () => { + window.electron?.ipcRenderer.removeListener( + "trigger-chrome-import", + handleChromeImportTrigger, + ); + }; + }, [handleImportFromChrome]); + + const handleImportAllChromeProfiles = useCallback(async () => { + if (importedSources.has("chrome-all-profiles")) { + showMessage( + 'All Chrome profiles already imported. Use "Clear All" to re-import.', + "info", + ); + return; + } + + try { + setIsImporting(true); + setProgressValue(10); + setProgressText("Starting import from all Chrome profiles..."); + + // Get the current window ID to enable progress bar + const windowId = await window.electron.ipcRenderer.invoke( + "interface:get-window-id", + ); + console.log("[usePasswords] Window ID for progress:", windowId); + + // Listen for progress updates + const progressHandler = ( + _event: any, + data: { progress: number; message: string }, + ) => { + console.log("[usePasswords] Progress update:", data); + setProgressValue(Math.min(data.progress, 94)); + setProgressText(data.message); + }; + + window.electron.ipcRenderer.on("chrome-import-progress", progressHandler); + + const result = await window.electron.ipcRenderer.invoke( + "chrome:import-all-profiles", + windowId, + ); + + // Remove listener + window.electron.ipcRenderer.removeListener( + "chrome-import-progress", + progressHandler, + ); + + if (result && result.success) { + // Animate to 100% completion + await new Promise(resolve => { + const animateToComplete = () => { + setProgressValue(prev => { + if (prev >= 100) { + setProgressText("Import complete!"); + resolve(); + return 100; + } + return Math.min(prev + 5, 100); + }); + }; + + const interval = setInterval(() => { + animateToComplete(); + }, 20); + + // Safety timeout + setTimeout(() => { + clearInterval(interval); + setProgressValue(100); + setProgressText("Import complete!"); + resolve(); + }, 1000); + }); + + showMessage( + `Successfully imported from all Chrome profiles: ${result.passwordCount || 0} passwords, ${result.bookmarkCount || 0} bookmarks, ${result.historyCount || 0} history entries`, + ); + setImportedSources(prev => new Set(prev).add("chrome-all-profiles")); + await loadPasswords(); + await loadImportSources(); + } else { + showMessage( + result?.error || "Failed to import from Chrome profiles", + "error", + ); + } + } catch (error) { + showMessage( + `Failed to import from Chrome profiles: ${error instanceof Error ? error.message : "Unknown error"}`, + "error", + ); + } finally { + setIsImporting(false); + setProgressValue(0); + setProgressText(""); + } + }, [importedSources, loadPasswords, loadImportSources, showMessage]); + + // This function is not being used - handleImportAllChromeProfiles is used instead + const handleComprehensiveImportFromChrome = useCallback(async () => { + // Redirect to the correct handler + return handleImportAllChromeProfiles(); + }, [handleImportAllChromeProfiles]); + + const handleImportChromeBookmarks = useCallback(async () => { + // Implementation can be moved here... + }, []); + + const handleImportChromeHistory = useCallback(async () => { + // Implementation can be moved here... + }, []); + + const handleImportChromeAutofill = useCallback(async () => { + // Implementation can be moved here... + }, []); + + const handleExportPasswords = useCallback(async () => { + try { + const result = + await window.electron.ipcRenderer.invoke("passwords:export"); + if (result.success) { + showMessage("Passwords exported successfully"); + } else { + showMessage(result.error || "Failed to export passwords", "error"); + } + } catch { + showMessage("Failed to export passwords", "error"); + } + }, [showMessage]); + + const handleDeletePassword = useCallback( + async (passwordId: string) => { + try { + const result = await window.electron.ipcRenderer.invoke( + "passwords:delete", + passwordId, + ); + if (result.success) { + showMessage("Password deleted successfully"); + await loadPasswords(); + } else { + showMessage("Failed to delete password", "error"); + } + } catch { + showMessage("Failed to delete password", "error"); + } + }, + [loadPasswords, showMessage], + ); + + const handleClearAllPasswords = useCallback(async () => { + try { + const result = await window.electron.ipcRenderer.invoke( + "passwords:clear-all", + ); + if (result.success) { + showMessage("All passwords cleared"); + setPasswords([]); + setImportSources([]); + setImportedSources(new Set()); + } else { + showMessage("Failed to clear passwords", "error"); + } + } catch { + showMessage("Failed to clear passwords", "error"); + } + }, [showMessage]); + + const handleRemoveSource = useCallback( + async (source: string) => { + try { + const result = await window.electron.ipcRenderer.invoke( + "passwords:remove-source", + source, + ); + if (result.success) { + showMessage(`Removed passwords from ${source}`); + setImportedSources(prev => { + const updated = new Set(prev); + updated.delete(source); + return updated; + }); + await loadPasswords(); + await loadImportSources(); + } else { + showMessage("Failed to remove import source", "error"); + } + } catch { + showMessage("Failed to remove import source", "error"); + } + }, + [loadPasswords, loadImportSources, showMessage], + ); + + const handleViewPassword = useCallback( + async (password: PasswordEntry) => { + try { + const result = await window.electron.ipcRenderer.invoke( + "passwords:decrypt", + password.id, + ); + if (result.success) { + setSelectedPassword({ + ...password, + password: result.decryptedPassword, + }); + setIsPasswordModalVisible(true); + setShowPassword(false); + } else { + showMessage("Failed to decrypt password", "error"); + } + } catch { + showMessage("Failed to decrypt password", "error"); + } + }, + [showMessage], + ); + + const copyToClipboard = useCallback( + (text: string) => { + navigator.clipboard + .writeText(text) + .then(() => showMessage("Copied to clipboard")) + .catch(() => showMessage("Failed to copy to clipboard", "error")); + }, + [showMessage], + ); + + return { + passwords, + filteredPasswords, + searchQuery, + setSearchQuery, + isPasswordModalVisible, + setIsPasswordModalVisible, + selectedPassword, + showPassword, + setShowPassword, + importSources, + loading, + statusMessage, + statusType, + isImporting, + importedSources, + progressValue, + progressText, + handleImportFromChrome, + handleComprehensiveImportFromChrome, + handleImportAllChromeProfiles, + handleImportChromeBookmarks, + handleImportChromeHistory, + handleImportChromeAutofill, + handleExportPasswords, + handleDeletePassword, + handleClearAllPasswords, + handleRemoveSource, + handleViewPassword, + copyToClipboard, + loadPasswords, + loadImportSources, + clearMessage, + }; +} diff --git a/apps/electron-app/src/renderer/src/hooks/usePrivyAuth.ts b/apps/electron-app/src/renderer/src/hooks/usePrivyAuth.ts new file mode 100644 index 0000000..a512d4d --- /dev/null +++ b/apps/electron-app/src/renderer/src/hooks/usePrivyAuth.ts @@ -0,0 +1,71 @@ +import { useState, useEffect } from "react"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("privy-auth"); + +interface PrivyUser { + address?: string; + email?: string; + name?: string; +} + +/** + * Mock Privy authentication hook + * Replace this with actual @privy-io/react-auth when integrated + */ +export function usePrivyAuth() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Check for stored auth state + const checkAuth = async () => { + try { + // This would be replaced with actual Privy auth check + const storedAuth = localStorage.getItem("privy-auth"); + if (storedAuth) { + const authData = JSON.parse(storedAuth); + setIsAuthenticated(true); + setUser(authData.user); + } + } catch (error) { + logger.error("Failed to check auth:", error); + } finally { + setIsLoading(false); + } + }; + + checkAuth(); + }, []); + + const login = async () => { + // This would be replaced with actual Privy login + logger.info("Privy login would be triggered here"); + // For demo purposes: + const mockUser = { + address: "0x1234567890abcdef1234567890abcdef12345678", + email: "user@example.com", + name: "Demo User", + }; + + localStorage.setItem("privy-auth", JSON.stringify({ user: mockUser })); + setIsAuthenticated(true); + setUser(mockUser); + }; + + const logout = async () => { + // This would be replaced with actual Privy logout + localStorage.removeItem("privy-auth"); + setIsAuthenticated(false); + setUser(null); + }; + + return { + isAuthenticated, + user, + isLoading, + login, + logout, + }; +} diff --git a/apps/electron-app/src/renderer/src/hooks/useUserProfileStore.ts b/apps/electron-app/src/renderer/src/hooks/useUserProfileStore.ts new file mode 100644 index 0000000..328c450 --- /dev/null +++ b/apps/electron-app/src/renderer/src/hooks/useUserProfileStore.ts @@ -0,0 +1,70 @@ +/** + * Hook for accessing user profile store in the renderer + * This provides a bridge to the main process user profile store + */ + +import { useState, useEffect } from "react"; +import { createLogger } from "@vibe/shared-types"; + +const logger = createLogger("user-profile-store"); + +export interface DownloadHistoryItem { + id: string; + fileName: string; + filePath: string; + createdAt: number; +} + +export interface UserProfile { + id: string; + name: string; + createdAt: number; + lastActive: number; + navigationHistory: any[]; + downloads?: DownloadHistoryItem[]; + settings?: { + defaultSearchEngine?: string; + theme?: string; + [key: string]: any; + }; +} + +export function useUserProfileStore() { + const [activeProfile, setActiveProfile] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadProfile = async () => { + try { + const profile = await window.vibe?.profile?.getActiveProfile(); + setActiveProfile(profile); + } catch (error) { + logger.error("Failed to load user profile:", error); + } finally { + setLoading(false); + } + }; + + loadProfile(); + }, []); + + // Refresh profile data periodically + useEffect(() => { + const interval = setInterval(async () => { + try { + const profile = await window.vibe?.profile?.getActiveProfile(); + setActiveProfile(profile); + } catch (error) { + logger.error("Failed to refresh user profile:", error); + } + }, 5000); // Refresh every 5 seconds + + return () => clearInterval(interval); + }, []); + + return { + activeProfile, + loading, + downloads: activeProfile?.downloads || [], + }; +} diff --git a/apps/electron-app/src/renderer/src/pages/settings/SettingsPage.tsx b/apps/electron-app/src/renderer/src/pages/settings/SettingsPage.tsx new file mode 100644 index 0000000..de1259d --- /dev/null +++ b/apps/electron-app/src/renderer/src/pages/settings/SettingsPage.tsx @@ -0,0 +1,1298 @@ +// SettingsPane.tsx +import { useState, useEffect } from "react"; +import { + AppstoreOutlined, + SettingOutlined, + UserOutlined, + BellOutlined, + SafetyOutlined, + SyncOutlined, + ThunderboltOutlined, + CloudOutlined, + KeyOutlined, + DownloadOutlined, + GlobalOutlined, + LeftOutlined, + RightOutlined, +} from "@ant-design/icons"; +import type { MenuProps } from "antd"; +import { + Menu, + Layout, + Card, + Switch, + Select, + Input, + Button, + Typography, + Space, + message, +} from "antd"; + +const { Sider, Content } = Layout; +const { Title, Text } = Typography; +const { Option } = Select; + +type MenuItem = Required["items"][number]; + +interface LevelKeysProps { + key?: string; + children?: LevelKeysProps[]; +} + +const getLevelKeys = (items1: LevelKeysProps[]) => { + const key: Record = {}; + const func = (items2: LevelKeysProps[], level = 1) => { + items2.forEach(item => { + if (item.key) { + key[item.key] = level; + } + if (item.children) { + func(item.children, level + 1); + } + }); + }; + func(items1); + return key; +}; + +const items: MenuItem[] = [ + { + key: "general", + icon: , + label: "General", + children: [ + { key: "startup", label: "Startup Behavior" }, + { key: "search", label: "Default Search Engine" }, + { key: "language", label: "Language" }, + { key: "theme", label: "Theme" }, + ], + }, + { + key: "accounts", + icon: , + label: "Accounts", + children: [ + { key: "apple", label: "Apple Account" }, + { key: "google", label: "Google Account" }, + { key: "sync", label: "Sync Settings" }, + ], + }, + { + key: "api", + icon: , + label: "API Keys", + children: [{ key: "api-keys", label: "Manage API Keys" }], + }, + { + key: "performance", + icon: , + label: "Performance", + children: [ + { key: "hardware", label: "Hardware Acceleration" }, + { key: "memory", label: "Memory Management" }, + { key: "cache", label: "Cache Settings" }, + ], + }, + { + key: "privacy", + icon: , + label: "Privacy & Security", + children: [{ key: "adblocking", label: "AdBlocking" }], + }, + { + key: "notifications", + icon: , + label: "Notifications", + children: [ + { key: "browser", label: "Browser Notifications" }, + { key: "system", label: "System Notifications" }, + { key: "sounds", label: "Notification Sounds" }, + { key: "tray", label: "System Tray" }, + ], + }, + { + key: "sync", + icon: , + label: "Sync", + children: [ + { key: "enable", label: "Enable Sync" }, + { key: "frequency", label: "Sync Frequency" }, + { key: "data", label: "Sync Data Types" }, + ], + }, + { + key: "extensions", + icon: , + label: "Extensions", + children: [ + { key: "installed", label: "Installed Extensions" }, + { key: "permissions", label: "Extension Permissions" }, + ], + }, + { + key: "shortcuts", + icon: , + label: "Keyboard Shortcuts", + children: [ + { key: "navigation", label: "Navigation" }, + { key: "tabs", label: "Tab Management" }, + { key: "browser", label: "Browser Actions" }, + ], + }, + { + key: "updates", + icon: , + label: "Updates", + children: [ + { key: "auto", label: "Auto Update" }, + { key: "channel", label: "Update Channel" }, + { key: "check", label: "Check for Updates" }, + ], + }, + { + key: "storage", + icon: , + label: "Storage", + children: [ + { key: "cache", label: "Cache Management" }, + { key: "downloads", label: "Download Location" }, + { key: "data", label: "Data Usage" }, + ], + }, + { + key: "location", + icon: , + label: "Location", + children: [ + { key: "access", label: "Location Access" }, + { key: "permissions", label: "Site Permissions" }, + ], + }, +]; + +const levelKeys = getLevelKeys(items as LevelKeysProps[]); + +const renderContent = ( + selectedKey: string, + apiKeys?: any, + passwordVisible?: any, + handleApiKeyChange?: any, + saveApiKeys?: any, + setPasswordVisible?: any, + trayEnabled?: boolean, + onTrayToggle?: (enabled: boolean) => void, + passwordPasteHotkey?: string, + onPasswordPasteHotkeyChange?: (hotkey: string) => void, +) => { + switch (selectedKey) { + case "startup": + return ( +
+ + +
+ When Vibe starts: + +
+
+ Homepage: + +
+
+
+
+ ); + + case "search": + return ( +
+ + +
+ Search Engine: + +
+
+ Custom Search URL: + +
+
+
+
+ ); + + case "hardware": + return ( +
+ + +
+
+ Use GPU acceleration +
+ + Use GPU acceleration when available for better performance + +
+ +
+
+
+ Hardware video decoding +
+ + Use hardware acceleration for video playback + +
+ +
+
+
+
+ ); + + case "adblocking": + return ( +
+ +
+
+ + AdBlocking (via Ghostery) + +
+ + Block ads and trackers to improve browsing speed and privacy + +
+ +
+
+
+ ); + + case "browser": + return ( +
+ + +
+
+ Allow notifications from websites +
+ + Show notifications from websites you visit + +
+ +
+
+
+ System notifications +
+ + Show system notifications for browser events + +
+ +
+
+
+
+ ); + + case "tray": + return ( +
+ + +
+
+ Show system tray icon +
+ + Display Vibe icon in the system tray for quick access + +
+ +
+
+
+
+ ); + + case "enable": + return ( +
+ + +
+
+ Sync your data across devices +
+ + Keep your bookmarks, history, and settings in sync + +
+ +
+
+ Sync Frequency: + +
+
+
+
+ ); + + case "navigation": + return ( +
+ + +
+ New Tab + ⌘T +
+
+ Close Tab + ⌘W +
+
+ Go Back + ⌘← +
+
+ Go Forward + ⌘→ +
+
+ Refresh + ⌘R +
+
+
+
+ ); + + case "browser-actions": + return ( +
+ + +
+
+ Paste Password +
+ + Paste the most recent password for the current website + +
+ onPasswordPasteHotkeyChange?.(e.target.value)} + style={{ width: 120, textAlign: "center" }} + placeholder="⌘⇧P" + /> +
+
+
+
+ ); + + case "auto": + return ( +
+ + +
+
+ Automatically download and install updates +
+ + Keep Vibe up to date with the latest features and security + patches + +
+ +
+
+ Update Channel: + +
+
+ +
+
+
+
+ ); + + case "cache": + return ( +
+ + +
+
+ Current cache size: 45.2 MB +
+ + Temporary files stored to improve browsing speed + +
+ +
+
+
+ Download folder +
+ ~/Downloads +
+ +
+
+
+
+ ); + + case "access": + return ( +
+ + +
+ Location Access: + +
+
+
+ Remember location permissions +
+ + Remember your choice for each website + +
+ +
+
+
+
+ ); + + case "api-keys": + return ( +
+ + +
+
+ OpenAI API Key +
+ Used for AI-powered features +
+ handleApiKeyChange?.("openai", e.target.value)} + onBlur={() => saveApiKeys?.()} + visibilityToggle={{ + visible: passwordVisible?.openai || false, + onVisibleChange: visible => + setPasswordVisible?.({ + ...passwordVisible, + openai: visible, + }), + }} + /> +
+
+
+ TurboPuffer API Key +
+ + Used for vector search and embeddings + +
+ + handleApiKeyChange?.("turbopuffer", e.target.value) + } + onBlur={() => saveApiKeys?.()} + visibilityToggle={{ + visible: passwordVisible?.turbopuffer || false, + onVisibleChange: visible => + setPasswordVisible?.({ + ...passwordVisible, + turbopuffer: visible, + }), + }} + /> +
+
+
+
+ ); + + default: + return ( +
+ +
+ + Select a setting to configure + + Choose an option from the menu to view and modify settings + +
+
+
+ ); + } +}; + +export function SettingsPane() { + const [stateOpenKeys, setStateOpenKeys] = useState(["general"]); + const [selectedKey, setSelectedKey] = useState("adblocking"); + const [apiKeys, setApiKeys] = useState({ openai: "", turbopuffer: "" }); + const [passwordVisible, setPasswordVisible] = useState({ + openai: false, + turbopuffer: false, + }); + const [trayEnabled, setTrayEnabled] = useState(true); + const [passwordPasteHotkey, setPasswordPasteHotkey] = useState("⌘⇧P"); + + // Load API keys and tray setting from profile on mount + useEffect(() => { + loadApiKeys(); + loadTraySetting(); + loadPasswordPasteHotkey(); + }, []); + + const loadApiKeys = async () => { + try { + const [openaiKey, turbopufferKey] = await Promise.all([ + window.apiKeys.get("openai"), + window.apiKeys.get("turbopuffer"), + ]); + setApiKeys({ + openai: openaiKey || "", + turbopuffer: turbopufferKey || "", + }); + } catch (error) { + console.error("Failed to load API keys:", error); + message.error("Failed to load API keys"); + } + }; + + const loadTraySetting = async () => { + try { + const { ipcRenderer } = await import("electron"); + const trayEnabled = await ipcRenderer.invoke( + "settings:get", + "tray.enabled", + ); + setTrayEnabled(trayEnabled ?? true); // Default to true if not set + } catch (error) { + console.error("Failed to load tray setting:", error); + setTrayEnabled(true); // Default to true on error + } + }; + + const loadPasswordPasteHotkey = async () => { + try { + const { ipcRenderer } = await import("electron"); + const result = await ipcRenderer.invoke("hotkeys:get-password-paste"); + if (result.success) { + setPasswordPasteHotkey(result.hotkey); + } + } catch (error) { + console.error("Failed to load password paste hotkey:", error); + setPasswordPasteHotkey("⌘⇧P"); // Default hotkey + } + }; + + const handleApiKeyChange = (key: "openai" | "turbopuffer", value: string) => { + setApiKeys({ ...apiKeys, [key]: value }); + }; + + const saveApiKeys = async () => { + try { + const results = await Promise.all([ + apiKeys.openai + ? window.apiKeys.set("openai", apiKeys.openai) + : Promise.resolve(true), + apiKeys.turbopuffer + ? window.apiKeys.set("turbopuffer", apiKeys.turbopuffer) + : Promise.resolve(true), + ]); + + if (results.every(result => result)) { + message.success("API keys saved successfully"); + } else { + message.error("Failed to save some API keys"); + } + } catch (error) { + console.error("Failed to save API keys:", error); + message.error("Failed to save API keys"); + } + }; + + const handleTrayToggle = async (enabled: boolean) => { + try { + const { ipcRenderer } = await import("electron"); + if (enabled) { + await ipcRenderer.invoke("tray:create"); + } else { + await ipcRenderer.invoke("tray:destroy"); + } + // Save setting + await ipcRenderer.invoke("settings:set", "tray.enabled", enabled); + setTrayEnabled(enabled); + message.success(`System tray ${enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error("Failed to toggle tray:", error); + message.error("Failed to toggle system tray"); + } + }; + + const handlePasswordPasteHotkeyChange = async (hotkey: string) => { + try { + const { ipcRenderer } = await import("electron"); + const result = await ipcRenderer.invoke( + "hotkeys:set-password-paste", + hotkey, + ); + if (result.success) { + setPasswordPasteHotkey(hotkey); + message.success("Password paste hotkey updated"); + } else { + message.error("Failed to update hotkey"); + } + } catch (error) { + console.error("Failed to update password paste hotkey:", error); + message.error("Failed to update hotkey"); + } + }; + + const onOpenChange: MenuProps["onOpenChange"] = openKeys => { + const currentOpenKey = openKeys.find( + key => stateOpenKeys.indexOf(key) === -1, + ); + + if (currentOpenKey !== undefined) { + const repeatIndex = openKeys + .filter(key => key !== currentOpenKey) + .findIndex(key => levelKeys[key] === levelKeys[currentOpenKey]); + + setStateOpenKeys( + openKeys + .filter((_, index) => index !== repeatIndex) + .filter(key => levelKeys[key] <= levelKeys[currentOpenKey]), + ); + } else { + setStateOpenKeys(openKeys); + } + }; + + const handleMenuClick: MenuProps["onClick"] = ({ key }) => { + setSelectedKey(key); + }; + + return ( + + {/* Traffic Lights */} +
+
+
+
+
+ + + + + + + {/* Header with navigation and title */} +
+
+
+ + Settings + +
+ + +
+ {renderContent( + selectedKey, + apiKeys, + passwordVisible, + handleApiKeyChange, + saveApiKeys, + setPasswordVisible, + trayEnabled, + handleTrayToggle, + passwordPasteHotkey, + handlePasswordPasteHotkeyChange, + )} +
+
+
+ + ); +} diff --git a/apps/electron-app/src/renderer/src/settings-entry.tsx b/apps/electron-app/src/renderer/src/settings-entry.tsx new file mode 100644 index 0000000..a81e480 --- /dev/null +++ b/apps/electron-app/src/renderer/src/settings-entry.tsx @@ -0,0 +1,33 @@ +/** + * Settings entry point for the settings dialog + * Initializes the React settings application + */ + +import "./components/styles/index.css"; +import "antd/dist/reset.css"; + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { init } from "@sentry/electron/renderer"; +import SettingsApp from "./Settings"; + +const isProd = process.env.NODE_ENV === "production"; + +init({ + debug: !isProd, + tracesSampleRate: isProd ? 0.05 : 1.0, + maxBreadcrumbs: 50, + beforeBreadcrumb: breadcrumb => { + if (isProd && breadcrumb.category === "console") { + return null; + } + return breadcrumb; + }, +}); + +// Create the root element and render the settings application +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/electron-app/src/renderer/src/types/passwords.ts b/apps/electron-app/src/renderer/src/types/passwords.ts new file mode 100644 index 0000000..4ea753d --- /dev/null +++ b/apps/electron-app/src/renderer/src/types/passwords.ts @@ -0,0 +1,9 @@ +export interface PasswordEntry { + id: string; + url: string; + username: string; + password: string; + source: "chrome" | "safari" | "csv" | "manual"; + dateCreated?: Date; + lastModified?: Date; +}