mana-swift-ui/Sources/ManaWebShell/WebShellScripts.swift
Till JS 8f4d4b0c03 feat(webshell): neues Library-Product ManaWebShell (v0.6.0)
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>
2026-05-17 21:11:47 +02:00

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
)
}
}