Native App Integration (iOS / macOS)

The Catch UX SDK is a JavaScript library. Native apps embed it inside a WKWebView with a small HTML wrapper. Submission is handled by Swift's URLSession so Vercel's bot-protection firewall never blocks the request.

Feature support in WKWebView

Not every SDK feature works inside a native app container. Check the table before enabling features in the Form Builder.

FeatureiOS / macOS (WKWebView)Notes
Text feedback✅ WorksFull support via URLSession bridge
Categories✅ Works
Star rating✅ Works
Screenshot⚠️ PartialThe built-in capture only captures the WebView (the form itself, not the app). Use the native screenshot bridge described below to attach a real app screenshot.
Screen recording❌ Not supportedrrweb only records DOM events inside the WebView — it cannot observe native UI interactions. Use Apple's ReplayKit for native screen recording instead.

How it works

Init modeDomain checkAPI key checkNetwork call on init
formId + apiKey (web)✅ Required✅ RequiredPOST /api/verify
formId + apiKey + native: true⛔ Skipped✅ On submissionNone — safe in WKWebView

Why skip /api/verify? Vercel's bot-protection firewall blocks fetch() calls from WKWebView (they appear as "Challenge" entries in the Vercel dashboard). Using native: true skips that call entirely and passes the inline config directly to the widget.

Swift / SwiftUI integration

The example below creates a self-contained FeedbackSheet that opens as a SwiftUI sheet. It covers dark/light mode, loading state, error fallback, and native success overlay.

1. FeedbackSheet.swift

import SwiftUI
import WebKit

// ── Your CatchUX credentials ────────────────────────────────────────────────
private let catchUXFormID = "your-form-id"
private let catchUXAPIKey = "your-api-key"

// ── Message handlers ─────────────────────────────────────────────────────────

private class SubmitHandler: NSObject, WKScriptMessageHandler {
    let onSubmit: (String) -> Void
    init(onSubmit: @escaping (String) -> Void) { self.onSubmit = onSubmit }
    func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
        if let body = m.body as? String { onSubmit(body) }
    }
}

private class SDKErrorHandler: NSObject, WKScriptMessageHandler {
    let onError: () -> Void
    init(onError: @escaping () -> Void) { self.onError = onError }
    func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
        onError()
    }
}

private class SDKReadyHandler: NSObject, WKScriptMessageHandler {
    func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
        DispatchQueue.main.async {
            NotificationCenter.default.post(name: .catchuxSDKReady, object: nil)
        }
    }
}

extension Notification.Name {
    static let catchuxSDKReady = Notification.Name("catchuxSDKReady")
}

// ── WKWebView factory ─────────────────────────────────────────────────────────

private func makeFeedbackWebView(
    html: String,
    onSubmit: @escaping (String) -> Void,
    onError: @escaping () -> Void
) -> WKWebView {
    let config = WKWebViewConfiguration()
    config.userContentController.add(SubmitHandler(onSubmit: onSubmit), name: "submitFeedback")
    config.userContentController.add(SDKErrorHandler(onError: onError),  name: "sdkInitError")
    config.userContentController.add(SDKReadyHandler(),                   name: "sdkReady")

    let webView = WKWebView(frame: .zero, configuration: config)
    webView.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) " +
        "AppleWebKit/605.1.15 (KHTML, like Gecko) MyApp/1.0 Safari/605.1.15"
    #if os(macOS)
    webView.setValue(false, forKey: "drawsBackground")
    #else
    webView.isOpaque = false
    webView.backgroundColor = .clear
    #endif
    webView.loadHTMLString(html, baseURL: URL(string: "https://www.catchux.com"))
    return webView
}

// ── Platform representable ────────────────────────────────────────────────────

#if os(macOS)
struct FeedbackWebView: NSViewRepresentable {
    let html: String; let onSubmit: (String) -> Void
    let onError: () -> Void; let isDark: Bool
    func makeNSView(context: Context) -> WKWebView {
        makeFeedbackWebView(html: html, onSubmit: onSubmit, onError: onError)
    }
    func updateNSView(_ v: WKWebView, context: Context) {
        v.evaluateJavaScript(colorUpdateJS(isDark: isDark)) { _, _ in }
    }
}
#else
struct FeedbackWebView: UIViewRepresentable {
    let html: String; let onSubmit: (String) -> Void
    let onError: () -> Void; let isDark: Bool
    func makeUIView(context: Context) -> WKWebView {
        makeFeedbackWebView(html: html, onSubmit: onSubmit, onError: onError)
    }
    func updateUIView(_ v: WKWebView, context: Context) {
        v.evaluateJavaScript(colorUpdateJS(isDark: isDark)) { _, _ in }
    }
}
#endif

// ── FeedbackSheet ─────────────────────────────────────────────────────────────

