diff --git a/CMakeLists.txt b/CMakeLists.txt index 57c2583..5c579d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,8 +1,43 @@ # 3.24 for FetchContent's DOWNLOAD_EXTRACT_TIMESTAMP, used by the Sparkle fetch # below. On 3.21-3.23 that keyword is unknown and configuring fails outright. cmake_minimum_required(VERSION 3.24) + +# Liquid Glass is an SDK-linked behavior as well as a runtime API. Resolve and +# freeze the active macOS 26 SDK before project() performs its compiler checks; +# silently falling back to an older SDK would compile out the native surface +# types even when the app later runs on macOS 26. Keep the deployment target at +# 12 so every use of the new API remains weak-linked behind @available. +execute_process( + COMMAND /usr/bin/xcrun --sdk macosx --show-sdk-path + OUTPUT_VARIABLE BRO_MACOS_SDK_PATH + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE BRO_MACOS_SDK_RESULT +) +execute_process( + COMMAND /usr/bin/xcrun --sdk macosx --show-sdk-version + OUTPUT_VARIABLE BRO_MACOS_SDK_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE BRO_MACOS_SDK_VERSION_RESULT +) +if(NOT BRO_MACOS_SDK_RESULT EQUAL 0 OR + NOT BRO_MACOS_SDK_VERSION_RESULT EQUAL 0 OR + BRO_MACOS_SDK_PATH STREQUAL "") + message(FATAL_ERROR "A macOS 26 SDK is required to build Bro Computer") +endif() +if(BRO_MACOS_SDK_VERSION VERSION_LESS 26 OR + NOT BRO_MACOS_SDK_VERSION VERSION_LESS 27) + message(FATAL_ERROR + "Bro Computer must link the macOS 26 SDK for Liquid Glass; xcrun resolved ${BRO_MACOS_SDK_VERSION}") +endif() +set(CMAKE_OSX_SYSROOT "${BRO_MACOS_SDK_PATH}" CACHE PATH + "Pinned macOS 26 SDK used for native Liquid Glass" FORCE) +set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING + "Oldest supported macOS version" FORCE) + project(Bro) +include(CTest) + # Default to Release build if not specified if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) @@ -219,6 +254,28 @@ set_target_properties(${EXECUTABLE_NAME} PROPERTIES INSTALL_RPATH "@executable_path/../Frameworks" ) +if(BUILD_TESTING) + add_executable(bro_persist_tests + tests/bro_persist_tests.mm + src/bro_persist.mm) + target_include_directories(bro_persist_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_compile_options(bro_persist_tests PRIVATE -fobjc-arc) + target_link_libraries(bro_persist_tests "-framework Foundation") + add_test(NAME appearance_persistence COMMAND bro_persist_tests) + + add_executable(bro_glass_surface_tests + tests/bro_glass_surface_tests.mm + src/bro_motion.mm) + target_include_directories(bro_glass_surface_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_compile_options(bro_glass_surface_tests PRIVATE -fobjc-arc) + target_link_libraries(bro_glass_surface_tests + "-framework AppKit" + "-framework QuartzCore") + add_test(NAME native_glass_surfaces COMMAND bro_glass_surface_tests) +endif() + # Helper apps. Chromium on macOS launches subprocesses from helper bundles with # exact names derived from the app name: "Bro Computer Helper.app", # "Bro Computer Helper (Alerts).app", "(GPU)", "(Plugin)", "(Renderer)". CEF_HELPER_APP_SUFFIXES diff --git a/src/bro_app.cc b/src/bro_app.cc index ffeb801..e3a4ebd 100644 --- a/src/bro_app.cc +++ b/src/bro_app.cc @@ -4,6 +4,7 @@ #include "bro_app.h" +#include #include #include "include/cef_command_line.h" @@ -29,6 +30,11 @@ void BroApp::OnBeforeCommandLineProcessing( command_line->AppendSwitch("enable-gpu"); command_line->AppendSwitchWithValue("use-angle", "metal"); command_line->AppendSwitch("ignore-gpu-blocklist"); + // Isolated UI smoke tests must never ask for the user's login-keychain + // password. Production launches leave Chromium's secure storage untouched. + if (std::getenv("BRO_USE_MOCK_KEYCHAIN")) { + command_line->AppendSwitch("use-mock-keychain"); + } // Chrome's Reading Mode ships in CEF without the profile plumbing it // expects: its page-load-metrics observer dereferences a null side-panel diff --git a/src/bro_downloads.mm b/src/bro_downloads.mm index 5371b2b..d37e8c2 100644 --- a/src/bro_downloads.mm +++ b/src/bro_downloads.mm @@ -140,6 +140,13 @@ - (BOOL)resignFirstResponder { return ok; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + if (focused_) { + self.layer.borderColor = BroControlBorderColor().CGColor; + } +} + - (void)keyDown:(NSEvent*)event { NSString* characters = event.charactersIgnoringModifiers; unichar key = characters.length > 0 ? [characters characterAtIndex:0] : 0; @@ -182,6 +189,11 @@ @implementation BroDownloadsPopover { __weak BroHoverButton* hoveredRevealButton_; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + separator_.layer.backgroundColor = [NSColor separatorColor].CGColor; +} + static NSAttributedString* BroDownloadsButtonTitle(NSString* text, NSFont* font, CGFloat whiteAlpha, diff --git a/src/bro_mac.mm b/src/bro_mac.mm index 991577e..6a324c6 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -63,6 +63,14 @@ // Map browser IDs to their container views static NSMutableDictionary* g_browser_views = nil; +static BroAppearancePreference g_appearance_preference = BroAppearanceSystem; + +// Runs the real AppKit window/chrome without initializing CEF. This keeps UI +// smoke tests independent of Chromium's profile lock and login-keychain +// prompts while exercising the exact production view hierarchy. +static BOOL BroUISmokeTestEnabled(void) { + return getenv("BRO_UI_SMOKE_TEST") != nullptr; +} // Tabs currently on a blank page (about:blank or no committed URL yet). Their // container stays hidden even while active so the unified shell shows through @@ -212,6 +220,11 @@ @implementation BroWindowBorderView - (NSView*)hitTest:(NSPoint)point { return nil; } + +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + self.layer.borderColor = BroGlassBorderColor().CGColor; +} @end // AppKit owns the outer shell shape. Cache its resolved radius as the root of @@ -536,6 +549,11 @@ - (void)updateHighlight { [[NSColor labelColor] colorWithAlphaComponent:lit ? 0.7 : 0.4].CGColor; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + [self updateHighlight]; +} + - (void)mouseEntered:(NSEvent*)event { hovered_ = YES; [self updateHighlight]; @@ -1419,6 +1437,24 @@ static void BroSnapshotOpenTabsForReopen(void) { [title substringFromIndex:title.length - tail]]; } +static void BroApplyAppearancePreference( + BroAppearancePreference preference) { + switch (preference) { + case BroAppearanceLight: + NSApp.appearance = + [NSAppearance appearanceNamed:NSAppearanceNameAqua]; + break; + case BroAppearanceDark: + NSApp.appearance = + [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + break; + case BroAppearanceSystem: + default: + NSApp.appearance = nil; + break; + } +} + @interface BroAppDelegate : NSObject - (void)createApplication:(id)object; @@ -1444,6 +1480,10 @@ - (void)setHandlingSendEvent:(BOOL)handlingSendEvent { } - (void)sendEvent:(NSEvent*)event { + if (BroUISmokeTestEnabled()) { + [super sendEvent:event]; + return; + } CefScopedSendingEvent sendingEventScoper; // A repeated downloads-row shortcut is only meaningful when one magnifier @@ -1580,6 +1620,10 @@ - (void)sendEvent:(NSEvent*)event { } - (void)terminate:(id)sender { + if (BroUISmokeTestEnabled()) { + [super terminate:sender]; + return; + } BroAppDelegate* delegate = static_cast([NSApp delegate]); [delegate tryToTerminateApplication:self]; } @@ -1589,18 +1633,26 @@ - (void)terminate:(id)sender { @implementation BroAppDelegate - (void)createApplication:(id)object { - // Regular Liquid Glass owns local light/dark adaptation on macOS 26+. - // Older systems keep Bro's established dark HUD fallback unchanged. - if (@available(macOS 26.0, *)) { - NSApp.appearance = nil; - } else { - NSApp.appearance = - [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + // Default to the system appearance and honor an explicit user override. + // Apply before constructing any native glass so AppKit resolves its first + // material frame, text colors, and field editor consistently. + g_appearance_preference = BroLoadAppearancePreference(); + if (BroUISmokeTestEnabled()) { + NSString* override = NSProcessInfo.processInfo.environment[ + @"BRO_UI_SMOKE_APPEARANCE"]; + if ([override caseInsensitiveCompare:@"light"] == NSOrderedSame) { + g_appearance_preference = BroAppearanceLight; + } else if ([override caseInsensitiveCompare:@"dark"] == NSOrderedSame) { + g_appearance_preference = BroAppearanceDark; + } } + BroApplyAppearancePreference(g_appearance_preference); // Before the menu, so the "Check for Updates…" item can bind to a live // updater rather than a nil target. - BroStartUpdater(); + if (!BroUISmokeTestEnabled()) { + BroStartUpdater(); + } // Create the main menu [self setupMainMenu]; @@ -1634,6 +1686,39 @@ - (void)createApplication:(id)object { g_main_window.alphaValue = 1.0; }); + if (BroUISmokeTestEnabled()) { + // Two representative pills prove the native container's resting + // separation. Exercise the exact idle → edit → Return path that once + // made URL text disappear after AppKit re-resolved material colors. + [g_tab_bar addTabWithBrowserId:1 title:@"New Tab"]; + [g_tab_bar updateTabURL:1 url:@"https://example.com/path"]; + [g_tab_bar addTabWithBrowserId:2 title:@"Documentation"]; + [g_tab_bar updateTabURL:2 url:@"https://developer.apple.com/documentation"]; + [g_tab_bar setActiveTab:1]; + dispatch_async(dispatch_get_main_queue(), ^{ + BroTabView* active = [g_tab_bar tabWithBrowserId:1]; + [active focusAddressField]; + active.addressField.stringValue = @"https://example.com/path"; + NSNotification* returned = [NSNotification + notificationWithName:NSControlTextDidEndEditingNotification + object:active.addressField + userInfo:@{ @"NSTextMovement" : @(NSReturnTextMovement) }]; + [g_toolbar controlTextDidEndEditing:returned]; + if (NSProcessInfo.processInfo.environment[@"BRO_UI_SMOKE_SCREENSHOT"] != + nil) { + // Exercise the production transition after the address field has + // resigned first responder. A short deferred hop leaves the initial + // material transaction visible to AppKit before the shell is inset. + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + ToggleScreenshotMode(); + }); + } + }); + return; + } + // Create the CEF browser in our window [self performSelectorOnMainThread:@selector(createBrowserInWindow) withObject:nil @@ -1661,6 +1746,26 @@ - (void)setupMainMenu { keyEquivalent:@""]; updateItem.target = BroUpdaterMenuTarget(); [appMenu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem* appearanceItem = + [[NSMenuItem alloc] initWithTitle:@"Appearance" + action:nil + keyEquivalent:@""]; + NSMenu* appearanceMenu = [[NSMenu alloc] initWithTitle:@"Appearance"]; + for (NSDictionary* choice in @[ + @{ @"title" : @"System", @"tag" : @(BroAppearanceSystem) }, + @{ @"title" : @"Light", @"tag" : @(BroAppearanceLight) }, + @{ @"title" : @"Dark", @"tag" : @(BroAppearanceDark) }, + ]) { + NSMenuItem* item = + [appearanceMenu addItemWithTitle:choice[@"title"] + action:@selector(selectAppearance:) + keyEquivalent:@""]; + item.tag = [choice[@"tag"] integerValue]; + } + appearanceItem.submenu = appearanceMenu; + [appMenu addItem:appearanceItem]; + [appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItemWithTitle:@"Quit Bro Computer" action:@selector(terminate:) keyEquivalent:@"q"]; @@ -1954,8 +2059,14 @@ - (void)createBrowserInWindow { CefRect(0, 0, bounds.size.width, bounds.size.height)); window_info.runtime_style = CEF_RUNTIME_STYLE_ALLOY; - // Create the browser on a blank page; the address bar stays empty. - std::string url = "about:blank"; + // Create the browser on a blank page; the address bar stays empty. The + // environment-only URL hook lets the real CEF compositor run deterministic + // local WebGL smoke fixtures without changing normal startup behavior. + NSString* smokeURL = NSProcessInfo.processInfo.environment[ + @"BRO_CEF_SMOKE_URL"]; + std::string url = smokeURL.length > 0 + ? std::string(smokeURL.UTF8String) + : "about:blank"; [g_pending_address_focus_containers addObject:browserContainer]; if (!CefBrowserHost::CreateBrowser(window_info, handler, url, browser_settings, nullptr, nullptr)) { @@ -1963,8 +2074,25 @@ - (void)createBrowserInWindow { [browserContainer removeFromSuperview]; } + NSString* secondSmokeURL = NSProcessInfo.processInfo.environment[ + @"BRO_CEF_SMOKE_NEW_TAB_URL"]; + if (secondSmokeURL.length > 0) { + std::string secondURL(secondSmokeURL.UTF8String); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + CreateNewBrowserTabWithURL(secondURL); + }); + } + if (NSProcessInfo.processInfo.environment[ + @"BRO_CEF_SMOKE_SCREENSHOT"] != nil) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1200 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + ToggleScreenshotMode(); + }); + } + if (g_toolbar) { - [g_toolbar updateURL:@""]; + [g_toolbar updateURL:smokeURL.length > 0 ? smokeURL : @""]; } } @@ -1998,6 +2126,22 @@ - (BOOL)applicationSupportsSecureRestorableState:(NSApplication*)app { } // Menu actions +- (void)selectAppearance:(NSMenuItem*)sender { + NSInteger value = sender.tag; + if (value < BroAppearanceSystem || value > BroAppearanceDark) { + return; + } + BroAppearancePreference preference = (BroAppearancePreference)value; + if (preference == g_appearance_preference) { + return; + } + g_appearance_preference = preference; + BroSaveAppearancePreference(preference); + BroApplyAppearancePreference(preference); + [g_main_window.contentView setNeedsDisplay:YES]; + [g_main_window.contentView displayIfNeeded]; +} + - (void)goBack:(id)sender { BroDismissTransientOverlays(); if (g_toolbar) { @@ -2266,6 +2410,12 @@ - (void)showTabSearch:(id)sender { // so the default branch must stay YES to keep the rest auto-enabled. - (BOOL)validateMenuItem:(NSMenuItem*)menuItem { SEL action = menuItem.action; + if (action == @selector(selectAppearance:)) { + menuItem.state = + menuItem.tag == g_appearance_preference ? NSControlStateValueOn + : NSControlStateValueOff; + return YES; + } if (action == @selector(goBack:) || action == @selector(goForward:)) { // Main thread == CEF UI thread, so querying the browser directly is safe // and never stale (the toolbar's copy updates via dispatch_async). @@ -2821,6 +2971,17 @@ void OpenLinkInNewTab(const std::string& url) { // Entry point function for the browser process. int main(int argc, char* argv[]) { + if (BroUISmokeTestEnabled()) { + @autoreleasepool { + [BroApplication sharedApplication]; + BroAppDelegate* delegate = [[BroAppDelegate alloc] init]; + NSApp.delegate = delegate; + [delegate createApplication:nil]; + [NSApp run]; + } + return 0; + } + // Load the CEF framework library at runtime instead of linking directly // as required by the macOS sandbox implementation. CefScopedLibraryLoader library_loader; diff --git a/src/bro_mac_internal.h b/src/bro_mac_internal.h index 4477553..bd9ec37 100644 --- a/src/bro_mac_internal.h +++ b/src/bro_mac_internal.h @@ -278,6 +278,9 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); // NSTextField subclass that tells the toolbar when it gains focus, so the // pill can swap from host-only display to the full editable URL. @interface BroAddressField : NSTextField +// The native glass pill owns this field through an intermediate contentView, +// so the direct superview is not necessarily the BroTabView. +@property (nonatomic, weak) BroTabView* owningTab; @end // One tab pill in the tab strip. Each pill permanently owns its address field; diff --git a/src/bro_motion.h b/src/bro_motion.h index 47d242a..98df43d 100644 --- a/src/bro_motion.h +++ b/src/bro_motion.h @@ -53,27 +53,32 @@ extern void BroRunLayoutSpring(void (^changes)(void), extern void BroApplyElevation(NSView* view, BroElevation elevation); // Shared material tokens for every glass surface. macOS 26 uses untinted -// Regular glass; older systems retain the dark HUD-style fallback. +// Regular glass; older systems retain an appearance-adaptive HUD fallback. extern const CGFloat kBroGlassTintAlpha; extern const CGFloat kBroGlassBorderWidth; extern NSColor* BroGlassTintColor(void); extern NSColor* BroGlassBorderColor(void); extern NSGlassEffectViewStyle BroGlassEffectStyle(void) API_AVAILABLE(macos(26.0)); +// Runtime gate shared by every native surface. BRO_FORCE_LEGACY_GLASS=1 is a +// test seam for exercising the macOS 12–25 fallback on a macOS 26 machine. +extern BOOL BroShouldUseNativeGlass(void); -// Installs the stable, shell-sized backdrop. It deliberately uses the -// conventional behind-window blur plus one tint rather than Liquid Glass, -// whose large-surface lensing continuously refracts background geometry. -// The returned sibling host owns the toolbar and other shell content. +// Installs the shell-sized native Liquid Glass backdrop on macOS 26. The +// returned host is NSGlassEffectView.contentView there; older systems receive +// the same content over the existing visual-effect fallback. extern NSView* BroInstallShellSurface(NSView* panel, CGFloat cornerRadius); // Replaces the elevation's flat fill with the shared glass surface and -// returns the sibling host that must own the surface's controls. Keeping the -// host outside NSGlassEffectView.contentView preserves normal field-editor, -// hit-testing, visibility, and Core Animation behavior on macOS 26; older -// systems use the same hierarchy above the existing HUD blur + tint. +// returns the host that must own the surface's controls. On macOS 26 this is +// the native glass view's contentView; older systems use a sibling host above +// the existing HUD blur + tint. // Call after BroApplyElevation and add all visible content to the result. extern NSView* BroInstallGlassSurface(NSView* panel, CGFloat cornerRadius); +// Wraps related glass descendants in AppKit's batching/merging container on +// macOS 26 and returns |contentView| unchanged on the legacy path. +extern NSView* BroGlassContainerForContentView(NSView* contentView, + CGFloat spacing); extern void BroOverlayShow(NSView* view); extern void BroOverlayHide(NSView* view); diff --git a/src/bro_motion.mm b/src/bro_motion.mm index d17bd9f..751c796 100644 --- a/src/bro_motion.mm +++ b/src/bro_motion.mm @@ -8,20 +8,27 @@ const CGFloat kBroGlassBorderWidth = 1.0; NSColor* BroGlassTintColor(void) { - return [NSColor colorWithWhite:0.0 alpha:kBroGlassTintAlpha]; + return [[NSColor controlBackgroundColor] + colorWithAlphaComponent:kBroGlassTintAlpha]; } NSColor* BroGlassBorderColor(void) { - if (@available(macOS 26.0, *)) { - return [NSColor separatorColor]; - } - return [NSColor colorWithWhite:1.0 alpha:0.12]; + return [NSColor separatorColor]; } NSGlassEffectViewStyle BroGlassEffectStyle(void) { return NSGlassEffectViewStyleRegular; } +BOOL BroShouldUseNativeGlass(void) { + if (@available(macOS 26.0, *)) { + NSString* forced = NSProcessInfo.processInfo.environment[ + @"BRO_FORCE_LEGACY_GLASS"]; + return forced.length == 0 || forced.boolValue == NO; + } + return NO; +} + namespace { struct BroSpringValues { @@ -276,12 +283,10 @@ void BroApplyElevation(NSView* view, BroElevation elevation) { } // Glass surface: the elevation's flat fill is replaced by glass while border -// and shadow stay on the panel's own layer. The controls live in a sibling -// host above the effect rather than NSGlassEffectView.contentView. AppKit's -// window-owned field editor and custom Core Animation content otherwise cross -// different compositor paths inside glass, which can leave stale text or -// controls visible when focus changes. Older systems use the same hierarchy -// above the established HUD blur + near-black tint. +// and shadow stay on the panel's own layer. On macOS 26 the controls live in +// NSGlassEffectView.contentView, the only hierarchy for which AppKit promises +// correct placement relative to the native effect. Older systems use a +// sibling host above the established HUD blur + adaptive tint. // Constraint-pinned rather than autoresized: panels are typically created at // NSZeroRect and sized on first show, and autoresizing from a zero-size // superview leaves the surface or content host with stale geometry. @@ -296,6 +301,25 @@ static void BroPinView(NSView* view, NSView* panel) { ]]; } +@interface BroAdaptiveGlassTintView : NSView +@end + +@implementation BroAdaptiveGlassTintView +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.wantsLayer = YES; + self.layer.backgroundColor = BroGlassTintColor().CGColor; + } + return self; +} + +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + self.layer.backgroundColor = BroGlassTintColor().CGColor; +} +@end + NSView* BroInstallShellSurface(NSView* panel, CGFloat cornerRadius) { if (!panel) { return nil; @@ -305,13 +329,23 @@ static void BroPinView(NSView* view, NSView* panel) { CGFloat backdropRadius = BroCornerRadiusForSize( BroNestedCornerRadius(cornerRadius, 0.0), panel.bounds.size); - NSVisualEffectView* backdrop = - [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; if (@available(macOS 26.0, *)) { - backdrop.material = NSVisualEffectMaterialUnderWindowBackground; - } else { - backdrop.material = NSVisualEffectMaterialHUDWindow; + if (BroShouldUseNativeGlass()) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; + glass.cornerRadius = backdropRadius; + glass.style = BroGlassEffectStyle(); + glass.tintColor = nil; + NSView* contentHost = [[NSView alloc] initWithFrame:glass.bounds]; + contentHost.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + glass.contentView = contentHost; + BroPinView(glass, panel); + return contentHost; + } } + NSVisualEffectView* backdrop = + [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; + backdrop.material = NSVisualEffectMaterialHUDWindow; backdrop.blendingMode = NSVisualEffectBlendingModeBehindWindow; backdrop.state = NSVisualEffectStateActive; backdrop.wantsLayer = YES; @@ -320,12 +354,9 @@ static void BroPinView(NSView* view, NSView* panel) { backdrop.layer.masksToBounds = YES; BroPinView(backdrop, panel); - // Resolve one consistent tone for the whole shell. The blur can still show - // ambient color, but it no longer performs Liquid Glass lensing or local - // light/dark flips as content moves behind the window. - NSView* tint = [[NSView alloc] initWithFrame:panel.bounds]; - tint.wantsLayer = YES; - tint.layer.backgroundColor = BroGlassTintColor().CGColor; + // Pre-26 fallback tone follows the selected/system appearance. + NSView* tint = + [[BroAdaptiveGlassTintView alloc] initWithFrame:panel.bounds]; tint.layer.cornerRadius = backdropRadius; tint.layer.cornerCurve = kCACornerCurveContinuous; tint.layer.masksToBounds = YES; @@ -352,39 +383,58 @@ static void BroPinView(NSView* view, NSView* panel) { contentHost.wantsLayer = YES; contentHost.layer.backgroundColor = [NSColor clearColor].CGColor; if (@available(macOS 26.0, *)) { - NSGlassEffectView* glass = - [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; - glass.cornerRadius = backdropRadius; - glass.style = BroGlassEffectStyle(); - glass.tintColor = nil; - // Regular glass supplies its own adaptive edge. A fixed white hairline - // would defeat that treatment on light backgrounds. - panel.layer.borderWidth = 0.0; - panel.layer.borderColor = [NSColor clearColor].CGColor; - BroPinView(glass, panel); - } else { - NSVisualEffectView* glass = - [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; - glass.material = NSVisualEffectMaterialHUDWindow; - glass.blendingMode = NSVisualEffectBlendingModeWithinWindow; - glass.state = NSVisualEffectStateActive; - glass.wantsLayer = YES; - glass.layer.cornerRadius = backdropRadius; - glass.layer.masksToBounds = YES; - BroPinView(glass, panel); - NSView* tint = [[NSView alloc] initWithFrame:panel.bounds]; - tint.wantsLayer = YES; - tint.layer.backgroundColor = BroGlassTintColor().CGColor; - tint.layer.cornerRadius = backdropRadius; - tint.layer.masksToBounds = YES; - BroPinView(tint, panel); + if (BroShouldUseNativeGlass()) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; + glass.cornerRadius = backdropRadius; + glass.style = BroGlassEffectStyle(); + glass.tintColor = nil; + // Regular glass supplies its own adaptive edge. A fixed white hairline + // would defeat that treatment on light backgrounds. + panel.layer.borderWidth = 0.0; + panel.layer.borderColor = [NSColor clearColor].CGColor; + glass.contentView = contentHost; + BroPinView(glass, panel); + return contentHost; + } } - // Add last so controls always stay above both the material and fallback - // tint without becoming part of either effect's private view hierarchy. + NSVisualEffectView* glass = + [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; + glass.material = NSVisualEffectMaterialHUDWindow; + glass.blendingMode = NSVisualEffectBlendingModeWithinWindow; + glass.state = NSVisualEffectStateActive; + glass.wantsLayer = YES; + glass.layer.cornerRadius = backdropRadius; + glass.layer.masksToBounds = YES; + BroPinView(glass, panel); + NSView* tint = + [[BroAdaptiveGlassTintView alloc] initWithFrame:panel.bounds]; + tint.layer.cornerRadius = backdropRadius; + tint.layer.masksToBounds = YES; + BroPinView(tint, panel); + // Add last so fallback controls stay above both the material and tint. BroPinView(contentHost, panel); return contentHost; } +NSView* BroGlassContainerForContentView(NSView* contentView, + CGFloat spacing) { + if (!contentView) { + return nil; + } + if (@available(macOS 26.0, *)) { + if (BroShouldUseNativeGlass()) { + NSGlassEffectContainerView* container = + [[NSGlassEffectContainerView alloc] + initWithFrame:contentView.frame]; + container.spacing = MAX(0.0, spacing); + container.contentView = contentView; + return container; + } + } + return contentView; +} + void BroOverlayShow(NSView* view) { if (!view) { return; @@ -513,6 +563,10 @@ - (void)hoverOnView:(NSView*)view animated:(BOOL)animated { if (!container || !view || !view.superview) { return; } + // CALayer stores a resolved CGColor, so refresh it at interaction time in + // case the app's explicit/system appearance changed while the group rested. + highlightLayer_.backgroundColor = + [[NSColor labelColor] colorWithAlphaComponent:0.08].CGColor; dismissGeneration_++; BOOL wasVisible = highlightLayer_.opacity > 0.001 || ((CALayer*)highlightLayer_.presentationLayer).opacity > 0.001; diff --git a/src/bro_palette.mm b/src/bro_palette.mm index a395dad..237d2e1 100644 --- a/src/bro_palette.mm +++ b/src/bro_palette.mm @@ -580,6 +580,13 @@ - (BOOL)resignFirstResponder { return ok; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + if (focused_) { + self.layer.borderColor = BroControlBorderColor().CGColor; + } +} + - (void)keyDown:(NSEvent*)event { NSString* characters = event.charactersIgnoringModifiers; unichar key = characters.length > 0 ? [characters characterAtIndex:0] : 0; @@ -640,6 +647,11 @@ @implementation BroCommandPalette { NSArray* historyItems_; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + separator_.layer.backgroundColor = [NSColor separatorColor].CGColor; +} + - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { diff --git a/src/bro_persist.h b/src/bro_persist.h index 79de2c7..12908f3 100644 --- a/src/bro_persist.h +++ b/src/bro_persist.h @@ -11,6 +11,15 @@ #import +// App appearance is a user preference rather than a launch-time fiat. The +// enum values double as menu-item tags; persistence uses stable strings so a +// future reorder cannot reinterpret an existing profile. +typedef NS_ENUM(NSInteger, BroAppearancePreference) { + BroAppearanceSystem = 0, + BroAppearanceLight = 1, + BroAppearanceDark = 2, +}; + // Profile directory shared with CEF's cache: $BRO_USER_DATA_DIR when absolute // (test harnesses run isolated instances concurrently), else ~/Library/ // Application Support/Bro. Created on first use; main() feeds the same path @@ -27,4 +36,10 @@ id BroLoadJSONFile(NSString* filename); // stops the loop, queued async work never runs. void BroSaveJSONFile(NSString* filename, id obj); +// Stored in /preferences.json using the same atomic JSON path as the +// rest of Bro's small durable state. Missing/corrupt/unknown values resolve to +// System so first launch follows macOS. +BroAppearancePreference BroLoadAppearancePreference(void); +void BroSaveAppearancePreference(BroAppearancePreference preference); + #endif // BRO_PERSIST_H_ diff --git a/src/bro_persist.mm b/src/bro_persist.mm index 6b64f55..0c73661 100644 --- a/src/bro_persist.mm +++ b/src/bro_persist.mm @@ -5,6 +5,9 @@ #import "bro_persist.h" +static NSString* const kBroPreferencesFilename = @"preferences.json"; +static NSString* const kBroAppearancePreferenceKey = @"appearance"; + NSString* BroUserDataDirectory(void) { static NSString* dir = nil; static dispatch_once_t once; @@ -47,3 +50,35 @@ void BroSaveJSONFile(NSString* filename, id obj) { [BroUserDataDirectory() stringByAppendingPathComponent:filename]; [data writeToFile:path options:NSDataWritingAtomic error:nil]; } + +BroAppearancePreference BroLoadAppearancePreference(void) { + id value = BroLoadJSONFile(kBroPreferencesFilename); + if (![value isKindOfClass:[NSDictionary class]]) { + return BroAppearanceSystem; + } + NSString* appearance = ((NSDictionary*)value)[kBroAppearancePreferenceKey]; + if ([appearance isEqualToString:@"light"]) { + return BroAppearanceLight; + } + if ([appearance isEqualToString:@"dark"]) { + return BroAppearanceDark; + } + return BroAppearanceSystem; +} + +void BroSaveAppearancePreference(BroAppearancePreference preference) { + NSString* value = @"system"; + if (preference == BroAppearanceLight) { + value = @"light"; + } else if (preference == BroAppearanceDark) { + value = @"dark"; + } + + id existing = BroLoadJSONFile(kBroPreferencesFilename); + NSMutableDictionary* preferences = + [existing isKindOfClass:[NSDictionary class]] + ? [existing mutableCopy] + : [NSMutableDictionary dictionary]; + preferences[kBroAppearancePreferenceKey] = value; + BroSaveJSONFile(kBroPreferencesFilename, preferences); +} diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index 58a0af7..e771d56 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -18,6 +18,9 @@ // hairline is its primary structural selection cue. static const CGFloat kActiveTabBorderAlpha = 0.28; static const CGFloat kHoveredTabBorderAlpha = 0.18; +// Resting pills keep the strip's 8pt gap. As a dragged pill comes within 6pt +// of a neighbor, AppKit's glass container performs the native merge/split. +static const CGFloat kTabGlassMergeSpacing = 6.0; static const CFTimeInterval kTabColorTransitionDuration = 0.18; static NSDictionary* BroTabLayerTransitionActions(void) { @@ -196,6 +199,12 @@ - (void)refreshBorder { } } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + [self refreshBorder]; + [self refreshBackground]; +} + - (void)setBaseBorderWidth:(CGFloat)width { _baseBorderWidth = width; [self refreshBorder]; @@ -410,10 +419,12 @@ void BroFetchFaviconGuarded(NSString* urlString, } @implementation BroTabView { - // macOS 12–25 keep the active browse input's established HUD glass. On - // macOS 26+ the whole toolbar is one Regular glass surface, so a nested - // pill surface would be glass-on-glass and is deliberately omitted. + // Every macOS 26 pill owns a native glass descendant so the tab strip's + // NSGlassEffectContainerView can batch it and merge neighboring pills + // during drag. Older systems retain the established HUD fallback. NSView* glassBackdrop_; + NSView* pillContentHost_; + BOOL nativeGlassBackdrop_; BOOL hovered_; BOOL focused_; BOOL wasActiveAtMouseDown_; @@ -449,12 +460,22 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { // accent-colored ring. self.focusRingType = NSFocusRingTypeNone; - // Older systems retain the active pill's HUD surface. Regular Liquid - // Glass on macOS 26+ belongs to the toolbar host instead, with this pill - // communicating selection through an adaptive fill and hairline. + // Native pills stay neutral; selection is layered through adaptive + // content and a restrained fill so all descendants remain sufficiently + // similar for NSGlassEffectContainerView to merge them. if (@available(macOS 26.0, *)) { - glassBackdrop_ = nil; - } else { + if (BroShouldUseNativeGlass()) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:self.bounds]; + glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + glass.cornerRadius = BroNestedCornerRadius(pillCornerRadius, 0.0); + glass.style = BroGlassEffectStyle(); + glass.tintColor = nil; + glassBackdrop_ = glass; + nativeGlassBackdrop_ = YES; + } + } + if (!glassBackdrop_) { NSVisualEffectView* glass = [[NSVisualEffectView alloc] initWithFrame:self.bounds]; glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; @@ -470,10 +491,25 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { glassBackdrop_ = glass; } if (glassBackdrop_) { - glassBackdrop_.hidden = YES; + glassBackdrop_.hidden = !nativeGlassBackdrop_; [self addSubview:glassBackdrop_]; } + // NSGlassEffectView composites its contentView above the refractive + // material. Keeping controls as siblings puts their glyphs and text below + // the effect and can wash them out completely. The legacy path uses the + // same host as an ordinary sibling so both layouts remain identical. + pillContentHost_ = [[NSView alloc] initWithFrame:self.bounds]; + pillContentHost_.autoresizingMask = + NSViewWidthSizable | NSViewHeightSizable; + if (nativeGlassBackdrop_) { + if (@available(macOS 26.0, *)) { + ((NSGlassEffectView*)glassBackdrop_).contentView = pillContentHost_; + } + } else { + [self addSubview:pillContentHost_]; + } + // Favicon view _faviconView = [[NSImageView alloc] initWithFrame:NSMakeRect(10, (kTabPillHeight - 15) / 2.0, 15, 15)]; @@ -484,7 +520,7 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { _faviconView.image = RadixIconImage(RadixIconGlobe, 15); _faviconView.contentTintColor = BroPlaceholderFaviconColor(); showingDefaultFavicon_ = YES; - [self addSubview:_faviconView]; + [pillContentHost_ addSubview:_faviconView]; // The host remains authoritative while loading; its shimmer is the one // loading treatment, so the favicon never swaps to a second animation. @@ -496,7 +532,7 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { frame.size.width - 32 - 26, kTabTextFrameHeight); [_titleLabel setText:kBroBlankTabTitle]; _titleLabel.autoresizingMask = NSViewWidthSizable; - [self addSubview:_titleLabel]; + [pillContentHost_ addSubview:_titleLabel]; // Every tab owns its editor permanently. Moving one shared NSTextField // while AppKit's window-owned field editor was live could leave the old @@ -532,8 +568,9 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { NSBaselineOffsetAttributeName : @(placeholderBaselineOffset), }]; _addressField.accessibilityLabel = @"Address and search bar"; + _addressField.owningTab = self; _addressField.hidden = YES; - [self addSubview:_addressField]; + [pillContentHost_ addSubview:_addressField]; // Trailing tab action: normal tabs show close; pinned tabs reuse the same // slot and hover behavior for unpin. @@ -573,7 +610,7 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { _closeButton = closeButton; // Start hidden to match trailingActionShown_'s NO default. _closeButton.hidden = YES; - [self addSubview:_closeButton]; + [pillContentHost_ addSubview:_closeButton]; [self updateAppearance]; @@ -717,10 +754,10 @@ - (void)updateAppearance { [CATransaction setDisableActions:BroMotionReduced()]; BOOL usesAdaptiveToolbarGlass = NO; if (@available(macOS 26.0, *)) { - usesAdaptiveToolbarGlass = YES; + usesAdaptiveToolbarGlass = BroShouldUseNativeGlass(); } if (_isActive) { - glassBackdrop_.hidden = _dropTarget; + glassBackdrop_.hidden = nativeGlassBackdrop_ ? NO : _dropTarget; self.layer.backgroundColor = usesAdaptiveToolbarGlass ? [[NSColor labelColor] colorWithAlphaComponent:0.08].CGColor @@ -733,7 +770,7 @@ - (void)updateAppearance { _titleLabel.color = foreground; [self applyDefaultFaviconColor:foreground]; } else { - glassBackdrop_.hidden = _dropTarget; + glassBackdrop_.hidden = nativeGlassBackdrop_ ? NO : _dropTarget; self.layer.backgroundColor = [NSColor clearColor].CGColor; BOOL emphasizeBorder = _isSplitPane || hovered_ || focused_; CGFloat borderAlpha = @@ -747,7 +784,7 @@ - (void)updateAppearance { // A dragged pill hovering this one (drop = split the two tabs) outshines // every other state so the target is unmistakable. if (_dropTarget) { - glassBackdrop_.hidden = YES; + glassBackdrop_.hidden = !nativeGlassBackdrop_; self.layer.backgroundColor = [[NSColor labelColor] colorWithAlphaComponent:0.14].CGColor; self.layer.borderColor = @@ -777,6 +814,11 @@ - (void)updateAppearance { [self setTrailingActionShown:showAction]; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + [self updateAppearance]; +} + // Fades the close/unpin action in/out instead of snapping. - (void)setTrailingActionShown:(BOOL)shown { if (shown == trailingActionShown_) { @@ -1369,6 +1411,7 @@ - (void)scrollWheel:(NSEvent*)event { @implementation BroTabBar { BroHorizontalTabScrollView* tabScrollView_; + NSView* tabDocumentView_; NSView* tabContentView_; BroTabView* draggingTab_; BOOL dragging_; @@ -1421,7 +1464,9 @@ - (instancetype)initWithFrame:(NSRect)frame { tabContentView_ = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 0, frame.size.height)]; tabContentView_.wantsLayer = YES; - tabScrollView_.documentView = tabContentView_; + tabDocumentView_ = BroGlassContainerForContentView( + tabContentView_, kTabGlassMergeSpacing); + tabScrollView_.documentView = tabDocumentView_; [self addSubview:tabScrollView_]; // Borderless "+" control, pinned between the scroll viewport and Search. @@ -2068,7 +2113,8 @@ - (void)layOutTabsThroughAnimator { } CGFloat documentWidth = MAX(viewportWidth, x); - tabContentView_.frame = NSMakeRect(0, 0, documentWidth, height); + tabDocumentView_.frame = NSMakeRect(0, 0, documentWidth, height); + tabContentView_.frame = tabDocumentView_.bounds; [self clampScrollOffset]; // The "+" trails the last pill by one gap (`x` already carries it) so it diff --git a/src/bro_text_morph.mm b/src/bro_text_morph.mm index ba39eeb..60a5e6b 100644 --- a/src/bro_text_morph.mm +++ b/src/bro_text_morph.mm @@ -160,6 +160,14 @@ - (void)viewDidChangeBackingProperties { [self updateBackingScale]; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + // BroTextLayer resolves its dynamic NSColor while drawing; invalidate the + // cached layer contents so an in-place System/Light/Dark change redraws it. + [baseTextLayer_ setNeedsDisplay]; + [shimmerMaskLayer_ setNeedsDisplay]; +} + - (void)viewDidMoveToWindow { [super viewDidMoveToWindow]; [self updateBackingScale]; diff --git a/src/bro_toolbar.mm b/src/bro_toolbar.mm index 6dc4c1a..ecbd08a 100644 --- a/src/bro_toolbar.mm +++ b/src/bro_toolbar.mm @@ -506,9 +506,7 @@ - (BOOL)configureAddressFieldEditor:(BroAddressField*)field { } - (void)addressFieldDidFocus:(BroAddressField*)field { - BroTabView* tab = [field.superview isKindOfClass:[BroTabView class]] - ? (BroTabView*)field.superview - : nil; + BroTabView* tab = field.owningTab; if (!tab || !tab.isActive) { return; } @@ -542,9 +540,7 @@ - (void)controlTextDidEndEditing:(NSNotification*)notification { return; } BroAddressField* field = (BroAddressField*)notification.object; - BroTabView* tab = [field.superview isKindOfClass:[BroTabView class]] - ? (BroTabView*)field.superview - : nil; + BroTabView* tab = field.owningTab; NSNumber* reason = notification.userInfo[@"NSTextMovement"]; if (tab.isActive && reason && reason.integerValue == NSReturnTextMovement) { diff --git a/tests/ax_smoke.swift b/tests/ax_smoke.swift new file mode 100644 index 0000000..10aba06 --- /dev/null +++ b/tests/ax_smoke.swift @@ -0,0 +1,206 @@ +import ApplicationServices +import CoreGraphics +import Foundation + +private func attribute(_ element: AXUIElement, _ name: CFString) -> T? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success else { + return nil + } + return value as? T +} + +private func children(of element: AXUIElement) -> [AXUIElement] { + attribute(element, kAXChildrenAttribute as CFString) ?? [] +} + +private func stringAttribute(_ element: AXUIElement, + _ name: CFString) -> String { + attribute(element, name) ?? "" +} + +private func descendants(of root: AXUIElement) -> [AXUIElement] { + var result: [AXUIElement] = [] + var queue = children(of: root) + while !queue.isEmpty { + let element = queue.removeFirst() + result.append(element) + queue.append(contentsOf: children(of: element)) + } + return result +} + +private func broPID() -> pid_t? { + let windows = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]] ?? [] + return windows.compactMap { window -> pid_t? in + guard window[kCGWindowOwnerName as String] as? String == "Bro Computer" + else { return nil } + return window[kCGWindowOwnerPID as String] as? pid_t + }.first +} + +guard AXIsProcessTrusted() else { + fputs("AX smoke requires Accessibility permission\n", stderr) + exit(2) +} +guard let pid = broPID() else { + fputs("AX smoke could not find a visible Bro Computer window\n", stderr) + exit(2) +} + +let app = AXUIElementCreateApplication(pid) +if CommandLine.arguments.contains("--dump") { + let elements = [app] + descendants(of: app) + for element in elements { + let role = stringAttribute(element, kAXRoleAttribute as CFString) + let title = stringAttribute(element, kAXTitleAttribute as CFString) + let description = stringAttribute( + element, kAXDescriptionAttribute as CFString) + let identifier = stringAttribute( + element, kAXIdentifierAttribute as CFString) + if !title.isEmpty || !description.isEmpty || !identifier.isEmpty || + role == kAXButtonRole as String || role == kAXTextFieldRole as String { + print("role=\(role) title=\(title) description=\(description) " + + "identifier=\(identifier)") + } + } + exit(0) +} + +private func fail(_ message: String) -> Never { + fputs("AX smoke failed: \(message)\n", stderr) + exit(1) +} + +private func firstElement(role: String? = nil, + title: String? = nil, + description: String? = nil, + identifier: String? = nil) -> AXUIElement? { + ([app] + descendants(of: app)).first { element in + if let role, + stringAttribute(element, kAXRoleAttribute as CFString) != role { + return false + } + if let title, + stringAttribute(element, kAXTitleAttribute as CFString) != title { + return false + } + if let description, + stringAttribute(element, kAXDescriptionAttribute as CFString) != + description { + return false + } + if let identifier, + stringAttribute(element, kAXIdentifierAttribute as CFString) != + identifier { + return false + } + return true + } +} + +private func waitUntil(timeout: TimeInterval = 8, + _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if condition() { return true } + Thread.sleep(forTimeInterval: 0.05) + } while Date() < deadline + return condition() +} + +private func tabCount() -> Int { + guard let group = firstElement(role: kAXTabGroupRole as String) else { + return 0 + } + return descendants(of: group).filter { + stringAttribute($0, kAXRoleAttribute as CFString) == + kAXRadioButtonRole as String + }.count +} + +guard let window = firstElement(role: kAXWindowRole as String) else { + fail("launch produced no accessible window") +} +let launchTitle = stringAttribute(window, kAXTitleAttribute as CFString) +guard launchTitle.hasPrefix("WEBGL_OK_") else { + fail("expected a rendered WebGL fixture, got window title \(launchTitle)") +} + +let initialTabs = tabCount() +guard initialTabs > 0 else { + fail("tab strip exposed no accessible tabs") +} +guard let newTab = firstElement(role: kAXButtonRole as String, + description: "New Tab") else { + fail("New Tab button is missing from the accessibility tree") +} +guard AXUIElementPerformAction(newTab, kAXPressAction as CFString) == .success +else { + fail("AXPress failed for New Tab") +} +guard waitUntil({ tabCount() == initialTabs + 1 }) else { + fail("New Tab AXPress did not add exactly one accessible tab") +} + +guard let screenshotMode = firstElement( + role: kAXMenuItemRole as String, + identifier: "toggleScreenshotMode:") else { + fail("Screenshot Mode menu item is missing from the accessibility tree") +} +guard AXUIElementPerformAction(screenshotMode, + kAXPressAction as CFString) == .success else { + fail("AXPress failed for Screenshot Mode") +} +guard waitUntil({ + firstElement(role: kAXButtonRole as String, + description: "New Tab") == nil +}) else { + fail("Screenshot Mode did not hide chrome from the accessibility tree") +} + +guard AXUIElementPerformAction(screenshotMode, + kAXPressAction as CFString) == .success else { + fail("AXPress failed while leaving Screenshot Mode") +} +guard waitUntil({ + firstElement(role: kAXButtonRole as String, + description: "New Tab") != nil +}) else { + fail("leaving Screenshot Mode did not restore accessible chrome") +} + +var appearanceResult = "" +if let option = CommandLine.arguments.firstIndex(of: "--preferences"), + CommandLine.arguments.indices.contains(option + 1) { + let preferencesURL = URL( + fileURLWithPath: CommandLine.arguments[option + 1]) + func storedAppearance() -> String? { + guard let data = try? Data(contentsOf: preferencesURL), + let object = try? JSONSerialization.jsonObject(with: data) + as? [String: Any] else { return nil } + return object["appearance"] as? String + } + for choice in ["Dark", "System"] { + guard let item = firstElement(role: kAXMenuItemRole as String, + title: choice, + identifier: "selectAppearance:") else { + fail("\(choice) appearance menu item is missing") + } + guard AXUIElementPerformAction(item, kAXPressAction as CFString) == .success + else { + fail("AXPress failed for \(choice) appearance") + } + let expected = choice.lowercased() + guard waitUntil({ storedAppearance() == expected }) else { + fail("\(choice) appearance was not persisted as \(expected)") + } + } + appearanceResult = ", appearance Dark→System persisted" +} + +print("AX smoke passed: launch title \(launchTitle), tabs " + + "\(initialTabs)→\(initialTabs + 1), screenshot chrome hidden/restored" + + appearanceResult) diff --git a/tests/bro_glass_surface_tests.mm b/tests/bro_glass_surface_tests.mm new file mode 100644 index 0000000..0d57929 --- /dev/null +++ b/tests/bro_glass_surface_tests.mm @@ -0,0 +1,91 @@ +#import + +#import "bro_motion.h" + +// bro_geometry.h's production root is resolved from NSThemeFrame in +// bro_mac.mm. The isolated surface test does not link the browser/window +// implementation, so provide the same deterministic design-scale root. +CGFloat BroShellCornerRadius(void) { + return 12.0; +} + +static void Require(BOOL condition, NSString* message) { + if (!condition) { + NSLog(@"glass surface test failed: %@", message); + abort(); + } +} + +static NSView* FindSubviewOfClass(NSView* parent, Class cls) { + for (NSView* child in parent.subviews) { + if ([child isKindOfClass:cls]) { + return child; + } + } + return nil; +} + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + + if (@available(macOS 26.0, *)) { + unsetenv("BRO_FORCE_LEGACY_GLASS"); + NSView* shell = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 640, 480)]; + NSView* shellContent = BroInstallShellSurface(shell, 12.0); + NSGlassEffectView* shellGlass = (NSGlassEffectView*) + FindSubviewOfClass(shell, [NSGlassEffectView class]); + Require(shellGlass != nil, @"shell must use NSGlassEffectView"); + Require(shellGlass.contentView == shellContent, + @"shell controls must live in native contentView"); + Require(shellGlass.style == NSGlassEffectViewStyleRegular, + @"shell must use Regular glass"); + Require(shellGlass.tintColor == nil, + @"shell glass must remain neutral"); + Require(fabs(shellGlass.cornerRadius - 12.0) < 0.01, + @"shell radius must be handed to AppKit"); + + NSView* panel = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 320, 180)]; + BroApplyElevation(panel, BroElevationPanel); + NSView* panelContent = BroInstallGlassSurface(panel, 10.0); + NSGlassEffectView* panelGlass = (NSGlassEffectView*) + FindSubviewOfClass(panel, [NSGlassEffectView class]); + Require(panelGlass != nil, @"overlay must use NSGlassEffectView"); + Require(panelGlass.contentView == panelContent, + @"overlay controls must live in native contentView"); + Require(fabs(panelGlass.cornerRadius - 10.0) < 0.01, + @"overlay radius must be handed to AppKit"); + + NSView* tabs = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 600, 52)]; + NSView* tabDocument = BroGlassContainerForContentView(tabs, 6.0); + Require([tabDocument isKindOfClass:[NSGlassEffectContainerView class]], + @"tab content must use NSGlassEffectContainerView"); + NSGlassEffectContainerView* tabContainer = + (NSGlassEffectContainerView*)tabDocument; + Require(tabContainer.contentView == tabs, + @"tab content must be owned by the native container"); + Require(fabs(tabContainer.spacing - 6.0) < 0.01, + @"tab merge proximity must be handed to AppKit"); + + setenv("BRO_FORCE_LEGACY_GLASS", "1", 1); + NSView* fallback = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 320, 180)]; + NSView* fallbackContent = BroInstallGlassSurface(fallback, 10.0); + Require(FindSubviewOfClass(fallback, [NSGlassEffectView class]) == nil, + @"forced fallback must not instantiate native glass"); + Require(FindSubviewOfClass(fallback, [NSVisualEffectView class]) != nil, + @"forced fallback must retain NSVisualEffectView"); + Require(fallbackContent.superview == fallback, + @"fallback content host must remain usable"); + NSView* fallbackTabs = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 600, 52)]; + Require(BroGlassContainerForContentView(fallbackTabs, 6.0) == + fallbackTabs, + @"legacy tab content must bypass the unavailable container"); + } + } + return 0; +} diff --git a/tests/bro_persist_tests.mm b/tests/bro_persist_tests.mm new file mode 100644 index 0000000..b0ddca9 --- /dev/null +++ b/tests/bro_persist_tests.mm @@ -0,0 +1,46 @@ +#import + +#import "bro_persist.h" + +static void Require(BOOL condition, NSString* message) { + if (!condition) { + NSLog(@"appearance persistence test failed: %@", message); + abort(); + } +} + +int main(void) { + @autoreleasepool { + NSString* profile = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString + stringWithFormat:@"BroAppearanceTests-%@", + NSUUID.UUID.UUIDString]]; + setenv("BRO_USER_DATA_DIR", profile.fileSystemRepresentation, 1); + + Require(BroLoadAppearancePreference() == BroAppearanceSystem, + @"missing preference must default to System"); + + BroSaveAppearancePreference(BroAppearanceLight); + Require(BroLoadAppearancePreference() == BroAppearanceLight, + @"Light must round-trip"); + NSDictionary* light = BroLoadJSONFile(@"preferences.json"); + Require([light[@"appearance"] isEqualToString:@"light"], + @"Light must use its stable serialized name"); + + BroSaveJSONFile(@"preferences.json", + @{ @"appearance" : @"light", @"future" : @42 }); + BroSaveAppearancePreference(BroAppearanceDark); + NSDictionary* dark = BroLoadJSONFile(@"preferences.json"); + Require([dark[@"appearance"] isEqualToString:@"dark"], + @"Dark must round-trip"); + Require([dark[@"future"] isEqual:@42], + @"saving appearance must preserve unrelated preferences"); + + BroSaveJSONFile(@"preferences.json", @{ @"appearance" : @"unknown" }); + Require(BroLoadAppearancePreference() == BroAppearanceSystem, + @"unknown values must safely fall back to System"); + + [[NSFileManager defaultManager] removeItemAtPath:profile error:nil]; + } + return 0; +} diff --git a/tests/fixtures/webgl_smoke.html b/tests/fixtures/webgl_smoke.html new file mode 100644 index 0000000..3ee21a3 --- /dev/null +++ b/tests/fixtures/webgl_smoke.html @@ -0,0 +1,98 @@ + + +WEBGL_PENDING + +
+ + Testing the Chromium WebGL compositor… +
+