// // BrowserModel.swift // Prisme // // Author: Simon-Pierre Boucher // import Foundation import Observation /// Root model wiring together tabs, identity containers, the web view pool /// and content blocking. Owned by the app entry point, passed down to views. @MainActor @Observable final class BrowserModel { let containers: IdentityContainerStore let contentRules: ContentRuleManager let pool: WebViewPool let tabs: TabStore let intelligence = IntelligenceCenter() let history = HistoryStore() let library = LibraryStore() /// Transient confirmation shown after a save ("Extrait sauvé"). private(set) var notice: String? @ObservationIgnored private var noticeTask: Task? /// Container used for newly opened tabs. var activeContainerID: IdentityContainer.ID var activeContainer: IdentityContainer { containers.container(for: activeContainerID) ?? containers.all[0] } init() { let containers = IdentityContainerStore() let contentRules = ContentRuleManager() self.containers = containers self.contentRules = contentRules self.pool = WebViewPool(containers: containers, rules: contentRules) self.tabs = TabStore() self.activeContainerID = containers.all[0].id } /// One-time async startup work. Content rules compile off the critical /// path: pages render immediately, blocking kicks in as soon as ready. func start() async { await contentRules.compileIfNeeded() if let list = contentRules.ruleList { pool.apply(list) } } /// Resolve a confirmed address-bar intent: load in the active tab if it /// belongs to the active container, otherwise open a new tab there. func submit(_ intent: EntryIntent) { if let tab = tabs.activeTab, tab.containerID == activeContainerID { tab.page.load(intent.destination) } else { openTab(intent.destination, in: activeContainerID) } } func newTab() { openTab(nil, in: activeContainerID) } /// Reopen a remembered page — in the container it was visited in, so /// identities stay partitioned. func open(_ visit: PageVisit) { guard let url = URL(string: visit.urlString) else { return } let containerID = containers.container(for: visit.containerID)?.id ?? activeContainerID activeContainerID = containerID openTab(url, in: containerID) } @discardableResult private func openTab(_ url: URL?, in containerID: IdentityContainer.ID) -> Tab { let tab = tabs.open(url, in: containerID) wireHistory(for: tab) return tab } // MARK: - Library (excerpts & structured favourites) private func saveExcerpt(from tab: Tab) { let page = tab.page let containerID = tab.containerID Task { [weak self] in guard let self, let selection = try? await page.captureSelection() else { return } self.library.saveExcerpt( selection, url: page.url, pageTitle: page.title, containerID: containerID ) self.show(notice: "Extrait sauvé dans la bibliothèque") } } /// Saves the current page as native data: the digest's structure when /// the model produced one, a deterministic distillation otherwise. func saveFavorite(for tab: Tab) { guard let url = tab.page.url else { return } let page = tab.page let containerID = tab.containerID var kindRaw: String? var gist = "" var outline: [String] = [] var isGenerated = false if case .ready(let digest, _) = intelligence.digestState(for: tab) { kindRaw = digest.kind.rawValue gist = digest.gist outline = digest.outline.map(\.title) isGenerated = true } Task { [weak self] in guard let self else { return } let content = try? await page.extractContent() var title = content?.title ?? "" if title.isEmpty { title = page.title } if title.isEmpty { title = url.host() ?? url.absoluteString } if gist.isEmpty { gist = content?.leadSentence ?? "" } if outline.isEmpty { outline = content?.blocks.filter { $0.kind == .heading }.map(\.text) ?? [] } self.library.saveFavorite( url: url, title: title, gist: gist, kindRaw: kindRaw, outlineTitles: outline, isGenerated: isGenerated, containerID: containerID ) self.show(notice: "Page sauvée en données natives") } } private func show(notice text: String) { notice = text noticeTask?.cancel() noticeTask = Task { [weak self] in try? await Task.sleep(for: .seconds(2.2)) guard !Task.isCancelled else { return } self?.notice = nil } } // MARK: - Semantic history recording /// Every finished load in a non-sensitive container is distilled and /// indexed locally. Sensitive containers are never recorded at all. private func wireHistory(for tab: Tab) { tab.page.onDidFinishLoad = { [weak self, weak tab] url in guard let self, let tab else { return } self.recordVisit(of: tab, url: url) } tab.page.onExcerptRequested = { [weak self, weak tab] in guard let self, let tab else { return } self.saveExcerpt(from: tab) } } private func recordVisit(of tab: Tab, url: URL) { let page = tab.page let containerID = tab.containerID let sensitive = containers.container(for: containerID)?.isSensitive ?? true Task { [weak self, weak tab] in // Let the page settle; skip if the user already moved on. try? await Task.sleep(for: .milliseconds(700)) guard let self, let tab, page.url == url, !page.isLoading else { return } guard let content = try? await page.extractContent() else { return } // Understanding strip: deterministic, instant, purely local. let approxWords = content.blocks.reduce(0) { $0 + $1.text.count } / 6 tab.insight = PageInsight( url: url, approxWords: approxWords, sectionCount: content.blocks.count(where: { $0.kind == .heading }) ) tab.insightDismissed = false // Background enrichment, local tier only, page priority (§8). self.intelligence.requestDigest(for: tab, priority: .activePage) // Sensitive containers are never written to history. if !sensitive { self.history.record(url: url, content: content, containerID: containerID) } } } func activate(_ tab: Tab) { tabs.activate(tab) activeContainerID = tab.containerID } /// Switching universe: activate the most recent tab of that container, /// or show the start page if it has none. func selectContainer(_ id: IdentityContainer.ID) { activeContainerID = id if let tab = tabs.mostRecentTab(in: id) { tabs.activate(tab) } else { tabs.activeTabID = nil } } }