struct FeedbackSheet: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.colorScheme) private var colorScheme
    @State private var showSuccess = false
    @State private var isSubmitting = false
    @State private var sdkFailed = false
    @State private var sdkReady = false
    @State private var submitError: String?

    private var isDark: Bool { colorScheme == .dark }

    private func handleSubmit(_ json: String) {
        guard !isSubmitting else { return }
        guard let data = json.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
        isSubmitting = true
        let body: [String: Any] = [
            "formId":   catchUXFormID,
            "message":  obj["message"] as? String ?? "",
            "category": obj["category"] ?? NSNull(),
            "rating":   obj["rating"]   ?? NSNull()
        ]
        guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else {
            isSubmitting = false; return
        }
        var req = URLRequest(url: URL(string: "https://www.catchux.com/api/feedback")!)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = bodyData
        Task {
            do {
                let (_, resp) = try await URLSession.shared.data(for: req)
                let ok = (resp as? HTTPURLResponse).map { (200...299).contains($0.statusCode) } ?? false
                await MainActor.run {
                    isSubmitting = false
                    if ok { showSuccess = true } else { submitError = "Submission failed. Please try again." }
                }
            } catch {
                await MainActor.run { isSubmitting = false; submitError = "No connection." }
            }
        }
    }

    var body: some View {
        VStack(spacing: 0) {
            // Header
            HStack(spacing: 10) {
                ZStack {
                    RoundedRectangle(cornerRadius: 8, style: .continuous)
                        .fill(Color.orange).frame(width: 32, height: 32)
                    Image(systemName: "bubble.left.and.bubble.right.fill")
                        .font(.system(size: 15, weight: .medium)).foregroundStyle(.white)
                }
                Text("Send Feedback").font(.headline)
                Spacer()
                Button("Close") { dismiss() }.buttonStyle(.bordered).keyboardShortcut(.escape, modifiers: [])
            }
            .padding(.horizontal, 20).padding(.vertical, 14)

            Divider()

            // Content
            if showSuccess {
                VStack(spacing: 16) {
                    Spacer()
                    Image(systemName: "checkmark.circle.fill").font(.system(size: 52)).foregroundStyle(.green)
                    Text("Thank you for your feedback!").font(.title3).bold()
                    Button("Close") { dismiss() }.buttonStyle(.borderedProminent).padding(.top, 8)
                    Spacer()
                }.frame(maxWidth: .infinity, maxHeight: .infinity)
            } else if sdkFailed {
                VStack(spacing: 16) {
                    Spacer()
                    Image(systemName: "wifi.exclamationmark").font(.system(size: 44)).foregroundStyle(.secondary)
                    Text("Feedback form unavailable").font(.title3).bold()
                    Text("Check your internet connection and try again.")
                        .font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
                    Button("Try again") { sdkFailed = false; sdkReady = false }.buttonStyle(.borderedProminent)
                    Spacer()
                }.frame(maxWidth: .infinity, maxHeight: .infinity)
            } else {
                ZStack {
                    FeedbackWebView(
                        html: htmlContent,
                        onSubmit: handleSubmit,
                        onError: { DispatchQueue.main.async { sdkFailed = true } },
                        isDark: isDark
                    )
                    if !sdkReady {
                        Rectangle().fill(.background).overlay(ProgressView()).transition(.opacity)
                    }
                    if isSubmitting {
                        Color.black.opacity(0.25).overlay(ProgressView().tint(.white))
                    }
                }
                .onReceive(NotificationCenter.default.publisher(for: .catchuxSDKReady)) { _ in
                    withAnimation(.easeOut(duration: 0.2)) { sdkReady = true }
                }
            }
        }
        .alert("Something went wrong", isPresented: Binding(get: { submitError != nil }, set: { if !$0 { submitError = nil } })) {
            Button("OK") { submitError = nil }
        } message: { Text(submitError ?? "") }
        #if os(macOS)
        .frame(minWidth: 480, idealWidth: 520, minHeight: 380, idealHeight: 440)
        #endif
    }

    // HTML injected into WKWebView
    private var htmlContent: String {
        let bg      = isDark ? "#1c1c1e" : "#ffffff"
        let text    = isDark ? "#ffffff" : "#111827"
        let border  = isDark ? "#3a3a3c" : "#d1d5db"
        let inputBg = isDark ? "#2c2c2e" : "#f9fafb"

        return """
        <!DOCTYPE html><html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
          <style>
            :root { --bg:\(bg); --text:\(text); --border:\(border); --input-bg:\(inputBg); }
            html { height:100%; background:var(--bg); }
            body { min-height:100%; background:var(--bg); color:var(--text);
                   font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',sans-serif; }
            textarea { background-color:var(--input-bg)!important; color:var(--text)!important;
                       border:1px solid var(--border)!important; border-radius:8px!important; }
            .catchux-modal-content { background-color:var(--bg)!important; }
          </style>
        </head>
        <body>
          <script src="https://www.catchux.com/sdk.js"
            onerror="window.webkit.messageHandlers.sdkInitError.postMessage('')"></script>
          <script>
            (function(){
              function init(){
                if(typeof catchux==='undefined'){
                  window.webkit.messageHandlers.sdkInitError.postMessage(''); return;
                }
                catchux.init({
                  formId: '\(catchUXFormID)',
                  apiKey: '\(catchUXAPIKey)',
                  native: true,
                  config: {
                    features: { formTitle:false, screenshots:false, textField:true,
                                rating:false, categories:false, widget:false, buttons:true },
                    submitButtonLabel: 'Send',
                    showCancelButton: false,
                    triggerConfig: { triggerType: 'embed' }
                  },
                  onSubmit: function(data){
                    window.webkit.messageHandlers.submitFeedback.postMessage(JSON.stringify(data));
                    return new Promise(function(){});
                  },
                  onError: function(){ window.webkit.messageHandlers.sdkInitError.postMessage(''); },
                  onReady: function(){ window.webkit.messageHandlers.sdkReady.postMessage(''); }
                }).then(function(){
                  var modal=catchux._modal, content=catchux._modalContent;
                  if(!modal) return;
                  modal.style.cssText='display:block!important;position:static!important;background:transparent!important;width:100%!important;height:auto!important;z-index:1!important;margin:0!important;padding:0!important;box-sizing:border-box!important;';
                  if(content) content.style.cssText='position:static!important;transform:none!important;width:100%!important;max-width:100%!important;max-height:none!important;border-radius:0!important;box-shadow:none!important;padding:16px!important;box-sizing:border-box!important;background-color:transparent!important;margin:0!important;';
                  if(catchux._textarea){
                    var ta=catchux._textarea;
                    ta.style.setProperty('background-color','var(--input-bg)','important');
                    ta.style.setProperty('color','var(--text)','important');
                  }
                });
              }
              document.readyState==='loading'
                ? document.addEventListener('DOMContentLoaded',init) : init();
            })();
          </script>
        </body></html>
        """
    }
}

