WKWebView-Huelle fuer Hybrid-Apps (Web-Lese-Surfaces + native Submit/Widget/ShareExt). Extrahiert aus den fast-byte-identischen `WebShell/`-Ordnern in seepuls-native und zitare-native (~900 LOC, davon ~700 LOC Duplikat). Audit 2026-05-17 V2. Neu (public API): - `WebShellView` — WKWebView-Wrapper mit Progress-Bar, Pull-to- Refresh (iOS), Fehler-Snackbar, External-Link-Delegation. Universal (iOS + macOS) - `WebShellConfig` — Host-Whitelist mit Wildcard-Support (`"*.mana.how"`), User-Agent, Theme-Hints, User-Scripts - `WebTarget` — URL + monoton wachsender reloadToken - `WebNavState` — @Observable, @MainActor, reaktiver Nav-State - `WebShellCoordinator` — WKNavigationDelegate + WKUIDelegate - `WebShellScripts` — Helfer fuer `preferDarkScheme`, `syncDarkMode(localStorageKey:)`, `hideElements(selectors:tagName:)` Logging unter Subsystem `ev.mana.webshell` (App-OSLog bleibt eigen). Tests: 6 neue Tests gegen `WebShellConfig.isAllowed` (Wildcards, Negativ-Cases). 50/50 grün insgesamt (6 ManaWebShell + 44 ManaAuthUI). Doku: `mana/docs/playbooks/HYBRID_NATIVE_APP.md` (Schwester-Repo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
4.6 KiB
Swift
119 lines
4.6 KiB
Swift
import Foundation
|
|
import WebKit
|
|
|
|
/// Vor-gefertigte `WKUserScript`-Helfer für ``WebShellView``. Apps
|
|
/// pickern, was sie brauchen, und reichen das Ergebnis als
|
|
/// `config.userScripts` durch.
|
|
///
|
|
/// `WKUserScript` ist MainActor-isolated; deshalb sind die Factory-
|
|
/// Methoden hier ebenfalls MainActor. Aufrufer leben sowieso auf Main
|
|
/// (SwiftUI `makeUIView`/`makeNSView` sind MainActor).
|
|
@MainActor
|
|
public enum WebShellScripts {
|
|
/// Erzwingt Dark-Color-Scheme im WebView, indem ein `<meta
|
|
/// name="color-scheme" content="dark">` injiziert und `.dark` an
|
|
/// `<html>` gehängt wird. Sinnvoll für Web-Apps, die nur Dark-
|
|
/// Styles haben (Seepuls) oder bei denen die App das Light/Dark
|
|
/// hart festlegt.
|
|
public static let preferDarkScheme: WKUserScript = .init(
|
|
source: """
|
|
(function() {
|
|
var meta = document.querySelector('meta[name="color-scheme"]');
|
|
if (!meta) {
|
|
meta = document.createElement('meta');
|
|
meta.setAttribute('name', 'color-scheme');
|
|
(document.head || document.documentElement).appendChild(meta);
|
|
}
|
|
meta.setAttribute('content', 'dark');
|
|
var html = document.documentElement;
|
|
if (html) html.classList.add('dark');
|
|
})();
|
|
""",
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: true
|
|
)
|
|
|
|
/// Synct den System-Dark-Mode in den WebView via
|
|
/// `matchMedia('(prefers-color-scheme: dark)')`. Setzt eine
|
|
/// `.dark`-Klasse auf `<html>` und optional einen `localStorage`-
|
|
/// Key, an dem das Web-Theme hängt. Listener für Live-Switch
|
|
/// während die Page offen ist.
|
|
///
|
|
/// - Parameter localStorageKey: Key, an dem das Web seinen Theme-
|
|
/// State liest. `nil` falls Web nur auf `<html>.dark` reagiert.
|
|
public static func syncDarkMode(localStorageKey: String? = nil) -> WKUserScript {
|
|
let setStorage: String
|
|
if let key = localStorageKey {
|
|
let escaped = key.replacingOccurrences(of: "'", with: "\\'")
|
|
setStorage = """
|
|
try {
|
|
if (isDark) localStorage.setItem('\(escaped)', 'dark');
|
|
else localStorage.removeItem('\(escaped)');
|
|
} catch (e) {}
|
|
"""
|
|
} else {
|
|
setStorage = ""
|
|
}
|
|
let source = """
|
|
(function() {
|
|
function apply(isDark) {
|
|
\(setStorage)
|
|
var html = document.documentElement;
|
|
if (!html) return;
|
|
if (isDark) html.classList.add('dark');
|
|
else html.classList.remove('dark');
|
|
}
|
|
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
apply(mq.matches);
|
|
if (mq.addEventListener) {
|
|
mq.addEventListener('change', function(e) { apply(e.matches); });
|
|
}
|
|
})();
|
|
"""
|
|
return WKUserScript(
|
|
source: source,
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: true
|
|
)
|
|
}
|
|
|
|
/// Versteckt eine Top-Nav-Komponente per CSS, damit eine native
|
|
/// TabBar nicht doppelt rendert. Mehrere Selektoren werden
|
|
/// gestapelt (mit `,`-Group), damit ein Markup-Refactor in
|
|
/// Web-Land das Hide nicht still bricht.
|
|
///
|
|
/// Konvention für Selektor-Kaskaden:
|
|
/// 1. `nav[data-app-nav]` / `header[data-app-nav]` — explizites
|
|
/// Attribut, falls Web es markieren will (greift sofort)
|
|
/// 2. strukturell (`body header:has(a.brand)` o.ä.) — heutige
|
|
/// Realität
|
|
/// 3. positionell (`body > nav:first-of-type`) — Fallback
|
|
///
|
|
/// - Parameter selectors: CSS-Selektoren, die `display: none
|
|
/// !important` bekommen. Werden mit `,` gejoint.
|
|
/// - Parameter tagName: Wert für das `data-mana-webshell`-
|
|
/// Attribut auf dem Style-Tag (debugging, source inspection).
|
|
public static func hideElements(
|
|
selectors: [String],
|
|
tagName: String = "hide"
|
|
) -> WKUserScript {
|
|
let joined = selectors.joined(separator: ",\n")
|
|
let escapedTag = tagName.replacingOccurrences(of: "'", with: "\\'")
|
|
let source = """
|
|
(function() {
|
|
var css = `\(joined) {
|
|
display: none !important;
|
|
}`;
|
|
var style = document.createElement('style');
|
|
style.setAttribute('data-mana-webshell', '\(escapedTag)');
|
|
style.textContent = css;
|
|
(document.head || document.documentElement).appendChild(style);
|
|
})();
|
|
"""
|
|
return WKUserScript(
|
|
source: source,
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: true
|
|
)
|
|
}
|
|
}
|