Swift 98.3%
Shell 1.7%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// SiteView.swift — le SITE WEB d'une plateforme Ka, intégré dans l'app :3// WKWebView plein volet avec chrome minimal (retour/avancer, recharger,4// domaine courant, partager, ouvrir dans le navigateur) et barre de5// progression teintée à l'accent de l'univers. Les liens externes non6// web (tel:, mailto:…) partent vers le système.7import SwiftUI8import WebKit910// MARK: - Modèle observable du WKWebView (progression, historique, titre)1112@MainActor13final class SiteWebModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate {14 let webView: WKWebView15 @Published var progress: Double = 016 @Published var isLoading = false17 @Published var canGoBack = false18 @Published var canGoForward = false19 @Published var currentURL: URL?2021 private var observers: [NSKeyValueObservation] = []2223 init(url: URL) {24 let config = WKWebViewConfiguration()25 config.defaultWebpagePreferences.allowsContentJavaScript = true26 webView = WKWebView(frame: .zero, configuration: config)27 webView.allowsBackForwardNavigationGestures = true28 webView.allowsMagnification = true29 super.init()30 webView.navigationDelegate = self31 webView.uiDelegate = self32 observers = [33 webView.observe(\.estimatedProgress) { [weak self] wv, _ in34 Task { @MainActor in self?.progress = wv.estimatedProgress }35 },36 webView.observe(\.isLoading) { [weak self] wv, _ in37 Task { @MainActor in self?.isLoading = wv.isLoading }38 },39 webView.observe(\.canGoBack) { [weak self] wv, _ in40 Task { @MainActor in self?.canGoBack = wv.canGoBack }41 },42 webView.observe(\.canGoForward) { [weak self] wv, _ in43 Task { @MainActor in self?.canGoForward = wv.canGoForward }44 },45 webView.observe(\.url) { [weak self] wv, _ in46 Task { @MainActor in self?.currentURL = wv.url }47 },48 ]49 currentURL = url50 webView.load(URLRequest(url: url))51 }5253 // Les schémas non web (tel:, mailto:, facetime:…) partent vers le système.54 nonisolated func webView(_ webView: WKWebView,55 decidePolicyFor navigationAction: WKNavigationAction,56 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {57 guard let url = navigationAction.request.url else { return decisionHandler(.allow) }58 if let scheme = url.scheme?.lowercased(), scheme != "http", scheme != "https", scheme != "about" {59 NSWorkspace.shared.open(url)60 return decisionHandler(.cancel)61 }62 decisionHandler(.allow)63 }6465 // target="_blank" : on reste dans la même vue plutôt que d'ouvrir une fenêtre.66 nonisolated func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,67 for navigationAction: WKNavigationAction,68 windowFeatures: WKWindowFeatures) -> WKWebView? {69 if let url = navigationAction.request.url {70 Task { @MainActor in webView.load(URLRequest(url: url)) }71 }72 return nil73 }74}7576private struct SiteWebViewRepresentable: NSViewRepresentable {77 let model: SiteWebModel78 func makeNSView(context: Context) -> WKWebView { model.webView }79 func updateNSView(_ nsView: WKWebView, context: Context) {}80}8182// MARK: - Le navigateur intégré (chrome + progression + web)8384struct SiteView: View {85 let url: URL86 var title: String87 var accent: Color88 var logoID: String?89 @StateObject private var model: SiteWebModel90 @Environment(\.colorScheme) private var scheme9192 init(url: URL, title: String, accent: Color, logoID: String? = nil) {93 self.url = url94 self.title = title95 self.accent = accent96 self.logoID = logoID97 _model = StateObject(wrappedValue: SiteWebModel(url: url))98 }99100 init(universe: Universe) {101 self.init(url: universe.baseURL, title: universe.name,102 accent: universe.accent, logoID: universe.id)103 }104105 var body: some View {106 VStack(spacing: 0) {107 chrome108 ZStack(alignment: .top) {109 SiteWebViewRepresentable(model: model)110 if model.isLoading {111 GeometryReader { geo in112 Rectangle()113 .fill(accent)114 .frame(width: geo.size.width * max(model.progress, 0.06), height: 2.5)115 .animation(.easeOut(duration: 0.25), value: model.progress)116 }117 .frame(height: 2.5)118 .accessibilityHidden(true)119 }120 }121 }122 .background(KATheme.paper(scheme))123 }124125 private var chrome: some View {126 HStack(spacing: 10) {127 if let logoID {128 KALogo(id: logoID, size: 20, fallbackAccent: accent)129 }130 Button { model.webView.goBack() } label: {131 Image(systemName: "chevron.left")132 }133 .buttonStyle(.plain)134 .disabled(!model.canGoBack)135 .help("Page précédente")136 Button { model.webView.goForward() } label: {137 Image(systemName: "chevron.right")138 }139 .buttonStyle(.plain)140 .disabled(!model.canGoForward)141 .help("Page suivante")142 Button {143 if model.isLoading { model.webView.stopLoading() }144 else { model.webView.reload() }145 } label: {146 Image(systemName: model.isLoading ? "xmark" : "arrow.clockwise")147 }148 .buttonStyle(.plain)149 .help(model.isLoading ? "Arrêter" : "Recharger")150151 // Domaine courant, façon champ d'adresse (lecture seule)152 HStack(spacing: 6) {153 Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)154 Text(model.currentURL?.host() ?? url.host() ?? "")155 .font(.system(.caption, design: .monospaced).weight(.semibold))156 .lineLimit(1)157 .truncationMode(.middle)158 }159 .padding(.horizontal, 10).padding(.vertical, 5)160 .frame(maxWidth: .infinity)161 .background(KATheme.surface(scheme), in: Capsule())162 .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1))163 .accessibilityLabel("Adresse : \(model.currentURL?.absoluteString ?? url.absoluteString)")164165 if let current = model.currentURL {166 ShareLink(item: current, subject: Text(title)) {167 Image(systemName: "square.and.arrow.up")168 }169 .buttonStyle(.plain)170 .help("Partager la page")171 }172 Button {173 NSWorkspace.shared.open(model.currentURL ?? url)174 } label: {175 Image(systemName: "safari")176 }177 .buttonStyle(.plain)178 .help("Ouvrir dans le navigateur")179 }180 .font(.body)181 .padding(.horizontal, 12).padding(.vertical, 8)182 .background(KATheme.paper(scheme))183 }184}185