// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // SiteView.swift — le SITE WEB d'une plateforme Ka, intégré dans l'app : // WKWebView plein volet avec chrome minimal (retour/avancer, recharger, // domaine courant, partager, ouvrir dans le navigateur) et barre de // progression teintée à l'accent de l'univers. Les liens externes non // web (tel:, mailto:…) partent vers le système. import SwiftUI import WebKit // MARK: - Modèle observable du WKWebView (progression, historique, titre) @MainActor final class SiteWebModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate { let webView: WKWebView @Published var progress: Double = 0 @Published var isLoading = false @Published var canGoBack = false @Published var canGoForward = false @Published var currentURL: URL? private var observers: [NSKeyValueObservation] = [] init(url: URL) { let config = WKWebViewConfiguration() config.defaultWebpagePreferences.allowsContentJavaScript = true webView = WKWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true webView.allowsMagnification = true super.init() webView.navigationDelegate = self webView.uiDelegate = self observers = [ webView.observe(\.estimatedProgress) { [weak self] wv, _ in Task { @MainActor in self?.progress = wv.estimatedProgress } }, webView.observe(\.isLoading) { [weak self] wv, _ in Task { @MainActor in self?.isLoading = wv.isLoading } }, webView.observe(\.canGoBack) { [weak self] wv, _ in Task { @MainActor in self?.canGoBack = wv.canGoBack } }, webView.observe(\.canGoForward) { [weak self] wv, _ in Task { @MainActor in self?.canGoForward = wv.canGoForward } }, webView.observe(\.url) { [weak self] wv, _ in Task { @MainActor in self?.currentURL = wv.url } }, ] currentURL = url webView.load(URLRequest(url: url)) } // Les schémas non web (tel:, mailto:, facetime:…) partent vers le système. nonisolated func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url else { return decisionHandler(.allow) } if let scheme = url.scheme?.lowercased(), scheme != "http", scheme != "https", scheme != "about" { NSWorkspace.shared.open(url) return decisionHandler(.cancel) } decisionHandler(.allow) } // target="_blank" : on reste dans la même vue plutôt que d'ouvrir une fenêtre. nonisolated func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let url = navigationAction.request.url { Task { @MainActor in webView.load(URLRequest(url: url)) } } return nil } } private struct SiteWebViewRepresentable: NSViewRepresentable { let model: SiteWebModel func makeNSView(context: Context) -> WKWebView { model.webView } func updateNSView(_ nsView: WKWebView, context: Context) {} } // MARK: - Le navigateur intégré (chrome + progression + web) struct SiteView: View { let url: URL var title: String var accent: Color var logoID: String? @StateObject private var model: SiteWebModel @Environment(\.colorScheme) private var scheme init(url: URL, title: String, accent: Color, logoID: String? = nil) { self.url = url self.title = title self.accent = accent self.logoID = logoID _model = StateObject(wrappedValue: SiteWebModel(url: url)) } init(universe: Universe) { self.init(url: universe.baseURL, title: universe.name, accent: universe.accent, logoID: universe.id) } var body: some View { VStack(spacing: 0) { chrome ZStack(alignment: .top) { SiteWebViewRepresentable(model: model) if model.isLoading { GeometryReader { geo in Rectangle() .fill(accent) .frame(width: geo.size.width * max(model.progress, 0.06), height: 2.5) .animation(.easeOut(duration: 0.25), value: model.progress) } .frame(height: 2.5) .accessibilityHidden(true) } } } .background(KATheme.paper(scheme)) } private var chrome: some View { HStack(spacing: 10) { if let logoID { KALogo(id: logoID, size: 20, fallbackAccent: accent) } Button { model.webView.goBack() } label: { Image(systemName: "chevron.left") } .buttonStyle(.plain) .disabled(!model.canGoBack) .help("Page précédente") Button { model.webView.goForward() } label: { Image(systemName: "chevron.right") } .buttonStyle(.plain) .disabled(!model.canGoForward) .help("Page suivante") Button { if model.isLoading { model.webView.stopLoading() } else { model.webView.reload() } } label: { Image(systemName: model.isLoading ? "xmark" : "arrow.clockwise") } .buttonStyle(.plain) .help(model.isLoading ? "Arrêter" : "Recharger") // Domaine courant, façon champ d'adresse (lecture seule) HStack(spacing: 6) { Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) Text(model.currentURL?.host() ?? url.host() ?? "") .font(.system(.caption, design: .monospaced).weight(.semibold)) .lineLimit(1) .truncationMode(.middle) } .padding(.horizontal, 10).padding(.vertical, 5) .frame(maxWidth: .infinity) .background(KATheme.surface(scheme), in: Capsule()) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1)) .accessibilityLabel("Adresse : \(model.currentURL?.absoluteString ?? url.absoluteString)") if let current = model.currentURL { ShareLink(item: current, subject: Text(title)) { Image(systemName: "square.and.arrow.up") } .buttonStyle(.plain) .help("Partager la page") } Button { NSWorkspace.shared.open(model.currentURL ?? url) } label: { Image(systemName: "safari") } .buttonStyle(.plain) .help("Ouvrir dans le navigateur") } .font(.body) .padding(.horizontal, 12).padding(.vertical, 8) .background(KATheme.paper(scheme)) } }