fancy new browser
1import Foundation
2import MereKit
3import Combine
4
5/// Drives a single browser window: tab list, active tab, engine routing.
6/// Mirrors ADK2.BrowserApplicationController / ADK2.BrowserController.
7@MainActor
8public final class WindowViewModel: ObservableObject {
9
10 @Published public private(set) var tabs: [Tab] = []
11 @Published public var activeTab: Tab?
12 @Published public private(set) var newTabBackgroundColor: PlatformColor?
13
14 /// Sidebar visibility — owned here so keyboard shortcuts in commands can toggle it.
15 @Published public var sidebarVisible = true
16 /// Incrementing this triggers the address bar to take focus and select-all.
17 @Published public var addressFocusTrigger = 0
18
19 private let webkitContext: any BrowserContext
20 private let chromiumContext: (any BrowserContext)?
21 private let cookieSync: CookieSyncController
22 public let adBlock: AdBlockController
23 private var activeTabObservation: AnyCancellable?
24
25 private let tabsFileURL: URL
26
27 public init(
28 webkitContext: any BrowserContext,
29 chromiumContext: (any BrowserContext)? = nil,
30 adBlockEngines: [any AdBlockEngine] = []
31 ) {
32 self.webkitContext = webkitContext
33 self.chromiumContext = chromiumContext
34 self.cookieSync = CookieSyncController(
35 webkit: webkitContext,
36 chromium: chromiumContext
37 )
38 self.adBlock = AdBlockController(engines: adBlockEngines)
39
40 let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
41 let appDir = appSupport.appendingPathComponent("Mere", isDirectory: true)
42 try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true)
43 self.tabsFileURL = appDir.appendingPathComponent("saved_tabs.json")
44 }
45
46 // MARK: - Tab management
47
48 @discardableResult
49 public func openTab(url: URL? = nil, engine: EngineType? = nil) -> Tab {
50 // Carry the previous tab's theme color into the new tab page background.
51 if url == nil, let current = activeTab, current.url != nil {
52 newTabBackgroundColor = current.themeColor
53 }
54 activeTab?.deactivate()
55 let resolvedEngine = engine ?? url.map(EngineType.preferred) ?? .webkit
56 let context = context(for: resolvedEngine)
57 let content = context.makeWebContent()
58 let tab = Tab(content: content)
59 tabs.append(tab)
60 activeTab = tab
61 tab.activate()
62 subscribeToActiveTab()
63 if let url { tab.loadURL(url) }
64 return tab
65 }
66
67 public func closeTab(_ tab: Tab) {
68 guard tabs.count > 1 else {
69 tab.resetToNewTab()
70 return
71 }
72 tab.deactivate()
73 tab.content.close()
74 tabs.removeAll { $0.id == tab.id }
75 if activeTab?.id == tab.id {
76 activeTab = tabs.last
77 activeTab?.activate()
78 subscribeToActiveTab()
79 }
80 }
81
82 public func activateTab(_ tab: Tab) {
83 activeTab?.deactivate()
84 activeTab = tab
85 tab.activate()
86 subscribeToActiveTab()
87 }
88
89 /// Reopen a tab in the other engine, syncing cookies first.
90 public func switchEngine(for tab: Tab) async {
91 guard let url = tab.url else { return }
92 let newEngine: EngineType = tab.engine == .webkit ? .chromium : .webkit
93 guard newEngine == .webkit || chromiumContext != nil else { return }
94
95 await cookieSync.sync(from: tab.engine, url: url)
96
97 let idx = tabs.firstIndex(where: { $0.id == tab.id })
98 closeTab(tab)
99
100 let newTab = openTab(url: url, engine: newEngine)
101 if let idx {
102 tabs.move(fromOffsets: IndexSet(integer: tabs.count - 1), toOffset: idx)
103 }
104 activeTab = newTab
105 newTab.activate()
106 subscribeToActiveTab()
107 }
108
109 // MARK: - Helpers
110
111 private func subscribeToActiveTab() {
112 activeTabObservation = activeTab?.objectWillChange
113 .receive(on: RunLoop.main)
114 .sink { [weak self] _ in self?.objectWillChange.send() }
115 }
116
117 private func context(for engine: EngineType) -> any BrowserContext {
118 switch engine {
119 case .webkit: return webkitContext
120 case .chromium: return chromiumContext ?? webkitContext
121 }
122 }
123}