fancy new browser
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 727 lines 26 kB view raw
1import SwiftUI 2import AppKit 3import MereCore 4import MereKit 5 6public struct BrowserWindowView: View { 7 8 @StateObject var window: WindowViewModel 9 10 public init(window: WindowViewModel) { 11 _window = StateObject(wrappedValue: window) 12 } 13 14 private var isNewTab: Bool { 15 window.activeTab == nil || (window.activeTab?.url == nil && window.activeTab?.isLoading == false) 16 } 17 18 /// Returns the color scheme that gives readable contrast against `color`. 19 private func scheme(for color: NSColor) -> ColorScheme { 20 guard let rgb = color.usingColorSpace(.deviceRGB) else { return .light } 21 let r = rgb.redComponent, g = rgb.greenComponent, b = rgb.blueComponent 22 let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b 23 return luma > 0.5 ? .light : .dark 24 } 25 26 private var tintColor: Color { 27 if isNewTab { 28 return window.newTabBackgroundColor.map { Color(nsColor: $0) } 29 ?? Color(nsColor: .windowBackgroundColor) 30 } 31 if let tc = window.activeTab?.themeColor { return Color(nsColor: tc) } 32 return Color(nsColor: .windowBackgroundColor) 33 } 34 35 private var preferredScheme: ColorScheme? { 36 if let tc = window.activeTab?.themeColor { return scheme(for: tc) } 37 if isNewTab, let bg = window.newTabBackgroundColor { return scheme(for: bg) } 38 return nil 39 } 40 41 private var isLocalhost: Bool { 42 guard let url = window.activeTab?.url else { return false } 43 let host = url.host?.lowercased() ?? "" 44 return host == "localhost" || 45 host == "127.0.0.1" || 46 host == "::1" || 47 host.hasPrefix("127.") || 48 host == "[::1]" 49 } 50 51 public var body: some View { 52 ZStack { 53 // Full-window color gradient using the raw page background colour. 54 LinearGradient( 55 stops: [ 56 .init(color: tintColor.opacity(0.95), location: 0), 57 .init(color: tintColor.opacity(0.70), location: 1), 58 ], 59 startPoint: .top, 60 endPoint: .bottom 61 ) 62 .ignoresSafeArea() 63 .animation(.easeInOut(duration: 0.35), value: isNewTab) 64 65 HStack(spacing: 0) { 66 if window.sidebarVisible { 67 SidebarView(window: window) 68 .frame(width: 220) 69 .background(.ultraThinMaterial) 70 .transition(.move(edge: .leading).combined(with: .opacity)) 71 } 72 73 VStack(spacing: 0) { 74 // Toolbar: transparent background window gradient shows through. 75 BrowserToolbarView( 76 window: window, 77 sidebarVisible: $window.sidebarVisible, 78 focusTrigger: window.addressFocusTrigger 79 ) 80 .padding(.top, 8) 81 .padding(.leading, window.sidebarVisible ? 10 : 86) 82 .padding(.trailing, 10) 83 .padding(.bottom, 8) 84 85 // Content: material matching sidebar, fills all remaining space. 86 contentArea 87 .frame(maxWidth: .infinity, maxHeight: .infinity) 88 .clipShape(UnevenRoundedRectangle( 89 topLeadingRadius: 0, 90 bottomLeadingRadius: 10, 91 bottomTrailingRadius: 10, 92 topTrailingRadius: 0 93 )) 94 .overlay( 95 UnevenRoundedRectangle( 96 topLeadingRadius: 0, 97 bottomLeadingRadius: 10, 98 bottomTrailingRadius: 10, 99 topTrailingRadius: 0 100 ) 101 .strokeBorder(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 0.5) 102 ) 103 .padding(.horizontal, 8) 104 .padding(.bottom, 8) 105 } 106 .padding(4) 107 .overlay( 108 Group { 109 if isLocalhost { 110 UnevenRoundedRectangle( 111 topLeadingRadius: 0, 112 bottomLeadingRadius: 14, 113 bottomTrailingRadius: 14, 114 topTrailingRadius: 0 115 ) 116 .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6, 4])) 117 .foregroundStyle(.yellow) 118 } 119 } 120 ) 121 .ignoresSafeArea(edges: .top) 122 } 123 } 124 .animation(.spring(duration: 0.22), value: window.sidebarVisible) 125 .background(TrafficLightNudge(xOffset: 8, yOffset: 8)) 126 .preferredColorScheme(preferredScheme) 127 .onChange(of: window.activeTab?.id) { _, _ in 128 NSApp.keyWindow?.makeFirstResponder(nil) 129 } 130 } 131 132 @ViewBuilder 133 private var contentArea: some View { 134 if let tab = window.activeTab { 135 if let error = tab.navigationError { 136 NavigationErrorView(error: error) 137 .frame(maxWidth: .infinity, maxHeight: .infinity) 138 } else if tab.url != nil || tab.isLoading { 139 WebContentView(content: tab.content) 140 .id(tab.id) 141 } else { 142 NewTabView(hasBackground: window.newTabBackgroundColor != nil) 143 } 144 } 145 } 146} 147 148// MARK: - New Tab Page 149 150struct NewTabView: View { 151 let hasBackground: Bool 152 153 var body: some View { 154 Text("mere") 155 .font(.system(size: 52, weight: .ultraLight, design: .rounded)) 156 .foregroundStyle(.primary) 157 .tracking(10) 158 .frame(maxWidth: .infinity, maxHeight: .infinity) 159 } 160} 161 162// MARK: - Navigation Error 163 164struct NavigationErrorView: View { 165 let error: Error 166 167 private var errorMessage: String { 168 let nsError = error as NSError 169 switch (nsError.domain, nsError.code) { 170 case ("NSURLErrorDomain", -1004): 171 return "Can't connect to server" 172 case ("NSURLErrorDomain", -1001): 173 return "Connection timed out" 174 case ("NSURLErrorDomain", -1003): 175 return "Server not found" 176 default: 177 return nsError.localizedDescription 178 } 179 } 180 181 var body: some View { 182 VStack(spacing: 12) { 183 Image(systemName: "exclamationmark.triangle.fill") 184 .font(.system(size: 48)) 185 .foregroundStyle(.orange) 186 187 Text("Unable to load page") 188 .font(.system(size: 20, weight: .semibold)) 189 190 Text(errorMessage) 191 .font(.system(size: 14)) 192 .foregroundStyle(.secondary) 193 .multilineTextAlignment(.center) 194 } 195 .frame(maxWidth: 300) 196 .padding(20) 197 .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) 198 .shadow(radius: 20) 199 } 200} 201 202// MARK: - Sidebar 203 204struct SidebarView: View { 205 @ObservedObject var window: WindowViewModel 206 207 var body: some View { 208 GlassEffectContainer { 209 ScrollView(.vertical, showsIndicators: false) { 210 VStack(spacing: 1) { 211 ForEach(window.tabs) { tab in 212 SidebarTabRow( 213 tab: tab as MereCore.Tab, 214 isActive: window.activeTab?.id == tab.id, 215 onActivate: { window.activateTab(tab) }, 216 onClose: { window.closeTab(tab) } 217 ) 218 } 219 } 220 .padding(.top, 8) 221 .padding(.bottom, 6) 222 .padding(.horizontal, 8) 223 } 224 } 225 } 226} 227 228struct SidebarTabRow: View { 229 @ObservedObject var tab: MereCore.Tab 230 let isActive: Bool 231 let onActivate: () -> Void 232 let onClose: () -> Void 233 @State private var isHovered = false 234 235 var body: some View { 236 rowContent 237 .background( 238 RoundedRectangle(cornerRadius: 8) 239 .fill(Color(nsColor: .labelColor).opacity(isActive ? 0.08 : isHovered ? 0.04 : 0)) 240 ) 241 } 242 243 private var rowContent: some View { 244 HStack(spacing: 9) { 245 FaviconView(url: tab.favicon, engine: tab.engine) 246 .frame(width: 14, height: 14) 247 248 Text(tab.title ?? tab.url?.host ?? "New Tab") 249 .lineLimit(1) 250 .font(.system(size: 13)) 251 .foregroundStyle(isActive ? .primary : .secondary) 252 253 Spacer(minLength: 0) 254 255 if tab.hasAudioPlaying { 256 Button { 257 tab.content.isMuted.toggle() 258 } label: { 259 Image(systemName: tab.content.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") 260 .font(.system(size: 9, weight: .medium)) 261 .frame(width: 18, height: 18) 262 } 263 .buttonStyle(HoverButtonStyle()) 264 .transition(.opacity.animation(.easeInOut(duration: 0.15))) 265 } 266 267 // Always reserve space; only visible on hover or when active. 268 Button { onClose() } label: { 269 Image(systemName: "xmark") 270 .font(.system(size: 8, weight: .semibold)) 271 .frame(width: 14, height: 14) 272 } 273 .buttonStyle(HoverButtonStyle()) 274 .opacity(isHovered || isActive ? 1 : 0) 275 } 276 .padding(.horizontal, 10) 277 .padding(.vertical, 7) 278 .contentShape(RoundedRectangle(cornerRadius: 8)) 279 .onTapGesture { onActivate() } 280 .onHover { isHovered = $0 } 281 } 282} 283 284// MARK: - Favicon 285 286private final class FaviconCache { 287 static let shared = FaviconCache() 288 private let cache: NSCache<NSURL, NSImage> = { 289 let c = NSCache<NSURL, NSImage>() 290 c.countLimit = 200 291 return c 292 }() 293 294 func image(for url: URL) -> NSImage? { 295 cache.object(forKey: url as NSURL) 296 } 297 298 func store(_ image: NSImage, for url: URL) { 299 cache.setObject(image, forKey: url as NSURL) 300 } 301} 302 303struct FaviconView: View { 304 let url: URL? 305 let engine: EngineType 306 @State private var image: NSImage? = nil 307 308 var body: some View { 309 Group { 310 if let image { 311 Image(nsImage: image) 312 .resizable() 313 .scaledToFit() 314 .clipShape(RoundedRectangle(cornerRadius: 3)) 315 } else { 316 Circle() 317 .fill(engine == .webkit ? Color.blue.opacity(0.7) : Color.orange.opacity(0.7)) 318 .frame(width: 8, height: 8) 319 .frame(maxWidth: .infinity, maxHeight: .infinity) 320 } 321 } 322 .task(id: url) { 323 guard let url else { image = nil; return } 324 if let cached = FaviconCache.shared.image(for: url) { 325 image = cached 326 return 327 } 328 guard let (data, _) = try? await URLSession.shared.data(from: url), 329 let loaded = NSImage(data: data) else { return } 330 FaviconCache.shared.store(loaded, for: url) 331 image = loaded 332 } 333 } 334} 335 336// MARK: - Toolbar 337 338struct HoverButtonStyle: ButtonStyle { 339 var disabled: Bool = false 340 @State private var isHovered = false 341 342 func makeBody(configuration: Configuration) -> some View { 343 configuration.label 344 .foregroundStyle(disabled ? .tertiary : isHovered ? .primary : .secondary) 345 .background( 346 RoundedRectangle(cornerRadius: 6) 347 .fill(Color(nsColor: .labelColor) 348 .opacity(configuration.isPressed ? 0.12 : isHovered ? 0.07 : 0)) 349 ) 350 .animation(.easeInOut(duration: 0.12), value: isHovered) 351 .animation(.easeInOut(duration: 0.08), value: configuration.isPressed) 352 .onHover { isHovered = $0 } 353 } 354} 355 356struct BrowserToolbarView: View { 357 @ObservedObject var window: WindowViewModel 358 @Binding var sidebarVisible: Bool 359 let focusTrigger: Int 360 @State private var addressText = "" 361 362 private var securityState: SecurityState { 363 guard let url = window.activeTab?.url else { return .none } 364 if isLocalhost(url) { return .localhost } 365 return url.scheme == "https" ? .secure : .insecure 366 } 367 368 var body: some View { 369 HStack(spacing: 4) { 370 navIcon("sidebar.left") { 371 sidebarVisible.toggle() 372 } 373 374 navIcon("chevron.left", disabled: window.activeTab?.canGoBack != true) { 375 window.activeTab?.goBack() 376 } 377 navIcon("chevron.right", disabled: window.activeTab?.canGoForward != true) { 378 window.activeTab?.goForward() 379 } 380 381 HStack(spacing: 6) { 382 securityIcon 383 384 HStack(spacing: 6) { 385 AddressBar(text: $addressText, focusTrigger: focusTrigger, onSubmit: navigate) 386 .frame(maxWidth: .infinity, minHeight: 22) 387 388 if let url = window.activeTab?.url { 389 Button { 390 NSPasteboard.general.clearContents() 391 NSPasteboard.general.setString(url.absoluteString, forType: .string) 392 } label: { 393 Image(systemName: "link") 394 .font(.system(size: 10, weight: .medium)) 395 } 396 .buttonStyle(HoverButtonStyle()) 397 } 398 } 399 .padding(.horizontal, 10) 400 .padding(.vertical, 3) 401 .onChange(of: window.activeTab?.url) { _, url in 402 // Show normalized URL in address bar (0.0.0.0 -> 127.0.0.1) 403 var urlString = url?.absoluteString ?? "" 404 urlString = urlString.replacingOccurrences(of: "0.0.0.0", with: "127.0.0.1", options: .caseInsensitive) 405 addressText = urlString 406 } 407 .background( 408 RoundedRectangle(cornerRadius: 7) 409 .fill(.regularMaterial) 410 .overlay( 411 RoundedRectangle(cornerRadius: 7) 412 .strokeBorder(Color(nsColor: .separatorColor).opacity(0.3), lineWidth: 0.5) 413 ) 414 ) 415 } 416 417 navIcon("arrow.clockwise") { window.activeTab?.reload() } 418 419 if let tab = window.activeTab { 420 Text(tab.engine == .webkit ? "WK" : "CR") 421 .font(.system(size: 10, weight: .semibold, design: .monospaced)) 422 .foregroundStyle(.secondary) 423 .padding(.horizontal, 5) 424 .padding(.vertical, 2) 425 .background(Color(nsColor: .separatorColor).opacity(0.3), 426 in: RoundedRectangle(cornerRadius: 4)) 427 } 428 } 429 } 430 431 private func navIcon(_ name: String, disabled: Bool = false, action: @escaping () -> Void) -> some View { 432 Button(action: action) { 433 Image(systemName: name) 434 .font(.system(size: 13, weight: .medium)) 435 .frame(width: 26, height: 26) 436 } 437 .buttonStyle(HoverButtonStyle(disabled: disabled)) 438 .disabled(disabled) 439 } 440 441 @ViewBuilder 442 private var securityIcon: some View { 443 Button { 444 showSecurityInfo() 445 } label: { 446 let iconName: String = { 447 switch securityState { 448 case .secure, .localhost: 449 return "lock.fill" 450 case .insecure: 451 return "lock.open.fill" 452 case .none: 453 return "lock.fill" 454 } 455 }() 456 457 Image(systemName: iconName) 458 .font(.system(size: 11, weight: .medium)) 459 .foregroundStyle(.primary) 460 .frame(width: 20, height: 26) 461 } 462 .buttonStyle(HoverButtonStyle()) 463 } 464 465 private func showSecurityInfo() { 466 guard let url = window.activeTab?.url else { return } 467 let message: String 468 switch securityState { 469 case .secure: 470 message = "Connection is secure (HTTPS)\n\n\(url.absoluteString)" 471 case .insecure: 472 message = "Connection is not secure (HTTP)\n\n\(url.absoluteString)" 473 case .localhost: 474 message = "Localhost connection\n\n\(url.absoluteString)" 475 case .none: 476 message = "No connection" 477 } 478 let alert = NSAlert() 479 alert.messageText = "Security Information" 480 alert.informativeText = message 481 alert.alertStyle = .informational 482 alert.addButton(withTitle: "OK") 483 alert.runModal() 484 } 485 486 private func navigate() { 487 let raw = addressText.trimmingCharacters(in: .whitespaces) 488 guard !raw.isEmpty else { return } 489 490 let url: URL 491 492 func isURL(_ input: String) -> Bool { 493 // Already has a scheme 494 if input.hasPrefix("http://") || input.hasPrefix("https://") { 495 return true 496 } 497 498 // localhost (with optional port) 499 if input.lowercased().hasPrefix("localhost") { 500 return true 501 } 502 503 // IPv4 address pattern 504 let ipv4Pattern = #"^(\d{1,3}\.){3}\d{1,3}(:\d+)?$"# 505 if let regex = try? NSRegularExpression(pattern: ipv4Pattern), 506 regex.firstMatch(in: input, options: [], range: NSRange(input.startIndex..., in: input)) != nil { 507 return true 508 } 509 510 // Domain-like (contains dot, no spaces) 511 if input.contains(".") && !input.contains(" ") { 512 return true 513 } 514 515 return false 516 } 517 518 func normalizeURL(_ input: String) -> String { 519 // Replace 0.0.0.0 with 127.0.0.1 (0.0.0.0 is blocked by WebKit) 520 return input.replacingOccurrences(of: "0.0.0.0", with: "127.0.0.1", options: .caseInsensitive) 521 } 522 523 if isURL(raw) { 524 let normalized = normalizeURL(raw) 525 let urlString = normalized.hasPrefix("http") ? normalized : "http://\(normalized)" 526 if let u = URL(string: urlString) { 527 url = u 528 } else { 529 // Fallback to search if URL construction fails 530 url = URL(string: "https://s.dunkirk.sh?q=\(raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")! 531 } 532 } else { 533 url = URL(string: "https://s.dunkirk.sh?q=\(raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")! 534 } 535 536 if let tab = window.activeTab { 537 tab.loadURL(url) 538 } else { 539 window.openTab(url: url) 540 } 541 } 542} 543 544// MARK: - Address bar (NSViewRepresentable) 545// SwiftUI TextField + @FocusState is unreliable on macOS for makeFirstResponder. 546// NSTextField gives us direct control over focus and avoids the focus-ring highlight. 547 548struct AddressBar: NSViewRepresentable { 549 @Binding var text: String 550 var focusTrigger: Int 551 var onSubmit: () -> Void 552 553 func makeNSView(context: Context) -> NSTextField { 554 let f = NSTextField() 555 f.placeholderString = "Search or enter URL" 556 f.isBordered = false 557 f.drawsBackground = false 558 f.focusRingType = .none 559 f.font = .systemFont(ofSize: 13) 560 f.cell?.isScrollable = true 561 f.cell?.wraps = false 562 f.alignment = .left 563 f.delegate = context.coordinator 564 return f 565 } 566 567 func updateNSView(_ nsView: NSTextField, context: Context) { 568 context.coordinator.parent = self 569 if !context.coordinator.isEditing, context.coordinator.lastDisplayedURL != text { 570 context.coordinator.lastDisplayedURL = text 571 if let attr = prettyAttributed(text) { 572 nsView.attributedStringValue = attr 573 } else { 574 nsView.stringValue = text 575 } 576 } 577 if context.coordinator.lastTrigger != focusTrigger { 578 context.coordinator.lastTrigger = focusTrigger 579 context.coordinator.focusGeneration += 1 580 let gen = context.coordinator.focusGeneration 581 Task { @MainActor [weak coordinator = context.coordinator] in 582 guard coordinator?.focusGeneration == gen else { return } 583 nsView.window?.makeFirstResponder(nsView) 584 nsView.currentEditor()?.selectAll(nil) 585 } 586 } 587 } 588 589 func makeCoordinator() -> Coordinator { Coordinator(self) } 590 591 /// Returns an attributed string showing `host` in label color and `/path` in secondary label color. 592 func prettyAttributed(_ urlString: String) -> NSAttributedString? { 593 guard !urlString.isEmpty, 594 let url = URL(string: urlString), 595 let host = url.host else { return nil } 596 let font = NSFont.systemFont(ofSize: 13) 597 let para = NSMutableParagraphStyle() 598 para.alignment = .left 599 let hostAttr: [NSAttributedString.Key: Any] = [ 600 .font: font, 601 .foregroundColor: NSColor.labelColor, 602 .paragraphStyle: para, 603 ] 604 let pathAttr: [NSAttributedString.Key: Any] = [ 605 .font: font, 606 .foregroundColor: NSColor.secondaryLabelColor, 607 .paragraphStyle: para, 608 ] 609 let displayHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host 610 let result = NSMutableAttributedString(string: displayHost, attributes: hostAttr) 611 let path = url.path 612 let query = url.query.map { "?\($0)" } ?? "" 613 let suffix = (path == "/" || path.isEmpty ? "" : path) + query 614 if !suffix.isEmpty { 615 result.append(NSAttributedString(string: suffix, attributes: pathAttr)) 616 } 617 return result 618 } 619 620 final class Coordinator: NSObject, NSTextFieldDelegate { 621 var parent: AddressBar 622 var lastTrigger: Int 623 var focusGeneration = 0 624 var lastDisplayedURL: String = "" 625 var isEditing = false 626 627 init(_ parent: AddressBar) { 628 self.parent = parent 629 self.lastTrigger = parent.focusTrigger 630 } 631 632 func controlTextDidChange(_ obj: Notification) { 633 guard let field = obj.object as? NSTextField else { return } 634 parent.text = field.stringValue 635 } 636 637 func control(_ control: NSControl, textView: NSTextView, 638 doCommandBy selector: Selector) -> Bool { 639 if selector == #selector(NSResponder.insertNewline(_:)) { 640 focusGeneration += 1 // cancel any pending focus-and-select 641 parent.onSubmit() 642 Task { @MainActor in control.window?.makeFirstResponder(nil) } 643 return true 644 } 645 return false 646 } 647 648 func controlTextDidBeginEditing(_ obj: Notification) { 649 isEditing = true 650 if let tv = (obj.object as? NSTextField)?.currentEditor() as? NSTextView { 651 tv.insertionPointColor = .labelColor 652 } 653 } 654 655 func controlTextDidEndEditing(_ obj: Notification) { 656 isEditing = false 657 if let field = obj.object as? NSTextField { 658 lastDisplayedURL = "" // force re-render of pretty URL 659 if let attr = parent.prettyAttributed(parent.text) { 660 field.attributedStringValue = attr 661 } else { 662 field.stringValue = parent.text 663 } 664 } 665 } 666 } 667} 668 669// MARK: - Security 670 671enum SecurityState { 672 case none 673 case secure 674 case insecure 675 case localhost 676} 677 678private func isLocalhost(_ url: URL) -> Bool { 679 guard let host = url.host?.lowercased() else { return false } 680 return host == "localhost" || 681 host == "127.0.0.1" || 682 host == "::1" || 683 host.hasPrefix("127.") || 684 host == "[::1]" 685} 686 687// MARK: - Traffic light repositioning 688 689/// Zero-size view that moves the window's traffic-light buttons down by `yOffset` points 690/// so they vertically align with the toolbar icon row. 691private struct TrafficLightNudge: NSViewRepresentable { 692 let xOffset: CGFloat 693 let yOffset: CGFloat 694 695 func makeNSView(context: Context) -> _View { _View() } 696 697 func updateNSView(_ nsView: _View, context: Context) { 698 let x = xOffset, y = yOffset 699 // Defer until after AppKit's own layout pass resets button frames. 700 Task { @MainActor in nsView.apply(xOffset: x, yOffset: y) } 701 } 702 703 final class _View: NSView { 704 private var baseOrigins: [NSWindow.ButtonType: NSPoint] = [:] 705 private static let types: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] 706 707 required init?(coder: NSCoder) { fatalError() } 708 init() { super.init(frame: .zero) } 709 710 func apply(xOffset: CGFloat, yOffset: CGFloat) { 711 guard let window else { return } 712 // Lazily capture default origins the first time we have a window. 713 if baseOrigins.isEmpty { 714 for type in Self.types { 715 if let btn = window.standardWindowButton(type) { 716 baseOrigins[type] = btn.frame.origin 717 } 718 } 719 } 720 for type in Self.types { 721 guard let btn = window.standardWindowButton(type), 722 let base = baseOrigins[type] else { continue } 723 btn.setFrameOrigin(NSPoint(x: base.x + xOffset, y: base.y - yOffset)) 724 } 725 } 726 } 727}