diff --git a/CMakeLists.txt b/CMakeLists.txt index 57c2583..ef5dd54 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,8 @@ set(BRO_SRCS src/radix_icons.mm src/tab_description_fetcher.cc src/tab_description_fetcher.h + src/tab_thumbnail_fetcher.cc + src/tab_thumbnail_fetcher.h ) set(BRO_HELPER_SRCS @@ -194,6 +196,7 @@ target_link_libraries(${EXECUTABLE_NAME} libcef_dll_wrapper ${CEF_STANDARD_LIBS} "-framework QuartzCore" + "-framework ImageIO" "-F${sparkle_SOURCE_DIR}" "-framework Sparkle" ) diff --git a/src/bro_handler.cc b/src/bro_handler.cc index e4cfbd4..725c301 100644 --- a/src/bro_handler.cc +++ b/src/bro_handler.cc @@ -44,7 +44,9 @@ std::string EscapeHTML(const std::string& text) { } // namespace BroHandler::BroHandler(bool is_alloy_style) - : is_alloy_style_(is_alloy_style), tab_description_fetcher_(this) { + : is_alloy_style_(is_alloy_style), + tab_description_fetcher_(this), + tab_thumbnail_fetcher_(this) { DCHECK(!g_instance); g_instance = this; } @@ -77,6 +79,12 @@ void BroHandler::SetActiveBrowser(int browser_id) { return; } + // Snapshot the outgoing tab for its hover-card thumbnail while the + // compositor surface still holds the on-screen frame; the capture resolves + // asynchronously and tolerates the WasHidden that follows. + tab_thumbnail_fetcher_.Fetch( + browser_registry_.GetById(browser_registry_.active_id())); + CefRefPtr browser = browser_registry_.SetActive(browser_id); if (browser) { OnActiveTabChanged(browser_id); @@ -202,6 +210,17 @@ void BroHandler::FetchTabDescription(int browser_id) { tab_description_fetcher_.Fetch(browser); } +void BroHandler::FetchTabThumbnail(int browser_id) { + if (!CefCurrentlyOn(TID_UI)) { + CefPostTask(TID_UI, base::BindOnce(&BroHandler::FetchTabThumbnail, this, + browser_id)); + return; + } + + CefRefPtr browser = browser_registry_.GetById(browser_id); + tab_thumbnail_fetcher_.Fetch(browser); +} + void BroHandler::OnDevToolsMethodResult(CefRefPtr browser, int message_id, bool success, @@ -210,6 +229,8 @@ void BroHandler::OnDevToolsMethodResult(CefRefPtr browser, CEF_REQUIRE_UI_THREAD(); tab_description_fetcher_.HandleMethodResult(browser, message_id, success, result, result_size); + tab_thumbnail_fetcher_.HandleMethodResult(browser, message_id, success, + result, result_size); } void BroHandler::ApplyEmulationToBrowser(CefRefPtr browser, @@ -405,6 +426,7 @@ void BroHandler::OnBeforeClose(CefRefPtr browser) { browser_registry_.Remove(browser); mobile_tab_ids_.erase(browser_id); tab_description_fetcher_.OnBrowserClosed(browser_id); + tab_thumbnail_fetcher_.OnBrowserClosed(browser_id); // Notify UI about tab closure OnTabClosed(browser_id); diff --git a/src/bro_handler.h b/src/bro_handler.h index ac89f50..8614811 100644 --- a/src/bro_handler.h +++ b/src/bro_handler.h @@ -10,6 +10,7 @@ #include "include/cef_devtools_message_observer.h" #include "include/cef_download_handler.h" #include "tab_description_fetcher.h" +#include "tab_thumbnail_fetcher.h" #include @@ -117,6 +118,12 @@ class BroHandler : public CefClient, // assumes the DevTools result callback fires. void FetchTabDescription(int browser_id); + // Asynchronously captures a snapshot of the tab's page via the DevTools + // protocol; the result arrives through OnTabThumbnailAvailable. + // Best-effort: silently does nothing if the browser is gone, and never + // assumes the DevTools result callback fires. + void FetchTabThumbnail(int browser_id); + // CefClient methods: CefRefPtr GetDisplayHandler() override { return this; } CefRefPtr GetFindHandler() override { return this; } @@ -254,6 +261,9 @@ class BroHandler : public CefClient, // DevTools CDP round trip that backs FetchTabDescription. TabDescriptionFetcher tab_description_fetcher_; + // DevTools CDP round trip that backs FetchTabThumbnail. + TabThumbnailFetcher tab_thumbnail_fetcher_; + // True while CloseAllBrowsers is tearing everything down (window close / // app quit). Individual tab closes are handled by detaching the tab's view // in DoClose; the whole-window close goes through the OS close path. diff --git a/src/bro_mac.mm b/src/bro_mac.mm index 991577e..22480cb 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -4,6 +4,7 @@ // found in the LICENSE file. #import +#import #import #include @@ -2674,6 +2675,51 @@ void OnTabDescriptionAvailable(int browser_id, const std::string& description) { }); } +void OnTabThumbnailAvailable(int browser_id, const std::string& jpeg_base64) { + // Convert before dispatching (see UpdateURL). + NSString* base64Str = + [NSString stringWithUTF8String:jpeg_base64.c_str()] ?: @""; + // Decode and downscale off-main (the favicon loader's pattern); only + // delivery hops to main. The 512px cap bounds decoded memory to ~1MB per + // tab while staying above 2x of the hover card's drawn size. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + NSImage* image = nil; + NSData* data = [[NSData alloc] + initWithBase64EncodedString:base64Str + options: + NSDataBase64DecodingIgnoreUnknownCharacters]; + if (data.length > 0) { + CGImageSourceRef source = + CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL); + if (source) { + NSDictionary* opts = @{ + (__bridge NSString*)kCGImageSourceCreateThumbnailFromImageAlways : + @YES, + (__bridge NSString*)kCGImageSourceThumbnailMaxPixelSize : @512, + }; + CGImageRef thumb = CGImageSourceCreateThumbnailAtIndex( + source, 0, (__bridge CFDictionaryRef)opts); + if (thumb) { + image = [[NSImage alloc] + initWithCGImage:thumb + size:NSMakeSize(CGImageGetWidth(thumb), + CGImageGetHeight(thumb))]; + CGImageRelease(thumb); + } + CFRelease(source); + } + } + if (!image) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (g_tab_bar) { + [g_tab_bar updateTabThumbnail:browser_id image:image]; + } + }); + }); +} + void OnTabURLChanged(int browser_id, const std::string& url) { // Convert before dispatching (see UpdateURL). NSString* urlStr = [NSString stringWithUTF8String:url.c_str()] ?: @""; @@ -2717,6 +2763,18 @@ void OnTabLoadingChanged(int browser_id, bool is_loading) { if (g_tab_bar) { [g_tab_bar updateTabLoading:browser_id loading:is_loading]; } + // Refresh the hover-card thumbnail when a visible tab finishes loading; + // hidden tabs get theirs at switch-away time instead (their compositor + // may not produce frames). + if (!is_loading && ![g_blank_tab_ids containsObject:@(browser_id)]) { + BroHandler* handler = BroHandler::GetInstance(); + BOOL visible = handler && + (browser_id == handler->GetActiveBrowserId() || + (SplitActive() && browser_id == g_split_browser_id)); + if (visible) { + handler->FetchTabThumbnail(browser_id); + } + } }); } diff --git a/src/bro_mac_internal.h b/src/bro_mac_internal.h index 4477553..e3fa685 100644 --- a/src/bro_mac_internal.h +++ b/src/bro_mac_internal.h @@ -85,6 +85,11 @@ static const NSTimeInterval kViewportAnimDuration = 0.28; static const NSTimeInterval kHoverCardDelay = 1.0; static const NSTimeInterval kHoverCardHideGrace = 0.3; static const CGFloat kHoverCardButtonSize = 24.0; +// The card's page-snapshot band: inset from the card edges (its corner +// radius stays concentric with the glass surface) and the fixed 16:10 +// crop it is aspect-filled into. +static const CGFloat kHoverCardThumbnailInset = 6.0; +static const CGFloat kHoverCardThumbnailAspect = 10.0 / 16.0; // Hover/press icon feedback: the whole button scales about its center, // animated by the shared 0.15s ease-out layer actions. Kept subtle so the // chrome stays calm; skipped entirely under Reduce Motion. @@ -325,6 +330,10 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); // nil until fetched (empty string = fetched, page has none). Cleared whenever // tabURL changes. @property (nonatomic, copy) NSString* pageDescription; +// Page snapshot captured via DevTools for the hover card; nil until a +// capture lands. Cleared whenever tabURL changes; refreshed at switch-away, +// on load-complete while visible, and on hover. +@property (nonatomic, strong) NSImage* pageThumbnail; @property (nonatomic, weak) id target; @property (nonatomic, assign) SEL selectAction; @property (nonatomic, assign) SEL closeAction; @@ -382,6 +391,7 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); - (void)tabFocusEnded:(BroTabView*)tab; - (void)hideHoverCard; - (void)updateTabDescription:(int)browserId description:(NSString*)desc; +- (void)updateTabThumbnail:(int)browserId image:(NSImage*)image; // Toggles the tab's pinned state, moving it across the pinned/unpinned // boundary (pinned pills are a strict prefix of the strip). - (void)togglePinForTab:(BroTabView*)tab; diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index 58a0af7..86a91dd 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -611,8 +611,9 @@ - (void)setFaviconURL:(NSString*)urlString { - (void)setTabURL:(NSString*)url { NSString* newURL = [url copy] ?: @""; if (![_tabURL isEqualToString:newURL]) { - // The cached meta description belongs to the old page. + // The cached meta description and snapshot belong to the old page. _pageDescription = nil; + _pageThumbnail = nil; } _tabURL = newURL; NSString* display = BroURLIsBlank(_tabURL) @@ -1167,15 +1168,18 @@ @interface BroTabHoverCard : NSView // The tab bar owning the hover lifecycle; told when the mouse enters/leaves // the card so the grace-period hide can be canceled and re-armed. @property (nonatomic, weak) BroTabBar* hoverDelegate; -// Populates the labels and resizes self to fit; caller positions the frame. +// Populates the labels and snapshot band and resizes self to fit; caller +// positions the frame. - (void)setTitle:(NSString*)title url:(NSString*)url pageDescription:(NSString*)desc + thumbnail:(NSImage*)thumbnail width:(CGFloat)width; @end @implementation BroTabHoverCard { NSView* contentHost_; + NSView* thumbnailView_; BroShimmerTextView* titleLabel_; NSTextField* urlLabel_; NSTextField* descriptionLabel_; @@ -1218,6 +1222,21 @@ - (instancetype)initWithFrame:(NSRect)frame { descriptionLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.75); descriptionLabel_.lineBreakMode = NSLineBreakByWordWrapping; descriptionLabel_.cell.wraps = YES; + + // Page snapshot band; absent until a capture lands. A plain layer-backed + // view rather than an NSImageView: aspect-fill + masksToBounds center- + // crops captures whose aspect differs from the band's (e.g. the portrait + // mobile-emulation viewport). + thumbnailView_ = [[NSView alloc] initWithFrame:NSZeroRect]; + thumbnailView_.wantsLayer = YES; + thumbnailView_.layer.masksToBounds = YES; + thumbnailView_.layer.contentsGravity = kCAGravityResizeAspectFill; + // Hairline to seat light page content on the glass. + thumbnailView_.layer.borderWidth = 1.0; + thumbnailView_.layer.borderColor = + [[NSColor labelColor] colorWithAlphaComponent:0.08].CGColor; + thumbnailView_.hidden = YES; + [contentHost_ addSubview:thumbnailView_]; [contentHost_ addSubview:titleLabel_]; [contentHost_ addSubview:urlLabel_]; [contentHost_ addSubview:descriptionLabel_]; @@ -1270,6 +1289,7 @@ - (void)mouseExited:(NSEvent*)event { - (void)setTitle:(NSString*)title url:(NSString*)url pageDescription:(NSString*)desc + thumbnail:(NSImage*)thumbnail width:(CGFloat)width { const CGFloat padX = 12.0; const CGFloat padY = 10.0; @@ -1280,6 +1300,11 @@ - (void)setTitle:(NSString*)title urlLabel_.stringValue = url ?: @""; descriptionLabel_.stringValue = desc ?: @""; descriptionLabel_.hidden = desc.length == 0; + thumbnailView_.hidden = thumbnail == nil; + thumbnailView_.layer.contents = + thumbnail + ? (id)[thumbnail CGImageForProposedRect:NULL context:nil hints:nil] + : nil; self.accessibilityLabel = title.length > 0 ? [NSString stringWithFormat:@"Tab preview: %@", title] : @"Tab preview"; @@ -1301,12 +1326,22 @@ - (void)setTitle:(NSString*)title // Space between the text block and the action-button row beneath it. const CGFloat buttonRowGap = 8.0; + // Space between the snapshot band and the title beneath it. + const CGFloat thumbnailGap = 8.0; + CGFloat thumbnailWidth = width - kHoverCardThumbnailInset * 2; + CGFloat thumbnailHeight = + thumbnail ? round(thumbnailWidth * kHoverCardThumbnailAspect) : 0; CGFloat height = padY * 2 + titleHeight + rowGap + urlHeight + buttonRowGap + kHoverCardButtonSize; if (descriptionHeight > 0) { height += rowGap + descriptionHeight; } + if (thumbnailHeight > 0) { + // The snapshot band swaps the top text padding for its tighter inset. + height += + thumbnailHeight + thumbnailGap + kHoverCardThumbnailInset - padY; + } // Flipped-less (default) coords: title on top, buttons at the bottom. The // buttons overhang padX slightly so their icons (not their hover @@ -1328,6 +1363,13 @@ - (void)setTitle:(NSString*)title urlLabel_.frame = NSMakeRect(padX, y, textWidth, urlHeight); y += urlHeight + rowGap; titleLabel_.frame = NSMakeRect(padX, y, textWidth, titleHeight); + if (thumbnailHeight > 0) { + y += titleHeight + thumbnailGap; + thumbnailView_.layer.cornerRadius = BroNestedCornerRadius( + self.layer.cornerRadius, kHoverCardThumbnailInset); + thumbnailView_.frame = NSMakeRect(kHoverCardThumbnailInset, y, + thumbnailWidth, thumbnailHeight); + } [self setFrameSize:NSMakeSize(width, height)]; } @@ -2457,11 +2499,13 @@ - (void)showHoverCardForTab:(BroTabView*)tab { [hoverCard_ setTitle:kBroBlankTabTitle url:@"Ask anything…" pageDescription:nil + thumbnail:nil width:width]; } else { [hoverCard_ setTitle:[self hoverCardTitleForTab:tab] url:BroDisplayHostForURL(tab.tabURL) pageDescription:tab.pageDescription + thumbnail:tab.pageThumbnail width:width]; } [self positionHoverCardForTab:tab animated:wasVisible]; @@ -2469,10 +2513,22 @@ - (void)showHoverCardForTab:(BroTabView*)tab { // Fetch the description on first hover per page; the card shows right away // with title + URL and fills in when (if) the result arrives. - if (tab.pageDescription == nil && !BroURLIsBlank(tab.tabURL)) { + if (!BroURLIsBlank(tab.tabURL)) { BroHandler* handler = BroHandler::GetInstance(); if (handler) { - handler->FetchTabDescription(tab.browserId); + if (tab.pageDescription == nil) { + handler->FetchTabDescription(tab.browserId); + } + // Snapshot: refresh a visible tab (its compositor is live), or make a + // best-effort first capture for a background tab that has none — a + // hidden browser often still serves its last surface frame; if the + // capture never resolves, the card simply stays text-only. + BOOL visibleTab = + tab.browserId == _activeTabId || + (SplitActive() && tab.browserId == g_split_browser_id); + if (visibleTab || tab.pageThumbnail == nil) { + handler->FetchTabThumbnail(tab.browserId); + } } } } @@ -2512,6 +2568,32 @@ - (void)updateTabDescription:(int)browserId description:(NSString*)desc { [hoverCard_ setTitle:[self hoverCardTitleForTab:tab] url:BroDisplayHostForURL(tab.tabURL) pageDescription:tab.pageDescription + thumbnail:tab.pageThumbnail + width:hoverCard_.frame.size.width]; + [self positionHoverCardForTab:tab animated:YES]; + } + break; + } +} + +- (void)updateTabThumbnail:(int)browserId image:(NSImage*)image { + if (!image) { + return; + } + for (BroTabView* tab in _tabs) { + if (tab.browserId != browserId) { + continue; + } + tab.pageThumbnail = image; + // Live-fill the visible card (the frame re-spring animates the growth). + // A tab that went blank since the capture keeps its "New Tab" card + // instead of a stale page's snapshot. + if (hoverCardTabId_ == browserId && hoverCard_ && !hoverCard_.hidden && + !BroURLIsBlank(tab.tabURL)) { + [hoverCard_ setTitle:[self hoverCardTitleForTab:tab] + url:BroDisplayHostForURL(tab.tabURL) + pageDescription:tab.pageDescription + thumbnail:tab.pageThumbnail width:hoverCard_.frame.size.width]; [self positionHoverCardForTab:tab animated:YES]; } diff --git a/src/tab_thumbnail_fetcher.cc b/src/tab_thumbnail_fetcher.cc new file mode 100644 index 0000000..bda52c8 --- /dev/null +++ b/src/tab_thumbnail_fetcher.cc @@ -0,0 +1,98 @@ +// Copyright (c) 2013 The Chromium Embedded Framework Authors. All rights +// reserved. Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +#include "tab_thumbnail_fetcher.h" + +#include "include/cef_parser.h" + +TabThumbnailFetcher::TabThumbnailFetcher(CefDevToolsMessageObserver* observer) + : observer_(observer) {} + +void TabThumbnailFetcher::Fetch(CefRefPtr browser) { + if (!browser) { + return; + } + CefRefPtr host = browser->GetHost(); + if (!host) { + return; + } + // Blank pages have nothing worth snapshotting; the hover card shows its + // "New Tab" variant for them anyway. + CefRefPtr frame = browser->GetMainFrame(); + std::string url = frame ? frame->GetURL().ToString() : std::string(); + if (url.empty() || url == "about:blank") { + return; + } + int browser_id = browser->GetIdentifier(); + + // Register the observer for this browser once; the registration stays + // alive until the browser closes (OnBrowserClosed erases it). + if (devtools_registrations_.find(browser_id) == + devtools_registrations_.end()) { + CefRefPtr registration = + host->AddDevToolsMessageObserver(observer_); + if (!registration) { + return; + } + devtools_registrations_[browser_id] = registration; + } + + // Captures the compositor's last surface frame (fromSurface defaults to + // true), so a capture issued just before the browser is hidden still + // resolves against the frame that was on screen. + CefRefPtr params = CefDictionaryValue::Create(); + params->SetString("format", "jpeg"); + params->SetInt("quality", 70); + int message_id = + host->ExecuteDevToolsMethod(0, "Page.captureScreenshot", params); + if (message_id != 0) { + // Only the latest request per browser is tracked; a superseded in-flight + // result is simply ignored in HandleMethodResult. + pending_thumbnail_requests_[browser_id] = message_id; + } +} + +void TabThumbnailFetcher::HandleMethodResult(CefRefPtr browser, + int message_id, + bool success, + const void* result, + size_t result_size) { + if (!browser) { + return; + } + int browser_id = browser->GetIdentifier(); + auto it = pending_thumbnail_requests_.find(browser_id); + if (it == pending_thumbnail_requests_.end() || it->second != message_id) { + // Not ours (e.g. a description fetch's result routed to the same + // observer). + return; + } + pending_thumbnail_requests_.erase(it); + + if (!success || !result || result_size == 0) { + return; + } + // result is the CDP response's payload: {"data": ""} — flat, + // unlike Runtime.evaluate's nested result.value. + CefRefPtr parsed = + CefParseJSON(result, result_size, JSON_PARSER_RFC); + if (!parsed || parsed->GetType() != VTYPE_DICTIONARY) { + return; + } + CefRefPtr dict = parsed->GetDictionary(); + if (!dict->HasKey("data") || dict->GetType("data") != VTYPE_STRING) { + return; + } + std::string data = dict->GetString("data").ToString(); + // Failures deliver nothing (unlike descriptions, which cache "none"): the + // UI keeps any older snapshot and the next trigger simply retries. + if (!data.empty()) { + OnTabThumbnailAvailable(browser_id, data); + } +} + +void TabThumbnailFetcher::OnBrowserClosed(int browser_id) { + devtools_registrations_.erase(browser_id); + pending_thumbnail_requests_.erase(browser_id); +} diff --git a/src/tab_thumbnail_fetcher.h b/src/tab_thumbnail_fetcher.h new file mode 100644 index 0000000..fce8ffd --- /dev/null +++ b/src/tab_thumbnail_fetcher.h @@ -0,0 +1,69 @@ +// Copyright (c) 2013 The Chromium Embedded Framework Authors. All rights +// reserved. Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +#ifndef TAB_THUMBNAIL_FETCHER_H_ +#define TAB_THUMBNAIL_FETCHER_H_ + +#include "include/cef_browser.h" +#include "include/cef_devtools_message_observer.h" +#include "include/cef_registration.h" + +#include +#include + +// Delivers a page snapshot as base64-encoded JPEG data in response to +// TabThumbnailFetcher::Fetch (implemented in bro_mac.mm). Only called on +// success with non-empty data; failed captures deliver nothing so an older +// good snapshot is never clobbered. +void OnTabThumbnailAvailable(int browser_id, const std::string& jpeg_base64); + +// Captures a snapshot of a tab's page via the DevTools protocol +// (Page.captureScreenshot over CefBrowserHost::ExecuteDevToolsMethod / +// CefDevToolsMessageObserver round trip) and delivers the result through +// OnTabThumbnailAvailable. Owns the per-browser DevTools observer +// registrations and in-flight request bookkeeping. +class TabThumbnailFetcher { + public: + // |observer| receives the raw DevTools method results (typically the + // owning BroHandler, which implements CefDevToolsMessageObserver and + // forwards its callback into HandleMethodResult()). Not owned; must + // outlive this object. + // + // Note: BroHandler is registered as a DevTools observer by both this + // fetcher and TabDescriptionFetcher, so CEF notifies it once per + // registration — every CDP result reaches each fetcher twice. Harmless: + // both filter by their own pending message id, and the duplicate call + // finds the pending entry already erased. + explicit TabThumbnailFetcher(CefDevToolsMessageObserver* observer); + + // Asynchronously captures a snapshot of |browser|'s page. Best-effort: + // silently does nothing for blank pages or browsers without a host, and + // never assumes the DevTools result callback fires (a capture issued + // against a browser that just went hidden may never resolve). + void Fetch(CefRefPtr browser); + + // Forwards a CefDevToolsMessageObserver::OnDevToolsMethodResult call here. + // Parses the CDP response and delivers it via OnTabThumbnailAvailable if + // |message_id| matches the in-flight request for |browser|. + void HandleMethodResult(CefRefPtr browser, + int message_id, + bool success, + const void* result, + size_t result_size); + + // Drops all bookkeeping for |browser_id|. Call when the browser closes; + // dropping the registration unregisters the DevTools observer, so no + // capture results arrive for a dead browser. + void OnBrowserClosed(int browser_id); + + private: + CefDevToolsMessageObserver* observer_; // Not owned. + + // Per-browser DevTools observer registrations (alive until the browser + // closes) and the message id of each browser's in-flight capture request. + std::map> devtools_registrations_; + std::map pending_thumbnail_requests_; +}; + +#endif // TAB_THUMBNAIL_FETCHER_H_