spb/prisme Public MIT
Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.
Swift 96.7%
JavaScript 3.3%
1//2// BrowserModel.swift3// Prisme4//5// Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import Foundation9import Observation1011/// Root model wiring together tabs, identity containers, the web view pool12/// and content blocking. Owned by the app entry point, passed down to views.13@MainActor14@Observable15final class BrowserModel {16 let containers: IdentityContainerStore17 let contentRules: ContentRuleManager18 let pool: WebViewPool19 let tabs: TabStore20 let intelligence = IntelligenceCenter()21 let history = HistoryStore()22 let library = LibraryStore()2324 /// Transient confirmation shown after a save ("Extrait sauvé").25 private(set) var notice: String?26 @ObservationIgnored private var noticeTask: Task<Void, Never>?2728 /// Container used for newly opened tabs.29 var activeContainerID: IdentityContainer.ID3031 var activeContainer: IdentityContainer {32 containers.container(for: activeContainerID) ?? containers.all[0]33 }3435 init() {36 let containers = IdentityContainerStore()37 let contentRules = ContentRuleManager()38 self.containers = containers39 self.contentRules = contentRules40 self.pool = WebViewPool(containers: containers, rules: contentRules)41 self.tabs = TabStore()42 self.activeContainerID = containers.all[0].id43 }4445 /// One-time async startup work. Content rules compile off the critical46 /// path: pages render immediately, blocking kicks in as soon as ready.47 func start() async {48 await contentRules.compileIfNeeded()49 if let list = contentRules.ruleList {50 pool.apply(list)51 }52 }5354 /// Resolve a confirmed address-bar intent: load in the active tab if it55 /// belongs to the active container, otherwise open a new tab there.56 func submit(_ intent: EntryIntent) {57 if let tab = tabs.activeTab, tab.containerID == activeContainerID {58 tab.page.load(intent.destination)59 } else {60 openTab(intent.destination, in: activeContainerID)61 }62 }6364 func newTab() {65 openTab(nil, in: activeContainerID)66 }6768 /// Reopen a remembered page — in the container it was visited in, so69 /// identities stay partitioned.70 func open(_ visit: PageVisit) {71 guard let url = URL(string: visit.urlString) else { return }72 let containerID = containers.container(for: visit.containerID)?.id ?? activeContainerID73 activeContainerID = containerID74 openTab(url, in: containerID)75 }7677 @discardableResult78 private func openTab(_ url: URL?, in containerID: IdentityContainer.ID) -> Tab {79 let tab = tabs.open(url, in: containerID)80 wireHistory(for: tab)81 return tab82 }8384 // MARK: - Library (excerpts & structured favourites)8586 private func saveExcerpt(from tab: Tab) {87 let page = tab.page88 let containerID = tab.containerID89 Task { [weak self] in90 guard let self,91 let selection = try? await page.captureSelection() else { return }92 self.library.saveExcerpt(93 selection,94 url: page.url,95 pageTitle: page.title,96 containerID: containerID97 )98 self.show(notice: "Extrait sauvé dans la bibliothèque")99 }100 }101102 /// Saves the current page as native data: the digest's structure when103 /// the model produced one, a deterministic distillation otherwise.104 func saveFavorite(for tab: Tab) {105 guard let url = tab.page.url else { return }106 let page = tab.page107 let containerID = tab.containerID108109 var kindRaw: String?110 var gist = ""111 var outline: [String] = []112 var isGenerated = false113 if case .ready(let digest, _) = intelligence.digestState(for: tab) {114 kindRaw = digest.kind.rawValue115 gist = digest.gist116 outline = digest.outline.map(\.title)117 isGenerated = true118 }119120 Task { [weak self] in121 guard let self else { return }122 let content = try? await page.extractContent()123 var title = content?.title ?? ""124 if title.isEmpty { title = page.title }125 if title.isEmpty { title = url.host() ?? url.absoluteString }126 if gist.isEmpty { gist = content?.leadSentence ?? "" }127 if outline.isEmpty {128 outline = content?.blocks.filter { $0.kind == .heading }.map(\.text) ?? []129 }130 self.library.saveFavorite(131 url: url,132 title: title,133 gist: gist,134 kindRaw: kindRaw,135 outlineTitles: outline,136 isGenerated: isGenerated,137 containerID: containerID138 )139 self.show(notice: "Page sauvée en données natives")140 }141 }142143 private func show(notice text: String) {144 notice = text145 noticeTask?.cancel()146 noticeTask = Task { [weak self] in147 try? await Task.sleep(for: .seconds(2.2))148 guard !Task.isCancelled else { return }149 self?.notice = nil150 }151 }152153 // MARK: - Semantic history recording154155 /// Every finished load in a non-sensitive container is distilled and156 /// indexed locally. Sensitive containers are never recorded at all.157 private func wireHistory(for tab: Tab) {158 tab.page.onDidFinishLoad = { [weak self, weak tab] url in159 guard let self, let tab else { return }160 self.recordVisit(of: tab, url: url)161 }162 tab.page.onExcerptRequested = { [weak self, weak tab] in163 guard let self, let tab else { return }164 self.saveExcerpt(from: tab)165 }166 }167168 private func recordVisit(of tab: Tab, url: URL) {169 let page = tab.page170 let containerID = tab.containerID171 let sensitive = containers.container(for: containerID)?.isSensitive ?? true172 Task { [weak self, weak tab] in173 // Let the page settle; skip if the user already moved on.174 try? await Task.sleep(for: .milliseconds(700))175 guard let self, let tab, page.url == url, !page.isLoading else { return }176 guard let content = try? await page.extractContent() else { return }177178 // Understanding strip: deterministic, instant, purely local.179 let approxWords = content.blocks.reduce(0) { $0 + $1.text.count } / 6180 tab.insight = PageInsight(181 url: url,182 approxWords: approxWords,183 sectionCount: content.blocks.count(where: { $0.kind == .heading })184 )185 tab.insightDismissed = false186187 // Background enrichment, local tier only, page priority (§8).188 self.intelligence.requestDigest(for: tab, priority: .activePage)189190 // Sensitive containers are never written to history.191 if !sensitive {192 self.history.record(url: url, content: content, containerID: containerID)193 }194 }195 }196197 func activate(_ tab: Tab) {198 tabs.activate(tab)199 activeContainerID = tab.containerID200 }201202 /// Switching universe: activate the most recent tab of that container,203 /// or show the start page if it has none.204 func selectContainer(_ id: IdentityContainer.ID) {205 activeContainerID = id206 if let tab = tabs.mostRecentTab(in: id) {207 tabs.activate(tab)208 } else {209 tabs.activeTabID = nil210 }211 }212}213