private func colorUpdateJS(isDark: Bool) -> String {
    let bg      = isDark ? "#1c1c1e" : "#ffffff"
    let text    = isDark ? "#ffffff" : "#111827"
    let border  = isDark ? "#3a3a3c" : "#d1d5db"
    let inputBg = isDark ? "#2c2c2e" : "#f9fafb"
    return """
    (function(){
      var r=document.documentElement.style;
      r.setProperty('--bg','\(bg)');r.setProperty('--text','\(text)');
      r.setProperty('--border','\(border)');r.setProperty('--input-bg','\(inputBg)');
      document.documentElement.style.backgroundColor='\(bg)';
      document.body.style.backgroundColor='\(bg)';
      var ta=document.querySelector('textarea');
      if(ta){ta.style.setProperty('background-color','\(inputBg)','important');
             ta.style.setProperty('color','\(text)','important');}
    })();
    """
}

2. Trigger the sheet from SettingsView

struct SettingsView: View {
    @State private var showFeedback = false

    var body: some View {
        // ... your existing settings UI ...
        Button {
            showFeedback = true
        } label: {
            Label("Feedback", systemImage: "bubble.left.and.bubble.right.fill")
        }
        .sheet(isPresented: $showFeedback) {
            FeedbackSheet()
        }
    }
}

Attaching a native app screenshot

The SDK's built-in screenshot button only captures the WKWebView (i.e. the form itself). To attach a screenshot of the actual app, take it from Swift before opening the sheet and inject it into the WebView after init.

// 1. Capture the app window before showing the sheet
#if os(macOS)
func appScreenshotBase64() -> String? {
    guard let window = NSApp.keyWindow,
          let view = window.contentView else { return nil }
    let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds)!
    view.cacheDisplay(in: view.bounds, to: bitmap)
    return bitmap.representation(using: .png, properties: [:])?
        .base64EncodedString()
        .map { "data:image/png;base64," + $0 }
}
#endif

// 2. After WKWebView loads, inject it:
webView.evaluateJavaScript("""
    if (window.catchux) {
        catchux._screenshotData = '\(base64String)';
    }
""")

Enable features.screenshots: true in the init config to show the screenshot attachment UI. The injected image will appear as the pre-attached screenshot.

Troubleshooting

  • Form is blank after opening — Vercel's bot-protection is blocking fetch() from WKWebView. Make sure native: true is set (skips /api/verify) and that onSubmit is set (delegates submission to URLSession).
  • _modal is null after init resolves — Same root cause as above. The SDK's internal fetch() was blocked silently. Use native: true.
  • Textarea stays white in dark mode — The SDK sets inline styles after init. Use ta.style.setProperty('background-color', ..., 'important') in the .then() block to override them.
  • Sheet background is black in light mode — Add html { height: 100%; background: var(--bg); } to ensure the body fills the full WKWebView height.