fancy new browser
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}