SPB Git

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%
7.4 KB · 213 lines swift
Raw Blame History
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