diff --git a/src/zen/tabs/ZenPinnedTabManager.mjs b/src/zen/tabs/ZenPinnedTabManager.mjs index 8598858a4d..3a4a52b55c 100644 --- a/src/zen/tabs/ZenPinnedTabManager.mjs +++ b/src/zen/tabs/ZenPinnedTabManager.mjs @@ -92,6 +92,7 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { this.observer.addPinnedTabListener(this._onPinnedTabEvent.bind(this)); this._zenClickEventListener = this._onTabClick.bind(this); + this._migrationAttempted = new Set(); gZenWorkspaces._resolvePinnedInitialized(); } @@ -102,14 +103,21 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { } } - onTabIconChanged(tab, url = null) { - const iconUrl = url ?? tab.iconImage.src; + async onTabIconChanged(tab, url = null) { + const iconUrl = url ?? gBrowser.getIcon(tab); + if (!iconUrl && tab.hasAttribute('zen-pin-id')) { try { setTimeout(async () => { const favicon = await this.getFaviconAsBase64(tab.linkedBrowser.currentURI); if (favicon) { gBrowser.setIcon(tab, favicon); + const pinId = tab.getAttribute('zen-pin-id'); + const pin = this._pinsCache?.find((p) => p.uuid === pinId); + if (pin) { + pin.iconUrl = favicon; + await this.savePin(pin, false); + } } }); } catch { @@ -119,6 +127,14 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { if (tab.hasAttribute('zen-essential')) { tab.style.setProperty('--zen-essential-tab-icon', `url(${iconUrl})`); } + if (iconUrl && tab.hasAttribute('zen-pin-id')) { + const pinId = tab.getAttribute('zen-pin-id'); + const pin = this._pinsCache?.find((p) => p.uuid === pinId); + if (pin && iconUrl.startsWith('data:image/')) { + pin.iconUrl = iconUrl; + this.savePin(pin, false).catch(console.error); + } + } } } @@ -172,20 +188,35 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { // Get pin data const pins = await ZenPinnedTabsStorage.getPins(); - // Enhance pins with favicons this._pinsCache = await Promise.all( pins.map(async (pin) => { try { if (pin.isGroup) { - return pin; // Skip groups for now + return pin; } + + if (pin.iconUrl && pin.iconUrl.trim().length > 0) { + return { + ...pin, + iconUrl: pin.iconUrl, + }; + } + const image = await this.getFaviconAsBase64(Services.io.newURI(pin.url)); + if (image) { + pin.iconUrl = image; + this.savePin(pin, false).catch(console.error); + return { + ...pin, + iconUrl: image, + }; + } + return { ...pin, - iconUrl: image || null, + iconUrl: null, }; } catch { - // If favicon fetch fails, continue without icon return { ...pin, iconUrl: null, @@ -193,6 +224,8 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { } }) ); + + this.#scheduleLazyMigration(); } catch (ex) { console.error('Failed to initialize pins cache:', ex); this._pinsCache = []; @@ -211,6 +244,76 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { this.hasInitializedPins = true; } + #scheduleLazyMigration() { + if (this._migrationScheduled) { + return; + } + this._migrationScheduled = true; + + setTimeout(() => { + this.#migrateMissingFavicons(); + }, 2000); + } + + async #migrateMissingFavicons() { + if (!this._pinsCache) { + return; + } + + const pinsNeedingMigration = this._pinsCache.filter( + (pin) => !pin.isGroup && !pin.iconUrl && pin.url + ); + + if (pinsNeedingMigration.length === 0) { + return; + } + + const essentialPins = pinsNeedingMigration.filter((pin) => pin.isEssential); + const regularPins = pinsNeedingMigration.filter((pin) => !pin.isEssential); + + for (const pin of essentialPins) { + await this.#migratePinFavicon(pin); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + for (const pin of regularPins) { + await this.#migratePinFavicon(pin); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + async #migratePinFavicon(pin) { + if (pin.iconUrl && pin.iconUrl.trim().length > 0) { + return; + } + + if (this._migrationAttempted?.has(pin.uuid)) { + return; + } + + this._migrationAttempted?.add(pin.uuid); + + try { + let favicon = await this.getFaviconAsBase64(Services.io.newURI(pin.url)); + + if (!favicon) { + favicon = await this.fetchFaviconFromNetwork(pin.url); + } + + if (favicon) { + pin.iconUrl = favicon; + await this.savePin(pin, false); + + const tab = gBrowser.tabs.find((t) => t.getAttribute('zen-pin-id') === pin.uuid); + if (tab) { + gBrowser.setIcon(tab, favicon); + } + } + } catch (ex) { + console.error('Error migrating favicon for pin', pin.uuid, ex); + } + } + async #initializePinnedTabs(init = false) { const pins = this._pinsCache; if (!pins?.length || !init) { @@ -714,6 +817,9 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { entry = JSON.parse(tab.getAttribute('zen-pinned-entry')); } + const tabFavicon = gBrowser.getIcon(tab); + const initialIconUrl = tabFavicon && tabFavicon.startsWith('data:image/') ? tabFavicon : null; + await this.savePin({ uuid, title: entry?.title || tab.label || browser.contentTitle, @@ -723,8 +829,13 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { isEssential: tab.getAttribute('zen-essential') === 'true', parentUuid: tab.group?.getAttribute('zen-pin-id') || null, position: tab._pPos, + iconUrl: initialIconUrl, }); + if (!initialIconUrl) { + this.captureFaviconForTab(tab, uuid); + } + tab.setAttribute('zen-pin-id', uuid); tab.dispatchEvent( new CustomEvent('ZenPinnedTabCreated', { @@ -978,16 +1089,88 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { this.resetPinChangedUrl(tab); } + captureFaviconForTab(tab, pinId) { + setTimeout(async () => { + try { + const tabFavicon = gBrowser.getIcon(tab); + let faviconDataURI = null; + + if (tabFavicon && tabFavicon.startsWith('data:image/')) { + faviconDataURI = tabFavicon; + } else { + faviconDataURI = await this.getFaviconAsBase64(tab.linkedBrowser.currentURI); + + if (!faviconDataURI) { + faviconDataURI = await this.fetchFaviconFromNetwork(tab.linkedBrowser.currentURI.spec); + } + } + + if (faviconDataURI) { + const pin = this._pinsCache?.find((p) => p.uuid === pinId); + if (pin) { + pin.iconUrl = faviconDataURI; + await this.savePin(pin, false); + } + } + } catch (ex) { + console.error('Favicon capture error for', pinId, ex); + } + }, 100); + } + async getFaviconAsBase64(pageUrl) { try { const faviconData = await PlacesUtils.favicons.getFaviconForPage(pageUrl); - if (!faviconData) { - // empty favicon + if (!faviconData || !faviconData.dataURI) { return null; } - return faviconData.dataURI; - } catch (ex) { - console.error('Failed to get favicon:', ex); + + const dataURI = faviconData.dataURI.spec || null; + + if (dataURI && typeof dataURI === 'string' && dataURI.startsWith('data:image/')) { + return dataURI; + } + + return null; + } catch { + return null; + } + } + + async fetchFaviconFromNetwork(pageUrl) { + try { + if ( + pageUrl.startsWith('about:') || + pageUrl.startsWith('chrome:') || + pageUrl.startsWith('moz-extension:') + ) { + return null; + } + + const uri = Services.io.newURI(pageUrl); + const faviconUrl = uri.prePath + '/favicon.ico'; + + const response = await fetch(faviconUrl, { + method: 'GET', + credentials: 'omit', + }); + + if (!response.ok) { + return null; + } + + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) { + return null; + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = () => reject(new Error('Failed to convert blob to data URI')); + reader.readAsDataURL(blob); + }); + } catch { return null; } } @@ -1019,9 +1202,20 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { if (tab.pinned && tab.hasAttribute('zen-pin-id')) { const pin = this._pinsCache.find((pin) => pin.uuid === tab.getAttribute('zen-pin-id')); if (pin) { + const pinId = tab.getAttribute('zen-pin-id'); pin.isEssential = true; pin.workspaceUuid = null; - this.savePin(pin); + + const tabFavicon = gBrowser.getIcon(tab); + if (tabFavicon && tabFavicon.startsWith('data:image/')) { + pin.iconUrl = tabFavicon; + } + + this.savePin(pin).catch(console.error); + + if (!pin.iconUrl) { + this.captureFaviconForTab(tab, pinId); + } } gBrowser.zenHandleTabMove(tab, () => { if (tab.ownerGlobal !== window) { @@ -1270,10 +1464,13 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { if (!pin) { return; } - // Remove # and ? from the URL + + if (!pin.iconUrl && pin.url && !this._migrationAttempted?.has(pin.uuid)) { + this.#migratePinFavicon(pin); + } + const pinUrl = pin.url.split('#')[0]; const currentUrl = browser.currentURI.spec.split('#')[0]; - // Add an indicator that the pin has been changed if (pinUrl === currentUrl) { this.resetPinChangedUrl(tab); return; @@ -1299,7 +1496,7 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { } else { tab.setAttribute('zen-pinned-changed', 'true'); } - tab.style.setProperty('--zen-original-tab-icon', `url(${pin.iconUrl?.spec})`); + tab.style.setProperty('--zen-original-tab-icon', `url(${pin.iconUrl})`); } removeTabContainersDragoverClass(hideIndicator = true) { diff --git a/src/zen/tabs/ZenPinnedTabsStorage.mjs b/src/zen/tabs/ZenPinnedTabsStorage.mjs index 4847687cf3..b8e6ad3399 100644 --- a/src/zen/tabs/ZenPinnedTabsStorage.mjs +++ b/src/zen/tabs/ZenPinnedTabsStorage.mjs @@ -42,6 +42,7 @@ window.ZenPinnedTabsStorage = { await addColumnIfNotExists('is_folder_collapsed', 'BOOLEAN NOT NULL DEFAULT 0'); await addColumnIfNotExists('folder_icon', 'TEXT DEFAULT NULL'); await addColumnIfNotExists('folder_parent_uuid', 'TEXT DEFAULT NULL'); + await addColumnIfNotExists('icon_data', 'TEXT DEFAULT NULL'); await db.execute(` CREATE INDEX IF NOT EXISTS idx_zen_pins_uuid ON zen_pins(uuid) @@ -117,17 +118,35 @@ window.ZenPinnedTabsStorage = { } // Insert or replace the pin + const iconData = + pin.iconUrl !== undefined + ? pin.iconUrl + : pin.iconData !== undefined + ? pin.iconData + : null; + + let iconDataString = null; + if (iconData !== null && iconData !== undefined) { + if ( + typeof iconData === 'string' && + iconData.trim().length > 0 && + iconData.startsWith('data:image/') + ) { + iconDataString = iconData; + } + } + await db.executeCached( ` INSERT OR REPLACE INTO zen_pins ( uuid, title, url, container_id, workspace_uuid, position, is_essential, is_group, folder_parent_uuid, edited_title, created_at, - updated_at, is_folder_collapsed, folder_icon + updated_at, is_folder_collapsed, folder_icon, icon_data ) VALUES ( :uuid, :title, :url, :container_id, :workspace_uuid, :position, :is_essential, :is_group, :folder_parent_uuid, :edited_title, COALESCE((SELECT created_at FROM zen_pins WHERE uuid = :uuid), :now), - :now, :is_folder_collapsed, :folder_icon + :now, :is_folder_collapsed, :folder_icon, :icon_data ) `, { @@ -144,6 +163,7 @@ window.ZenPinnedTabsStorage = { now, folder_icon: pin.folderIcon || null, is_folder_collapsed: pin.isFolderCollapsed || false, + icon_data: iconDataString, } ); @@ -174,7 +194,7 @@ window.ZenPinnedTabsStorage = { SELECT * FROM zen_pins ORDER BY position ASC `); - return rows.map((row) => ({ + const pins = rows.map((row) => ({ uuid: row.getResultByName('uuid'), title: row.getResultByName('title'), url: row.getResultByName('url'), @@ -187,7 +207,9 @@ window.ZenPinnedTabsStorage = { editedTitle: Boolean(row.getResultByName('edited_title')), folderIcon: row.getResultByName('folder_icon'), isFolderCollapsed: Boolean(row.getResultByName('is_folder_collapsed')), + iconUrl: row.getResultByName('icon_data'), })); + return pins; }, /**