From a5f939511dd95f3a40a9dd07dda71eca42ab49c4 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 22 Oct 2025 02:58:57 +0900 Subject: [PATCH 1/6] feat: implement bookmark management system with favorites page --- apps/browser/assets/start-page.html | 318 ++++++++++++++++++ apps/browser/src/main/bookmark-manager.ts | 162 +++++++++ apps/browser/src/main/index.ts | 4 +- apps/browser/src/main/ipc-handlers.ts | 74 ++++ apps/browser/src/main/tab-manager.ts | 19 +- apps/browser/src/main/window-manager.ts | 4 +- apps/browser/src/preload.ts | 19 ++ apps/browser/src/renderer/app.tsx | 32 +- .../src/renderer/components/menu-overlay.tsx | 130 +++++++ .../components/navigation-controls.tsx | 11 + .../src/renderer/components/settings.tsx | 184 +++++++++- .../src/renderer/components/top-bar.tsx | 3 + apps/browser/src/types/electron-api.d.ts | 22 ++ 13 files changed, 968 insertions(+), 14 deletions(-) create mode 100644 apps/browser/assets/start-page.html create mode 100644 apps/browser/src/main/bookmark-manager.ts create mode 100644 apps/browser/src/renderer/components/menu-overlay.tsx diff --git a/apps/browser/assets/start-page.html b/apps/browser/assets/start-page.html new file mode 100644 index 0000000..2afa4d2 --- /dev/null +++ b/apps/browser/assets/start-page.html @@ -0,0 +1,318 @@ + + + + + + New Tab + + + +
+

Favorites

