| @MainActor
final class SpectraService: NSObject {
static let shared = SpectraService()
private override init() { super.init() }
private var webView: WKWebView?
private var continuation: CheckedContinuation<URL?, Never>?
private var showName: String = ""
// MARK: - Public
func findShowURL(named showName: String) async -> URL? {
self.showName = showName
webView?.navigationDelegate = nil
webView?.stopLoading()
webView?.removeFromSuperview()
webView = nil
print("[Spectra] Starting search for: \(showName)")
return await withCheckedContinuation { cont in
self.continuation = cont
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 375, height: 812),
configuration: config)
wv.navigationDelegate = self
wv.customUserAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
"AppleWebKit/605.1.15 (KHTML, like Gecko) " +
"Version/17.0 Mobile/15E148 Safari/604.1"
wv.isUserInteractionEnabled = false // prevent keyboard from ever appearing
self.webView = wv
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.windows.first {
wv.isHidden = true
window.addSubview(wv)
}
let url = URL(string: "https://spectra.theater/search")!
wv.load(URLRequest(url: url))
}
}
// MARK: - Private
private func typeAndExtract(in wv: WKWebView) {
let safe = showName
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
// React controlled inputs ignore plain value assignments.
// We must use the native HTMLInputElement prototype value setter
// so React sees the change, then fire the 'input' event.
let fillJS = """
(function() {
var input =
document.querySelector('[placeholder*="Search for any"]') ||
document.querySelector('[placeholder*="search" i]') ||
document.querySelector('input[type="search"]') ||
document.querySelector('input[type="text"]');
if (!input) return 'ERROR: no search input found';
// React-compatible value injection
var nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value').set;
nativeSetter.call(input, '\(safe)');
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' }));
// Do NOT call input.focus() — that causes the system keyboard to appear.
return 'typed: ' + input.value;
})()
"""
wv.evaluateJavaScript(fillJS) { result, error in
if let err = error {
print("[Spectra] Fill error: \(err.localizedDescription)")
} else {
print("[Spectra] Fill: \(result as? String ?? "nil")")
}
}
// Wait for live results to render, then extract the first show link
Task { @MainActor in
try? await Task.sleep(nanoseconds: 5_000_000_000)
self.extractResult(from: wv)
}
}
private func extractResult(from wv: WKWebView) {
// Capture the webView's current URL before running JS — after React processes
// the search it may have updated the address bar to include the query string,
// giving us a useful fallback URL even if no direct show page is found.
let fallbackURL = wv.url
let js = """
(function() {
// spectra.theater show pages live at /explore/production/<UUID>
var patterns = ['/explore/production/', '/production/', '/show/', '/shows/'];
var anchors = Array.from(document.querySelectorAll('a[href]'));
for (var p of patterns) {
var match = anchors.find(function(a) { return a.href.indexOf(p) !== -1; });
if (match) return match.href;
}
return null;
})()
"""
wv.evaluateJavaScript(js) { [weak self] result, error in
guard let self else { return }
if let err = error {
print("[Spectra] JS error: \(err.localizedDescription)")
self.finish(with: fallbackURL); return
}
if let raw = result as? String, !raw.isEmpty {
// Direct show page found — best case.
print("[Spectra] Found show page: \(raw)")
self.finish(with: URL(string: raw))
} else {
// No direct link found; fall back to the search-results page URL so
// the user gets a list they can browse (React updates the URL bar).
print("[Spectra] No direct link — using search page: \(fallbackURL?.absoluteString ?? "nil")")
self.finish(with: fallbackURL)
}
}
}
private func finish(with url: URL?) {
webView?.removeFromSuperview()
webView?.navigationDelegate = nil
webView = nil
continuation?.resume(returning: url)
continuation = nil
}
} | Code block | See source code for full implementation. |
| extension SpectraService: WKNavigationDelegate {
nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
Task { @MainActor in
let url = webView.url?.absoluteString ?? "?"
print("[Spectra] didFinish: \(url)")
// Give the Next.js app a moment to hydrate before we interact
try? await Task.sleep(nanoseconds: 1_500_000_000)
self.typeAndExtract(in: webView)
}
}
nonisolated func webView(_ webView: WKWebView,
didFail navigation: WKNavigation!,
withError error: Error) {
Task { @MainActor in
print("[Spectra] Navigation failed: \(error.localizedDescription)")
self.finish(with: nil)
}
}
nonisolated func webView(_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error) {
Task { @MainActor in
print("[Spectra] Provisional navigation failed: \(error.localizedDescription)")
self.finish(with: nil)
}
}
} | `SpectraService` extension | Defines the `SpectraService` extension. Conforms to WKNavigationDelegate. |