+
+ +
+ +
+ + + + diff --git a/apps/browser/src/main/bookmark-manager.ts b/apps/browser/src/main/bookmark-manager.ts new file mode 100644 index 0000000..4489a10 --- /dev/null +++ b/apps/browser/src/main/bookmark-manager.ts @@ -0,0 +1,162 @@ +/** + * Bookmark management functionality + */ + +import { app } from "electron"; +import path from "path"; +import fs from "fs"; + +export interface Bookmark { + id: string; + title: string; + url: string; + favicon?: string; + createdAt: number; + updatedAt: number; +} + +export class BookmarkManager { + private bookmarksPath: string; + private bookmarks: Bookmark[] = []; + + constructor() { + const userDataPath = app.getPath("userData"); + this.bookmarksPath = path.join(userDataPath, "bookmarks.json"); + this.loadBookmarks(); + } + + /** + * Load bookmarks from file + */ + private loadBookmarks(): void { + try { + if (fs.existsSync(this.bookmarksPath)) { + const data = fs.readFileSync(this.bookmarksPath, "utf-8"); + this.bookmarks = JSON.parse(data); + console.log(`[BookmarkManager] Loaded ${this.bookmarks.length} bookmarks`); + } else { + this.bookmarks = []; + console.log("[BookmarkManager] No bookmarks file found, starting fresh"); + } + } catch (error) { + console.error("[BookmarkManager] Failed to load bookmarks:", error); + this.bookmarks = []; + } + } + + /** + * Save bookmarks to file + */ + private saveBookmarks(): void { + try { + const data = JSON.stringify(this.bookmarks, null, 2); + fs.writeFileSync(this.bookmarksPath, data, "utf-8"); + console.log(`[BookmarkManager] Saved ${this.bookmarks.length} bookmarks`); + } catch (error) { + console.error("[BookmarkManager] Failed to save bookmarks:", error); + } + } + + /** + * Get all bookmarks + */ + getAll(): Bookmark[] { + return [...this.bookmarks]; + } + + /** + * Get bookmark by ID + */ + getById(id: string): Bookmark | undefined { + return this.bookmarks.find((b) => b.id === id); + } + + /** + * Check if URL is bookmarked + */ + isBookmarked(url: string): boolean { + return this.bookmarks.some((b) => b.url === url); + } + + /** + * Add a new bookmark + */ + add(title: string, url: string, favicon?: string): Bookmark { + const now = Date.now(); + const bookmark: Bookmark = { + id: `bookmark-${now}-${Math.random().toString(36).substr(2, 9)}`, + title, + url, + favicon, + createdAt: now, + updatedAt: now, + }; + + this.bookmarks.push(bookmark); + this.saveBookmarks(); + console.log(`[BookmarkManager] Added bookmark: ${title} (${url})`); + return bookmark; + } + + /** + * Update an existing bookmark + */ + update(id: string, updates: Partial>): Bookmark | null { + const index = this.bookmarks.findIndex((b) => b.id === id); + if (index === -1) { + console.error(`[BookmarkManager] Bookmark not found: ${id}`); + return null; + } + + this.bookmarks[index] = { + ...this.bookmarks[index], + ...updates, + updatedAt: Date.now(), + }; + + this.saveBookmarks(); + console.log(`[BookmarkManager] Updated bookmark: ${id}`); + return this.bookmarks[index]; + } + + /** + * Remove a bookmark + */ + remove(id: string): boolean { + const index = this.bookmarks.findIndex((b) => b.id === id); + if (index === -1) { + console.error(`[BookmarkManager] Bookmark not found: ${id}`); + return false; + } + + const removed = this.bookmarks.splice(index, 1)[0]; + this.saveBookmarks(); + console.log(`[BookmarkManager] Removed bookmark: ${removed.title}`); + return true; + } + + /** + * Remove bookmark by URL + */ + removeByUrl(url: string): boolean { + const index = this.bookmarks.findIndex((b) => b.url === url); + if (index === -1) { + console.error(`[BookmarkManager] Bookmark not found for URL: ${url}`); + return false; + } + + const removed = this.bookmarks.splice(index, 1)[0]; + this.saveBookmarks(); + console.log(`[BookmarkManager] Removed bookmark: ${removed.title}`); + return true; + } + + /** + * Clear all bookmarks + */ + clear(): void { + this.bookmarks = []; + this.saveBookmarks(); + console.log("[BookmarkManager] Cleared all bookmarks"); + } +} diff --git a/apps/browser/src/main/index.ts b/apps/browser/src/main/index.ts index 158ee36..ab8d5cd 100644 --- a/apps/browser/src/main/index.ts +++ b/apps/browser/src/main/index.ts @@ -7,6 +7,7 @@ import { AppState } from "./types"; import { ThemeColorCache } from "./theme-cache"; import { TabManager } from "./tab-manager"; import { WindowManager } from "./window-manager"; +import { BookmarkManager } from "./bookmark-manager"; import { IPCHandlers } from "./ipc-handlers"; import { TrayManager } from "./tray-manager"; import { AppLifecycle } from "./app-lifecycle"; @@ -25,10 +26,11 @@ const appState: AppState = { // Initialize managers const themeColorCache = new ThemeColorCache(); +const bookmarkManager = new BookmarkManager(); const tabManager = new TabManager(appState, themeColorCache); const windowManager = new WindowManager(appState, tabManager); const trayManager = new TrayManager(appState, windowManager); -const ipcHandlers = new IPCHandlers(appState, tabManager, windowManager, themeColorCache); +const ipcHandlers = new IPCHandlers(appState, tabManager, windowManager, bookmarkManager, themeColorCache); const appLifecycle = new AppLifecycle(appState, windowManager, trayManager); // Initialize Widevine diff --git a/apps/browser/src/main/ipc-handlers.ts b/apps/browser/src/main/ipc-handlers.ts index dfaca7f..e7bc312 100644 --- a/apps/browser/src/main/ipc-handlers.ts +++ b/apps/browser/src/main/ipc-handlers.ts @@ -6,6 +6,7 @@ import { ipcMain, app, nativeTheme } from "electron"; import { AppState } from "./types"; import { TabManager } from "./tab-manager"; import { WindowManager } from "./window-manager"; +import { BookmarkManager } from "./bookmark-manager"; import { isValidUrl, sanitizeUrl, getUserAgentForUrl, logSecurityEvent } from "./security"; import { ThemeColorCache } from "./theme-cache"; @@ -13,17 +14,20 @@ export class IPCHandlers { private state: AppState; private tabManager: TabManager; private windowManager: WindowManager; + private bookmarkManager: BookmarkManager; private themeColorCache: ThemeColorCache; constructor( state: AppState, tabManager: TabManager, windowManager: WindowManager, + bookmarkManager: BookmarkManager, themeColorCache: ThemeColorCache ) { this.state = state; this.tabManager = tabManager; this.windowManager = windowManager; + this.bookmarkManager = bookmarkManager; this.themeColorCache = themeColorCache; } @@ -37,6 +41,7 @@ export class IPCHandlers { this.registerThemeHandlers(); this.registerOrientationHandlers(); this.registerAppHandlers(); + this.registerBookmarkHandlers(); } /** @@ -359,4 +364,73 @@ export class IPCHandlers { return app.getVersion(); }); } + + /** + * Register bookmark handlers + */ + private registerBookmarkHandlers(): void { + // Get all bookmarks + ipcMain.handle("bookmarks-get-all", () => { + return this.bookmarkManager.getAll(); + }); + + // Get bookmark by ID + ipcMain.handle("bookmarks-get-by-id", (_event, id: string) => { + return this.bookmarkManager.getById(id); + }); + + // Check if URL is bookmarked + ipcMain.handle("bookmarks-is-bookmarked", (_event, url: string) => { + return this.bookmarkManager.isBookmarked(url); + }); + + // Add bookmark + ipcMain.handle("bookmarks-add", (_event, title: string, url: string, favicon?: string) => { + const bookmark = this.bookmarkManager.add(title, url, favicon); + // Notify all windows about bookmark update + if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { + this.state.mainWindow.webContents.send("bookmarks-updated"); + } + return bookmark; + }); + + // Update bookmark + ipcMain.handle("bookmarks-update", (_event, id: string, updates: any) => { + const bookmark = this.bookmarkManager.update(id, updates); + // Notify all windows about bookmark update + if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { + this.state.mainWindow.webContents.send("bookmarks-updated"); + } + return bookmark; + }); + + // Remove bookmark + ipcMain.handle("bookmarks-remove", (_event, id: string) => { + const result = this.bookmarkManager.remove(id); + // Notify all windows about bookmark update + if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { + this.state.mainWindow.webContents.send("bookmarks-updated"); + } + return result; + }); + + // Remove bookmark by URL + ipcMain.handle("bookmarks-remove-by-url", (_event, url: string) => { + const result = this.bookmarkManager.removeByUrl(url); + // Notify all windows about bookmark update + if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { + this.state.mainWindow.webContents.send("bookmarks-updated"); + } + return result; + }); + + // Clear all bookmarks + ipcMain.handle("bookmarks-clear", () => { + this.bookmarkManager.clear(); + // Notify all windows about bookmark update + if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { + this.state.mainWindow.webContents.send("bookmarks-updated"); + } + }); + } } diff --git a/apps/browser/src/main/tab-manager.ts b/apps/browser/src/main/tab-manager.ts index e61ec4b..57caa22 100644 --- a/apps/browser/src/main/tab-manager.ts +++ b/apps/browser/src/main/tab-manager.ts @@ -26,7 +26,7 @@ export class TabManager { /** * Create a new tab */ - createTab(url: string = "https://www.google.com"): Tab { + createTab(url: string = ""): Tab { const tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const webviewPreloadPath = path.join(__dirname, "..", "webview-preload.js"); @@ -78,10 +78,19 @@ export class TabManager { this.state.tabs.push(tab); this.setupWebContentsViewHandlers(view, tabId); - // Load URL - const sanitized = sanitizeUrl(url); - if (isValidUrl(sanitized)) { - view.webContents.loadURL(sanitized); + // Load URL or start page + if (!url || url.trim() === "") { + // Load start page for blank tabs + const { app } = require("electron"); + const startPagePath = path.join(app.getAppPath(), "assets", "start-page.html"); + view.webContents.loadFile(startPagePath).catch((err) => { + console.error("[TabManager] Failed to load start page:", err); + }); + } else { + const sanitized = sanitizeUrl(url); + if (isValidUrl(sanitized)) { + view.webContents.loadURL(sanitized); + } } return tab; diff --git a/apps/browser/src/main/window-manager.ts b/apps/browser/src/main/window-manager.ts index bf29477..4544bfa 100644 --- a/apps/browser/src/main/window-manager.ts +++ b/apps/browser/src/main/window-manager.ts @@ -217,8 +217,8 @@ export class WindowManager { // Register local keyboard shortcuts (only work when window is focused) this.registerLocalShortcuts(); - // Create initial tab - const initialTab = this.tabManager.createTab("https://www.google.com"); + // Create initial blank tab with start page + const initialTab = this.tabManager.createTab(""); this.tabManager.switchToTab(initialTab.id); // Set permission request handler diff --git a/apps/browser/src/preload.ts b/apps/browser/src/preload.ts index 7b27836..fe707e7 100644 --- a/apps/browser/src/preload.ts +++ b/apps/browser/src/preload.ts @@ -175,4 +175,23 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.removeListener("webcontents-http-error", listener); }, }, + + // Bookmark management APIs + bookmarks: { + getAll: () => ipcRenderer.invoke("bookmarks-get-all"), + getById: (id: string) => ipcRenderer.invoke("bookmarks-get-by-id", id), + isBookmarked: (url: string) => ipcRenderer.invoke("bookmarks-is-bookmarked", url), + add: (title: string, url: string, favicon?: string) => + ipcRenderer.invoke("bookmarks-add", title, url, favicon), + update: (id: string, updates: any) => + ipcRenderer.invoke("bookmarks-update", id, updates), + remove: (id: string) => ipcRenderer.invoke("bookmarks-remove", id), + removeByUrl: (url: string) => ipcRenderer.invoke("bookmarks-remove-by-url", url), + clear: () => ipcRenderer.invoke("bookmarks-clear"), + onUpdate: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("bookmarks-updated", listener); + return () => ipcRenderer.removeListener("bookmarks-updated", listener); + }, + }, }); diff --git a/apps/browser/src/renderer/app.tsx b/apps/browser/src/renderer/app.tsx index 6ecd7dd..8d89409 100644 --- a/apps/browser/src/renderer/app.tsx +++ b/apps/browser/src/renderer/app.tsx @@ -4,20 +4,22 @@ import TopBar from "./components/top-bar"; import PhoneFrame from "./components/phone-frame"; import TabOverview from "./components/tab-overview"; import Settings from "./components/settings"; +import MenuOverlay from "./components/menu-overlay"; function App() { const [_time, setTime] = useState("9:41"); - const [pageTitle, setPageTitle] = useState("Loading..."); - const [pageDomain, setPageDomain] = useState("www.google.com"); + const [pageTitle, setPageTitle] = useState("New Tab"); + const [pageDomain, setPageDomain] = useState(""); const [themeColor, setThemeColor] = useState("#ffffff"); const [textColor, setTextColor] = useState("#000000"); const [systemTheme, setSystemTheme] = useState<"light" | "dark">("dark"); - const [currentUrl, setCurrentUrl] = useState("https://www.google.com"); + const [currentUrl, setCurrentUrl] = useState(""); const [orientation, setOrientation] = useState<"portrait" | "landscape">( "portrait" ); const [showTabOverview, setShowTabOverview] = useState(false); const [showSettings, setShowSettings] = useState(false); + const [showMenu, setShowMenu] = useState(false); const [tabCount, setTabCount] = useState(1); const [isFullscreen, setIsFullscreen] = useState(false); const webContainerRef = useRef(null); @@ -573,6 +575,20 @@ function App() { window.electronAPI?.webContents.setVisible(true); }; + const handleShowMenu = () => { + setShowMenu(true); + }; + + const handleCloseMenu = () => { + setShowMenu(false); + }; + + const handleOpenSettingsFromMenu = () => { + setShowSettings(true); + // Hide WebContentsView when showing settings + window.electronAPI?.webContents.setVisible(false); + }; + return (
+ {showMenu && ( + + )}
); } diff --git a/apps/browser/src/renderer/components/menu-overlay.tsx b/apps/browser/src/renderer/components/menu-overlay.tsx new file mode 100644 index 0000000..fbcd282 --- /dev/null +++ b/apps/browser/src/renderer/components/menu-overlay.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect } from "react"; +import { Star, Settings } from "lucide-react"; + +interface MenuOverlayProps { + theme: "light" | "dark"; + currentUrl: string; + currentTitle: string; + onClose: () => void; + onOpenSettings: () => void; +} + +function MenuOverlay({ + theme, + currentUrl, + currentTitle, + onClose, + onOpenSettings, +}: MenuOverlayProps) { + const [isBookmarked, setIsBookmarked] = useState(false); + const isDark = theme === "dark"; + + useEffect(() => { + checkBookmarkStatus(); + }, [currentUrl]); + + const checkBookmarkStatus = async () => { + if (!currentUrl || currentUrl.startsWith("file://")) { + setIsBookmarked(false); + return; + } + + try { + const bookmarked = await window.electronAPI?.bookmarks?.isBookmarked(currentUrl); + setIsBookmarked(bookmarked || false); + } catch (error) { + console.error("Failed to check bookmark status:", error); + setIsBookmarked(false); + } + }; + + const handleToggleBookmark = async () => { + if (!currentUrl || currentUrl.startsWith("file://")) { + return; + } + + try { + if (isBookmarked) { + await window.electronAPI?.bookmarks?.removeByUrl(currentUrl); + setIsBookmarked(false); + } else { + // Get high-resolution favicon + let favicon: string | undefined; + try { + const domain = new URL(currentUrl).origin; + // Try Google's high-res favicon service first (128x128) + favicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; + } catch { + favicon = undefined; + } + + await window.electronAPI?.bookmarks?.add( + currentTitle || "Untitled", + currentUrl, + favicon + ); + setIsBookmarked(true); + } + } catch (error) { + console.error("Failed to toggle bookmark:", error); + } + }; + + const handleSettingsClick = () => { + onClose(); + onOpenSettings(); + }; + + const isStartPage = currentUrl.startsWith("file://") && currentUrl.includes("start-page.html"); + + return ( +
+
e.stopPropagation()} + > +
+ {!isStartPage && ( + + )} + +
+
+
+ ); +} + +export default MenuOverlay; diff --git a/apps/browser/src/renderer/components/navigation-controls.tsx b/apps/browser/src/renderer/components/navigation-controls.tsx index f11027e..25d485b 100644 --- a/apps/browser/src/renderer/components/navigation-controls.tsx +++ b/apps/browser/src/renderer/components/navigation-controls.tsx @@ -1,6 +1,9 @@ +import { MoreVertical } from 'lucide-react'; + interface NavigationControlsProps { onShowTabs: () => void; onRefresh: () => void; + onShowMenu: () => void; theme: 'light' | 'dark'; tabCount: number; } @@ -8,6 +11,7 @@ interface NavigationControlsProps { function NavigationControls({ onShowTabs, onRefresh, + onShowMenu, theme, tabCount, }: NavigationControlsProps) { @@ -44,6 +48,13 @@ function NavigationControls({ )} + ); } diff --git a/apps/browser/src/renderer/components/settings.tsx b/apps/browser/src/renderer/components/settings.tsx index 7b6ec04..cd021d2 100644 --- a/apps/browser/src/renderer/components/settings.tsx +++ b/apps/browser/src/renderer/components/settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Info, Globe, ChevronRight, ChevronLeft } from "lucide-react"; +import { Info, Globe, ChevronRight, ChevronLeft, Star, Trash2 } from "lucide-react"; interface SettingsProps { theme: "light" | "dark"; @@ -22,17 +22,58 @@ interface SettingsItem { onClick?: () => void; } +interface Bookmark { + id: string; + title: string; + url: string; + favicon?: string; + createdAt: number; + updatedAt: number; +} + function Settings({ theme, orientation, onClose }: SettingsProps) { - const [currentView, setCurrentView] = useState<"main" | "about">("main"); + const [currentView, setCurrentView] = useState<"main" | "about" | "bookmarks">("main"); const [appVersion, setAppVersion] = useState("0.0.0"); + const [bookmarks, setBookmarks] = useState([]); useEffect(() => { // Get app version window.electronAPI?.getAppVersion().then((version: string) => { setAppVersion(version); }); + + // Load bookmarks + loadBookmarks(); + + // Listen for bookmark updates + const unsubscribe = window.electronAPI?.bookmarks?.onUpdate(() => { + loadBookmarks(); + }); + + return () => { + if (unsubscribe) unsubscribe(); + }; }, []); + const loadBookmarks = async () => { + try { + const allBookmarks = await window.electronAPI?.bookmarks?.getAll(); + setBookmarks(allBookmarks || []); + } catch (error) { + console.error("Failed to load bookmarks:", error); + setBookmarks([]); + } + }; + + const handleDeleteBookmark = async (id: string) => { + try { + await window.electronAPI?.bookmarks?.remove(id); + // Bookmarks will be reloaded via onUpdate listener + } catch (error) { + console.error("Failed to delete bookmark:", error); + } + }; + const isDark = theme === "dark"; const settingsSections: SettingsSection[] = [ @@ -40,6 +81,14 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { id: "general", title: "General", items: [ + { + id: "bookmarks", + label: "Favorites", + value: `${bookmarks.length} items`, + icon: , + hasDetail: true, + onClick: () => setCurrentView("bookmarks"), + }, { id: "about", label: "About", @@ -307,6 +356,131 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { ); + const renderBookmarksView = () => ( + <> + {/* Header */} +
+ +

+ Favorites +

+
+
+ + {/* Bookmarks Content */} +
+ {bookmarks.length === 0 ? ( +
+ +

+ No favorites yet.
+ Add your favorite sites from the menu. +

+
+ ) : ( +
+ {bookmarks.map((bookmark) => ( +
+
+ {bookmark.favicon ? ( + { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + const parent = target.parentElement; + if (parent) { + parent.textContent = bookmark.title.charAt(0).toUpperCase(); + } + }} + /> + ) : ( + + {bookmark.title.charAt(0).toUpperCase()} + + )} +
+
+
+ {bookmark.title} +
+
+ {(() => { + try { + return new URL(bookmark.url).hostname; + } catch { + return bookmark.url; + } + })()} +
+
+ +
+ ))} +
+ )} +
+ + ); + return (
- {currentView === "main" ? renderMainView() : renderAboutView()} + {currentView === "main" + ? renderMainView() + : currentView === "about" + ? renderAboutView() + : renderBookmarksView()}
); } diff --git a/apps/browser/src/renderer/components/top-bar.tsx b/apps/browser/src/renderer/components/top-bar.tsx index 1788c6b..81976a2 100644 --- a/apps/browser/src/renderer/components/top-bar.tsx +++ b/apps/browser/src/renderer/components/top-bar.tsx @@ -8,6 +8,7 @@ interface TopBarProps { currentUrl: string; onNavigate: (url: string) => void; onShowTabs: () => void; + onShowMenu: () => void; onRefresh: () => void; theme: 'light' | 'dark'; orientation: 'portrait' | 'landscape'; @@ -20,6 +21,7 @@ function TopBar({ currentUrl, onNavigate, onShowTabs, + onShowMenu, onRefresh, theme, orientation, @@ -107,6 +109,7 @@ function TopBar({ diff --git a/apps/browser/src/types/electron-api.d.ts b/apps/browser/src/types/electron-api.d.ts index c16f032..9887507 100644 --- a/apps/browser/src/types/electron-api.d.ts +++ b/apps/browser/src/types/electron-api.d.ts @@ -26,6 +26,15 @@ interface Bounds { height: number; } +interface Bookmark { + id: string; + title: string; + url: string; + favicon?: string; + createdAt: number; + updatedAt: number; +} + export interface ElectronAPI { platform: NodeJS.Platform; closeWindow: () => void; @@ -102,6 +111,19 @@ export interface ElectronAPI { callback: (statusCode: number, statusText: string, url: string) => void ) => () => void; }; + + // Bookmark management APIs + bookmarks: { + getAll: () => Promise; + getById: (id: string) => Promise; + isBookmarked: (url: string) => Promise; + add: (title: string, url: string, favicon?: string) => Promise; + update: (id: string, updates: Partial>) => Promise; + remove: (id: string) => Promise; + removeByUrl: (url: string) => Promise; + clear: () => Promise; + onUpdate: (callback: () => void) => () => void; + }; } declare global { From d7f2648978924f60f03d18b4598806436143d95f Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 22 Oct 2025 13:46:31 +0900 Subject: [PATCH 2/6] feat: update bookmark grid layout and standardize favicon URLs using Google's favicon service --- apps/browser/assets/start-page.html | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/browser/assets/start-page.html b/apps/browser/assets/start-page.html index 2afa4d2..039b629 100644 --- a/apps/browser/assets/start-page.html +++ b/apps/browser/assets/start-page.html @@ -44,7 +44,7 @@ .bookmarks-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 0 20px; max-width: 100%; @@ -183,21 +183,28 @@

Favorites

id: "default-google", title: "Google", url: "https://www.google.com", - favicon: "https://www.google.com/images/branding/googleg/1x/googleg_standard_color_128dp.png", + favicon: "https://www.google.com/s2/favicons?domain=google.com&sz=128", displayUrl: "google.com", }, { id: "default-youtube", title: "YouTube", url: "https://www.youtube.com", - favicon: "https://www.youtube.com/s/desktop/f506bd45/img/favicon_144x144.png", + favicon: "https://www.google.com/s2/favicons?domain=youtube.com&sz=128", displayUrl: "youtube.com", }, + { + id: "default-netflix", + title: "Netflix", + url: "https://www.netflix.com", + favicon: "https://www.google.com/s2/favicons?domain=netflix.com&sz=128", + displayUrl: "netflix.com", + }, { id: "default-x", title: "X", url: "https://x.com", - favicon: "https://abs.twimg.com/responsive-web/client-web/icon-ios.77d25eba.png", + favicon: "https://www.google.com/s2/favicons?domain=x.com&sz=128", displayUrl: "x.com", }, ]; From 646d0b74d44d77700d3f232b31db8109de50e0d9 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 22 Oct 2025 15:23:50 +0900 Subject: [PATCH 3/6] feat: add bookmark URL normalization and hidden default bookmarks support --- apps/browser/assets/start-page.html | 31 +- apps/browser/src/main/bookmark-manager.ts | 34 +- apps/browser/src/main/ipc-handlers.ts | 42 +-- apps/browser/src/main/overlay-manager.ts | 145 +++++++++ apps/browser/src/renderer/app.tsx | 12 +- .../src/renderer/components/settings.tsx | 301 ++++++++++++++++-- apps/browser/src/webview-preload.ts | 23 +- 7 files changed, 538 insertions(+), 50 deletions(-) create mode 100644 apps/browser/src/main/overlay-manager.ts diff --git a/apps/browser/assets/start-page.html b/apps/browser/assets/start-page.html index 039b629..8d2dee9 100644 --- a/apps/browser/assets/start-page.html +++ b/apps/browser/assets/start-page.html @@ -216,8 +216,35 @@

Favorites

const grid = document.getElementById("bookmarksGrid"); const emptyState = document.getElementById("emptyState"); - // Use user bookmarks if available, otherwise use defaults - const displayBookmarks = (bookmarks && bookmarks.length > 0) ? bookmarks : defaultBookmarks; + // Load hidden default bookmarks from localStorage + let hiddenDefaults = []; + try { + const hidden = localStorage.getItem("hiddenDefaultBookmarks"); + if (hidden) { + hiddenDefaults = JSON.parse(hidden); + } + } catch (error) { + console.error("Failed to load hidden bookmarks:", error); + } + + // Filter out hidden default bookmarks + const visibleDefaults = defaultBookmarks.filter( + (bookmark) => !hiddenDefaults.includes(bookmark.id) + ); + + // Combine user bookmarks and visible default bookmarks + const userBookmarks = bookmarks || []; + const displayBookmarks = [...userBookmarks, ...visibleDefaults]; + + console.log("[Start Page] User bookmarks:", userBookmarks.length, userBookmarks); + console.log("[Start Page] Visible defaults:", visibleDefaults.length, visibleDefaults); + console.log("[Start Page] Total display bookmarks:", displayBookmarks.length, displayBookmarks); + + if (displayBookmarks.length === 0) { + grid.style.display = "none"; + emptyState.style.display = "block"; + return; + } grid.style.display = "grid"; emptyState.style.display = "none"; diff --git a/apps/browser/src/main/bookmark-manager.ts b/apps/browser/src/main/bookmark-manager.ts index 4489a10..c15ee22 100644 --- a/apps/browser/src/main/bookmark-manager.ts +++ b/apps/browser/src/main/bookmark-manager.ts @@ -25,6 +25,22 @@ export class BookmarkManager { this.loadBookmarks(); } + /** + * Normalize URL by ensuring it has a protocol + */ + private normalizeUrl(url: string): string { + // Trim whitespace + url = url.trim(); + + // If URL already has a protocol, return as is + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) { + return url; + } + + // Add https:// by default + return `https://${url}`; + } + /** * Load bookmarks from file */ @@ -75,18 +91,20 @@ export class BookmarkManager { * Check if URL is bookmarked */ isBookmarked(url: string): boolean { - return this.bookmarks.some((b) => b.url === url); + const normalizedUrl = this.normalizeUrl(url); + return this.bookmarks.some((b) => b.url === normalizedUrl); } /** * Add a new bookmark */ add(title: string, url: string, favicon?: string): Bookmark { + const normalizedUrl = this.normalizeUrl(url); const now = Date.now(); const bookmark: Bookmark = { id: `bookmark-${now}-${Math.random().toString(36).substr(2, 9)}`, title, - url, + url: normalizedUrl, favicon, createdAt: now, updatedAt: now, @@ -94,7 +112,7 @@ export class BookmarkManager { this.bookmarks.push(bookmark); this.saveBookmarks(); - console.log(`[BookmarkManager] Added bookmark: ${title} (${url})`); + console.log(`[BookmarkManager] Added bookmark: ${title} (${normalizedUrl})`); return bookmark; } @@ -108,6 +126,11 @@ export class BookmarkManager { return null; } + // Normalize URL if it's being updated + if (updates.url) { + updates.url = this.normalizeUrl(updates.url); + } + this.bookmarks[index] = { ...this.bookmarks[index], ...updates, @@ -139,9 +162,10 @@ export class BookmarkManager { * Remove bookmark by URL */ removeByUrl(url: string): boolean { - const index = this.bookmarks.findIndex((b) => b.url === url); + const normalizedUrl = this.normalizeUrl(url); + const index = this.bookmarks.findIndex((b) => b.url === normalizedUrl); if (index === -1) { - console.error(`[BookmarkManager] Bookmark not found for URL: ${url}`); + console.error(`[BookmarkManager] Bookmark not found for URL: ${normalizedUrl}`); return false; } diff --git a/apps/browser/src/main/ipc-handlers.ts b/apps/browser/src/main/ipc-handlers.ts index e7bc312..ad43880 100644 --- a/apps/browser/src/main/ipc-handlers.ts +++ b/apps/browser/src/main/ipc-handlers.ts @@ -366,7 +366,22 @@ export class IPCHandlers { } /** - * Register bookmark handlers + * Notify all windows about bookmark updates + */ + private notifyBookmarkUpdate(): void { + // Notify main window + if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { + this.state.mainWindow.webContents.send("bookmarks-updated"); + } + + // Notify WebContentsView + if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { + this.state.webContentsView.webContents.send("bookmarks-updated"); + } + } + + /** + * Register bookmark management handlers */ private registerBookmarkHandlers(): void { // Get all bookmarks @@ -387,50 +402,35 @@ export class IPCHandlers { // Add bookmark ipcMain.handle("bookmarks-add", (_event, title: string, url: string, favicon?: string) => { const bookmark = this.bookmarkManager.add(title, url, favicon); - // Notify all windows about bookmark update - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send("bookmarks-updated"); - } + this.notifyBookmarkUpdate(); return bookmark; }); // Update bookmark ipcMain.handle("bookmarks-update", (_event, id: string, updates: any) => { const bookmark = this.bookmarkManager.update(id, updates); - // Notify all windows about bookmark update - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send("bookmarks-updated"); - } + this.notifyBookmarkUpdate(); return bookmark; }); // Remove bookmark ipcMain.handle("bookmarks-remove", (_event, id: string) => { const result = this.bookmarkManager.remove(id); - // Notify all windows about bookmark update - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send("bookmarks-updated"); - } + this.notifyBookmarkUpdate(); return result; }); // Remove bookmark by URL ipcMain.handle("bookmarks-remove-by-url", (_event, url: string) => { const result = this.bookmarkManager.removeByUrl(url); - // Notify all windows about bookmark update - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send("bookmarks-updated"); - } + this.notifyBookmarkUpdate(); return result; }); // Clear all bookmarks ipcMain.handle("bookmarks-clear", () => { this.bookmarkManager.clear(); - // Notify all windows about bookmark update - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send("bookmarks-updated"); - } + this.notifyBookmarkUpdate(); }); } } diff --git a/apps/browser/src/main/overlay-manager.ts b/apps/browser/src/main/overlay-manager.ts new file mode 100644 index 0000000..bdc2b02 --- /dev/null +++ b/apps/browser/src/main/overlay-manager.ts @@ -0,0 +1,145 @@ +/** + * Manages overlay views that appear on top of WebContentsView + * Used for menus, dialogs, and other UI elements that need to be above web content + */ + +import { WebContentsView } from "electron"; +import path from "path"; +import { AppState } from "./types"; + +export class OverlayManager { + private appState: AppState; + private overlayView: WebContentsView | null = null; + private isVisible: boolean = false; + + constructor(appState: AppState) { + this.appState = appState; + } + + /** + * Initialize overlay view (called once) + */ + initializeOverlay() { + if (this.overlayView || !this.appState.mainWindow) return; + + console.log("[OverlayManager] Initializing overlay view"); + + this.overlayView = new WebContentsView({ + webPreferences: { + preload: path.join(__dirname, "../preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + // Load the same renderer but it will show overlay content + const isDev = process.env.NODE_ENV === "development"; + if (isDev) { + this.overlayView.webContents.loadURL("http://localhost:5173/#overlay"); + } else { + this.overlayView.webContents.loadFile( + path.join(__dirname, "../../dist-renderer/index.html"), + { hash: "overlay" } + ); + } + + // Set initial bounds (full window, but invisible) + const bounds = this.appState.mainWindow.getBounds(); + this.overlayView.setBounds({ + x: 0, + y: 0, + width: bounds.width, + height: bounds.height, + }); + + // Start hidden + this.overlayView.setVisible(false); + + // Add to window (will be on top due to order) + this.appState.mainWindow.contentView.addChildView(this.overlayView); + + console.log("[OverlayManager] Overlay view initialized"); + } + + /** + * Show overlay + */ + showOverlay() { + if (!this.overlayView || !this.appState.mainWindow) return; + + console.log("[OverlayManager] Showing overlay"); + + // Update bounds to match current window size + const bounds = this.appState.mainWindow.getBounds(); + this.overlayView.setBounds({ + x: 0, + y: 0, + width: bounds.width, + height: bounds.height, + }); + + // Make visible + this.overlayView.setVisible(true); + this.isVisible = true; + + // Send message to overlay to show menu + this.overlayView.webContents.send("overlay-show-menu"); + } + + /** + * Hide overlay + */ + hideOverlay() { + if (!this.overlayView) return; + + console.log("[OverlayManager] Hiding overlay"); + + this.overlayView.setVisible(false); + this.isVisible = false; + + // Send message to overlay to hide menu + this.overlayView.webContents.send("overlay-hide-menu"); + } + + /** + * Update overlay bounds (called on window resize) + */ + updateBounds() { + if (!this.overlayView || !this.appState.mainWindow || !this.isVisible) return; + + const bounds = this.appState.mainWindow.getBounds(); + this.overlayView.setBounds({ + x: 0, + y: 0, + width: bounds.width, + height: bounds.height, + }); + } + + /** + * Destroy overlay + */ + destroy() { + if (this.overlayView) { + console.log("[OverlayManager] Destroying overlay view"); + this.overlayView.webContents.close(); + this.overlayView = null; + this.isVisible = false; + } + } + + /** + * Check if overlay is visible + */ + isOverlayVisible(): boolean { + return this.isVisible; + } + + /** + * Get overlay view + */ + getOverlayView(): WebContentsView | null { + return this.overlayView; + } +} diff --git a/apps/browser/src/renderer/app.tsx b/apps/browser/src/renderer/app.tsx index 8d89409..e386cf0 100644 --- a/apps/browser/src/renderer/app.tsx +++ b/apps/browser/src/renderer/app.tsx @@ -576,7 +576,14 @@ function App() { }; const handleShowMenu = () => { - setShowMenu(true); + if (showSettings) { + // If settings is open, close it and show WebContentsView + handleCloseSettings(); + } else { + // Hide WebContentsView and show settings directly + window.electronAPI?.webContents.setVisible(false); + setShowSettings(true); + } }; const handleCloseMenu = () => { @@ -585,8 +592,7 @@ function App() { const handleOpenSettingsFromMenu = () => { setShowSettings(true); - // Hide WebContentsView when showing settings - window.electronAPI?.webContents.setVisible(false); + // WebContentsView is already hidden }; return ( diff --git a/apps/browser/src/renderer/components/settings.tsx b/apps/browser/src/renderer/components/settings.tsx index cd021d2..c9c53d0 100644 --- a/apps/browser/src/renderer/components/settings.tsx +++ b/apps/browser/src/renderer/components/settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Info, Globe, ChevronRight, ChevronLeft, Star, Trash2 } from "lucide-react"; +import { Info, Globe, ChevronRight, ChevronLeft, Star, Trash2, Plus, Edit2, X } from "lucide-react"; interface SettingsProps { theme: "light" | "dark"; @@ -31,10 +31,51 @@ interface Bookmark { updatedAt: number; } +// Default bookmarks (same as start-page.html) +const defaultBookmarks: Bookmark[] = [ + { + id: "default-google", + title: "Google", + url: "https://www.google.com", + favicon: "https://www.google.com/s2/favicons?domain=google.com&sz=128", + createdAt: 0, + updatedAt: 0, + }, + { + id: "default-youtube", + title: "YouTube", + url: "https://www.youtube.com", + favicon: "https://www.google.com/s2/favicons?domain=youtube.com&sz=128", + createdAt: 0, + updatedAt: 0, + }, + { + id: "default-netflix", + title: "Netflix", + url: "https://www.netflix.com", + favicon: "https://www.google.com/s2/favicons?domain=netflix.com&sz=128", + createdAt: 0, + updatedAt: 0, + }, + { + id: "default-x", + title: "X", + url: "https://x.com", + favicon: "https://www.google.com/s2/favicons?domain=x.com&sz=128", + createdAt: 0, + updatedAt: 0, + }, +]; + function Settings({ theme, orientation, onClose }: SettingsProps) { const [currentView, setCurrentView] = useState<"main" | "about" | "bookmarks">("main"); const [appVersion, setAppVersion] = useState("0.0.0"); const [bookmarks, setBookmarks] = useState([]); + const [hiddenDefaultBookmarks, setHiddenDefaultBookmarks] = useState>(new Set()); + const [showBookmarkDialog, setShowBookmarkDialog] = useState(false); + const [editingBookmark, setEditingBookmark] = useState(null); + const [bookmarkTitle, setBookmarkTitle] = useState(""); + const [bookmarkUrl, setBookmarkUrl] = useState(""); useEffect(() => { // Get app version @@ -42,6 +83,16 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { setAppVersion(version); }); + // Load hidden default bookmarks from localStorage + try { + const hidden = localStorage.getItem("hiddenDefaultBookmarks"); + if (hidden) { + setHiddenDefaultBookmarks(new Set(JSON.parse(hidden))); + } + } catch (error) { + console.error("Failed to load hidden bookmarks:", error); + } + // Load bookmarks loadBookmarks(); @@ -65,15 +116,102 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { } }; + // Get all bookmarks (user + visible default bookmarks) + const getAllBookmarks = (): Bookmark[] => { + const visibleDefaults = defaultBookmarks.filter( + (bookmark) => !hiddenDefaultBookmarks.has(bookmark.id) + ); + + console.log("[Settings] User bookmarks:", bookmarks.length, bookmarks); + console.log("[Settings] Visible defaults:", visibleDefaults.length, visibleDefaults); + console.log("[Settings] Hidden defaults:", Array.from(hiddenDefaultBookmarks)); + + // Show user bookmarks + visible default bookmarks + // User bookmarks first, then default bookmarks + const allBookmarks = [...bookmarks, ...visibleDefaults]; + console.log("[Settings] Total bookmarks:", allBookmarks.length, allBookmarks); + + return allBookmarks; + }; + const handleDeleteBookmark = async (id: string) => { try { - await window.electronAPI?.bookmarks?.remove(id); - // Bookmarks will be reloaded via onUpdate listener + // Check if it's a default bookmark + if (id.startsWith("default-")) { + // Hide default bookmark + const newHidden = new Set(hiddenDefaultBookmarks); + newHidden.add(id); + setHiddenDefaultBookmarks(newHidden); + + // Save to localStorage + localStorage.setItem( + "hiddenDefaultBookmarks", + JSON.stringify(Array.from(newHidden)) + ); + } else { + // Remove user bookmark + await window.electronAPI?.bookmarks?.remove(id); + // Bookmarks will be reloaded via onUpdate listener + } } catch (error) { console.error("Failed to delete bookmark:", error); } }; + const handleAddBookmark = () => { + setEditingBookmark(null); + setBookmarkTitle(""); + setBookmarkUrl(""); + setShowBookmarkDialog(true); + }; + + const handleEditBookmark = (bookmark: Bookmark) => { + // Don't allow editing default bookmarks + if (bookmark.id.startsWith("default-")) { + return; + } + + setEditingBookmark(bookmark); + setBookmarkTitle(bookmark.title); + setBookmarkUrl(bookmark.url); + setShowBookmarkDialog(true); + }; + + const handleSaveBookmark = async () => { + if (!bookmarkTitle.trim() || !bookmarkUrl.trim()) { + return; + } + + try { + if (editingBookmark) { + // Update existing bookmark + await window.electronAPI?.bookmarks?.update(editingBookmark.id, { + title: bookmarkTitle.trim(), + url: bookmarkUrl.trim(), + }); + } else { + // Add new bookmark + await window.electronAPI?.bookmarks?.add( + bookmarkTitle.trim(), + bookmarkUrl.trim() + ); + } + setShowBookmarkDialog(false); + setEditingBookmark(null); + setBookmarkTitle(""); + setBookmarkUrl(""); + } catch (error) { + console.error("Failed to save bookmark:", error); + } + }; + + const handleCancelBookmarkDialog = () => { + setShowBookmarkDialog(false); + setEditingBookmark(null); + setBookmarkTitle(""); + setBookmarkUrl(""); + }; + const isDark = theme === "dark"; const settingsSections: SettingsSection[] = [ @@ -84,7 +222,7 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { { id: "bookmarks", label: "Favorites", - value: `${bookmarks.length} items`, + value: `${getAllBookmarks().length} items`, icon: , hasDetail: true, onClick: () => setCurrentView("bookmarks"), @@ -382,12 +520,22 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { > Favorites -
+ {/* Bookmarks Content */}
- {bookmarks.length === 0 ? ( + {getAllBookmarks().length === 0 ? (
) : (
- {bookmarks.map((bookmark) => ( + {getAllBookmarks().map((bookmark) => (
- +
+ {!bookmark.id.startsWith("default-") && ( + + )} + +
))}
@@ -498,6 +661,108 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { : currentView === "about" ? renderAboutView() : renderBookmarksView()} + + {/* Bookmark Add/Edit Dialog */} + {showBookmarkDialog && ( +
+
e.stopPropagation()} + > + {/* Dialog Header */} +
+

+ {editingBookmark ? "Edit Favorite" : "Add Favorite"} +

+ +
+ + {/* Dialog Content */} +
+
+ + setBookmarkTitle(e.target.value)} + placeholder="Enter title" + className={`w-full px-4 py-2 rounded-lg border ${ + isDark + ? "bg-zinc-700 border-zinc-600 text-white placeholder-zinc-400" + : "bg-white border-zinc-300 text-zinc-900 placeholder-zinc-500" + } focus:outline-none focus:ring-2 focus:ring-blue-500`} + autoFocus + /> +
+ +
+ + setBookmarkUrl(e.target.value)} + placeholder="https://example.com" + className={`w-full px-4 py-2 rounded-lg border ${ + isDark + ? "bg-zinc-700 border-zinc-600 text-white placeholder-zinc-400" + : "bg-white border-zinc-300 text-zinc-900 placeholder-zinc-500" + } focus:outline-none focus:ring-2 focus:ring-blue-500`} + /> +
+
+ + {/* Dialog Footer */} +
+ + +
+
+
+ )}
); } diff --git a/apps/browser/src/webview-preload.ts b/apps/browser/src/webview-preload.ts index 740ac0d..10deb10 100644 --- a/apps/browser/src/webview-preload.ts +++ b/apps/browser/src/webview-preload.ts @@ -1,7 +1,7 @@ // Preload script for WebContentsView (embedded web content) // This runs in the context of loaded web pages with limited privileges -import { ipcRenderer } from "electron"; +import { ipcRenderer, contextBridge } from "electron"; // ============================================================================ // Fullscreen API Polyfill (Plan 1.5 - Final) @@ -599,3 +599,24 @@ function setupNavigationGestures() { // Setup gesture detection immediately setupNavigationGestures(); + +// ============================================================================ +// Expose Bookmark API to webview (for start-page.html) +// ============================================================================ + +contextBridge.exposeInMainWorld("electronAPI", { + bookmarks: { + getAll: () => ipcRenderer.invoke("bookmarks-get-all"), + add: (title: string, url: string, favicon?: string) => + ipcRenderer.invoke("bookmarks-add", title, url, favicon), + remove: (id: string) => ipcRenderer.invoke("bookmarks-remove", id), + update: (id: string, updates: { title?: string; url?: string; favicon?: string }) => + ipcRenderer.invoke("bookmarks-update", id, updates), + clear: () => ipcRenderer.invoke("bookmarks-clear"), + onUpdate: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("bookmarks-updated", listener); + return () => ipcRenderer.removeListener("bookmarks-updated", listener); + }, + }, +}); From 2bac5c31057f1080bedabcb1f62c352fc80b69b7 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 22 Oct 2025 15:30:02 +0900 Subject: [PATCH 4/6] feat: implement favicon caching system with local storage and fallback sources --- apps/browser/src/main/favicon-cache.ts | 225 ++++++++++++++++++ apps/browser/src/main/index.ts | 4 +- apps/browser/src/main/ipc-handlers.ts | 35 +++ apps/browser/src/preload.ts | 9 + .../src/renderer/components/settings.tsx | 24 +- apps/browser/src/types/electron-api.d.ts | 9 + apps/browser/src/webview-preload.ts | 7 + 7 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 apps/browser/src/main/favicon-cache.ts diff --git a/apps/browser/src/main/favicon-cache.ts b/apps/browser/src/main/favicon-cache.ts new file mode 100644 index 0000000..c11f0a7 --- /dev/null +++ b/apps/browser/src/main/favicon-cache.ts @@ -0,0 +1,225 @@ +/** + * Favicon caching system + * Downloads and caches favicons locally to avoid repeated network requests + */ + +import { app, net } from "electron"; +import path from "path"; +import fs from "fs"; +import crypto from "crypto"; + +export class FaviconCache { + private cachePath: string; + + constructor() { + const userDataPath = app.getPath("userData"); + this.cachePath = path.join(userDataPath, "favicon-cache"); + this.ensureCacheDirectory(); + } + + /** + * Ensure cache directory exists + */ + private ensureCacheDirectory(): void { + if (!fs.existsSync(this.cachePath)) { + fs.mkdirSync(this.cachePath, { recursive: true }); + console.log("[FaviconCache] Created cache directory"); + } + } + + /** + * Generate cache key from URL + */ + private getCacheKey(url: string): string { + return crypto.createHash("md5").update(url).digest("hex"); + } + + /** + * Get cached favicon path + */ + private getCachedPath(url: string, extension: string = "png"): string { + const key = this.getCacheKey(url); + return path.join(this.cachePath, `${key}.${extension}`); + } + + /** + * Check if favicon is cached + */ + isCached(url: string): boolean { + const cachedPath = this.getCachedPath(url); + return fs.existsSync(cachedPath); + } + + /** + * Get cached favicon as data URL + */ + getCached(url: string): string | null { + try { + const cachedPath = this.getCachedPath(url); + if (fs.existsSync(cachedPath)) { + const data = fs.readFileSync(cachedPath); + const base64 = data.toString("base64"); + const ext = path.extname(cachedPath).slice(1); + const mimeType = ext === "svg" ? "image/svg+xml" : `image/${ext}`; + return `data:${mimeType};base64,${base64}`; + } + return null; + } catch (error) { + console.error("[FaviconCache] Failed to read cached favicon:", error); + return null; + } + } + + /** + * Download and cache favicon + */ + async downloadAndCache(url: string): Promise { + try { + console.log("[FaviconCache] Downloading favicon:", url); + + return new Promise((resolve) => { + const request = net.request(url); + const chunks: Buffer[] = []; + + request.on("response", (response) => { + if (response.statusCode !== 200) { + console.error("[FaviconCache] Failed to download:", response.statusCode); + resolve(null); + return; + } + + response.on("data", (chunk) => { + chunks.push(Buffer.from(chunk)); + }); + + response.on("end", () => { + try { + const buffer = Buffer.concat(chunks); + + // Determine file extension from content-type + const contentType = response.headers["content-type"]; + let extension = "png"; + if (contentType) { + if (contentType.includes("svg")) extension = "svg"; + else if (contentType.includes("jpeg") || contentType.includes("jpg")) extension = "jpg"; + else if (contentType.includes("gif")) extension = "gif"; + else if (contentType.includes("webp")) extension = "webp"; + else if (contentType.includes("ico")) extension = "ico"; + } + + const cachedPath = this.getCachedPath(url, extension); + fs.writeFileSync(cachedPath, buffer); + console.log("[FaviconCache] Cached favicon:", cachedPath); + + // Return as data URL + const base64 = buffer.toString("base64"); + const mimeType = extension === "svg" ? "image/svg+xml" : `image/${extension}`; + resolve(`data:${mimeType};base64,${base64}`); + } catch (error) { + console.error("[FaviconCache] Failed to save favicon:", error); + resolve(null); + } + }); + + response.on("error", (error) => { + console.error("[FaviconCache] Response error:", error); + resolve(null); + }); + }); + + request.on("error", (error) => { + console.error("[FaviconCache] Request error:", error); + resolve(null); + }); + + request.end(); + }); + } catch (error) { + console.error("[FaviconCache] Failed to download favicon:", error); + return null; + } + } + + /** + * Get favicon with caching + * Returns cached version if available, otherwise downloads and caches + */ + async getFavicon(url: string): Promise { + // Check cache first + const cached = this.getCached(url); + if (cached) { + console.log("[FaviconCache] Using cached favicon"); + return cached; + } + + // Download and cache + return this.downloadAndCache(url); + } + + /** + * Get multiple favicon URLs to try (high-res first) + */ + getFaviconUrls(pageUrl: string): string[] { + try { + const url = new URL(pageUrl); + const domain = url.hostname; + + return [ + `https://www.google.com/s2/favicons?domain=${domain}&sz=128`, + `https://www.google.com/s2/favicons?domain=${domain}&sz=64`, + `https://${domain}/favicon.ico`, + ]; + } catch { + return []; + } + } + + /** + * Try multiple favicon sources and return the first successful one + */ + async getFaviconWithFallback(pageUrl: string): Promise { + const urls = this.getFaviconUrls(pageUrl); + + for (const url of urls) { + const favicon = await this.getFavicon(url); + if (favicon) { + return favicon; + } + } + + return null; + } + + /** + * Clear cache + */ + clearCache(): void { + try { + const files = fs.readdirSync(this.cachePath); + for (const file of files) { + fs.unlinkSync(path.join(this.cachePath, file)); + } + console.log("[FaviconCache] Cache cleared"); + } catch (error) { + console.error("[FaviconCache] Failed to clear cache:", error); + } + } + + /** + * Get cache size in bytes + */ + getCacheSize(): number { + try { + const files = fs.readdirSync(this.cachePath); + let totalSize = 0; + for (const file of files) { + const stats = fs.statSync(path.join(this.cachePath, file)); + totalSize += stats.size; + } + return totalSize; + } catch (error) { + console.error("[FaviconCache] Failed to get cache size:", error); + return 0; + } + } +} diff --git a/apps/browser/src/main/index.ts b/apps/browser/src/main/index.ts index ab8d5cd..3030dc2 100644 --- a/apps/browser/src/main/index.ts +++ b/apps/browser/src/main/index.ts @@ -8,6 +8,7 @@ import { ThemeColorCache } from "./theme-cache"; import { TabManager } from "./tab-manager"; import { WindowManager } from "./window-manager"; import { BookmarkManager } from "./bookmark-manager"; +import { FaviconCache } from "./favicon-cache"; import { IPCHandlers } from "./ipc-handlers"; import { TrayManager } from "./tray-manager"; import { AppLifecycle } from "./app-lifecycle"; @@ -27,10 +28,11 @@ const appState: AppState = { // Initialize managers const themeColorCache = new ThemeColorCache(); const bookmarkManager = new BookmarkManager(); +const faviconCache = new FaviconCache(); const tabManager = new TabManager(appState, themeColorCache); const windowManager = new WindowManager(appState, tabManager); const trayManager = new TrayManager(appState, windowManager); -const ipcHandlers = new IPCHandlers(appState, tabManager, windowManager, bookmarkManager, themeColorCache); +const ipcHandlers = new IPCHandlers(appState, tabManager, windowManager, bookmarkManager, faviconCache, themeColorCache); const appLifecycle = new AppLifecycle(appState, windowManager, trayManager); // Initialize Widevine diff --git a/apps/browser/src/main/ipc-handlers.ts b/apps/browser/src/main/ipc-handlers.ts index ad43880..27d981e 100644 --- a/apps/browser/src/main/ipc-handlers.ts +++ b/apps/browser/src/main/ipc-handlers.ts @@ -7,6 +7,7 @@ import { AppState } from "./types"; import { TabManager } from "./tab-manager"; import { WindowManager } from "./window-manager"; import { BookmarkManager } from "./bookmark-manager"; +import { FaviconCache } from "./favicon-cache"; import { isValidUrl, sanitizeUrl, getUserAgentForUrl, logSecurityEvent } from "./security"; import { ThemeColorCache } from "./theme-cache"; @@ -15,6 +16,7 @@ export class IPCHandlers { private tabManager: TabManager; private windowManager: WindowManager; private bookmarkManager: BookmarkManager; + private faviconCache: FaviconCache; private themeColorCache: ThemeColorCache; constructor( @@ -22,12 +24,14 @@ export class IPCHandlers { tabManager: TabManager, windowManager: WindowManager, bookmarkManager: BookmarkManager, + faviconCache: FaviconCache, themeColorCache: ThemeColorCache ) { this.state = state; this.tabManager = tabManager; this.windowManager = windowManager; this.bookmarkManager = bookmarkManager; + this.faviconCache = faviconCache; this.themeColorCache = themeColorCache; } @@ -42,6 +46,7 @@ export class IPCHandlers { this.registerOrientationHandlers(); this.registerAppHandlers(); this.registerBookmarkHandlers(); + this.registerFaviconHandlers(); } /** @@ -433,4 +438,34 @@ export class IPCHandlers { this.notifyBookmarkUpdate(); }); } + + /** + * Register favicon cache handlers + */ + private registerFaviconHandlers(): void { + // Get favicon with caching + ipcMain.handle("favicon-get", async (_event, url: string) => { + return this.faviconCache.getFavicon(url); + }); + + // Get favicon with fallback sources + ipcMain.handle("favicon-get-with-fallback", async (_event, pageUrl: string) => { + return this.faviconCache.getFaviconWithFallback(pageUrl); + }); + + // Check if favicon is cached + ipcMain.handle("favicon-is-cached", (_event, url: string) => { + return this.faviconCache.isCached(url); + }); + + // Clear favicon cache + ipcMain.handle("favicon-clear-cache", () => { + this.faviconCache.clearCache(); + }); + + // Get cache size + ipcMain.handle("favicon-get-cache-size", () => { + return this.faviconCache.getCacheSize(); + }); + } } diff --git a/apps/browser/src/preload.ts b/apps/browser/src/preload.ts index fe707e7..417bf19 100644 --- a/apps/browser/src/preload.ts +++ b/apps/browser/src/preload.ts @@ -194,4 +194,13 @@ contextBridge.exposeInMainWorld("electronAPI", { return () => ipcRenderer.removeListener("bookmarks-updated", listener); }, }, + + // Favicon cache APIs + favicon: { + get: (url: string) => ipcRenderer.invoke("favicon-get", url), + getWithFallback: (pageUrl: string) => ipcRenderer.invoke("favicon-get-with-fallback", pageUrl), + isCached: (url: string) => ipcRenderer.invoke("favicon-is-cached", url), + clearCache: () => ipcRenderer.invoke("favicon-clear-cache"), + getCacheSize: () => ipcRenderer.invoke("favicon-get-cache-size"), + }, }); diff --git a/apps/browser/src/renderer/components/settings.tsx b/apps/browser/src/renderer/components/settings.tsx index c9c53d0..ff5a004 100644 --- a/apps/browser/src/renderer/components/settings.tsx +++ b/apps/browser/src/renderer/components/settings.tsx @@ -109,7 +109,29 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { const loadBookmarks = async () => { try { const allBookmarks = await window.electronAPI?.bookmarks?.getAll(); - setBookmarks(allBookmarks || []); + + // Load cached favicons for bookmarks that don't have data URLs + if (allBookmarks) { + const bookmarksWithFavicons = await Promise.all( + allBookmarks.map(async (bookmark) => { + // If favicon exists and is not a data URL, try to get cached version + if (bookmark.favicon && !bookmark.favicon.startsWith('data:')) { + try { + const cachedFavicon = await window.electronAPI?.favicon?.getWithFallback(bookmark.url); + if (cachedFavicon) { + return { ...bookmark, favicon: cachedFavicon }; + } + } catch (error) { + console.error("Failed to load cached favicon:", error); + } + } + return bookmark; + }) + ); + setBookmarks(bookmarksWithFavicons); + } else { + setBookmarks([]); + } } catch (error) { console.error("Failed to load bookmarks:", error); setBookmarks([]); diff --git a/apps/browser/src/types/electron-api.d.ts b/apps/browser/src/types/electron-api.d.ts index 9887507..8668bf8 100644 --- a/apps/browser/src/types/electron-api.d.ts +++ b/apps/browser/src/types/electron-api.d.ts @@ -124,6 +124,15 @@ export interface ElectronAPI { clear: () => Promise; onUpdate: (callback: () => void) => () => void; }; + + // Favicon cache APIs + favicon: { + get: (url: string) => Promise; + getWithFallback: (pageUrl: string) => Promise; + isCached: (url: string) => Promise; + clearCache: () => Promise; + getCacheSize: () => Promise; + }; } declare global { diff --git a/apps/browser/src/webview-preload.ts b/apps/browser/src/webview-preload.ts index 10deb10..e1fe699 100644 --- a/apps/browser/src/webview-preload.ts +++ b/apps/browser/src/webview-preload.ts @@ -619,4 +619,11 @@ contextBridge.exposeInMainWorld("electronAPI", { return () => ipcRenderer.removeListener("bookmarks-updated", listener); }, }, + favicon: { + get: (url: string) => ipcRenderer.invoke("favicon-get", url), + getWithFallback: (pageUrl: string) => ipcRenderer.invoke("favicon-get-with-fallback", pageUrl), + isCached: (url: string) => ipcRenderer.invoke("favicon-is-cached", url), + clearCache: () => ipcRenderer.invoke("favicon-clear-cache"), + getCacheSize: () => ipcRenderer.invoke("favicon-get-cache-size"), + }, }); From 6b21ef4cb22267923e4d234fd5c7c508825e0149 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 22 Oct 2025 15:43:03 +0900 Subject: [PATCH 5/6] feat: persist theme colors and apply them immediately on tab switch --- apps/browser/assets/start-page.html | 1 + apps/browser/src/main/tab-manager.ts | 44 +++++++++++ apps/browser/src/main/theme-cache.ts | 98 +++++++++++++++++++++++- apps/browser/src/preload.ts | 5 ++ apps/browser/src/renderer/app.tsx | 19 ++++- apps/browser/src/types/electron-api.d.ts | 1 + 6 files changed, 163 insertions(+), 5 deletions(-) diff --git a/apps/browser/assets/start-page.html b/apps/browser/assets/start-page.html index 8d2dee9..a08fd39 100644 --- a/apps/browser/assets/start-page.html +++ b/apps/browser/assets/start-page.html @@ -6,6 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> + New Tab