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.
| Feature | iOS / macOS (WKWebView) | Notes |
|---|---|---|
| Text feedback | ✅ Works | Full support via URLSession bridge |
| Categories | ✅ Works | |
| Star rating | ✅ Works | |
| Screenshot | ⚠️ Partial | The 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 supported | rrweb 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 mode | Domain check | API key check | Network call on init |
|---|---|---|---|
| formId + apiKey (web) | ✅ Required | ✅ Required | POST /api/verify |
| formId + apiKey + native: true | ⛔ Skipped | ✅ On submission | None — 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()fromWKWebView. Make surenative: trueis set (skips/api/verify) and thatonSubmitis set (delegates submission toURLSession). _modalis null after init resolves — Same root cause as above. The SDK's internalfetch()was blocked silently. Usenative: 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 fullWKWebViewheight.
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.
| Feature | iOS / macOS (WKWebView) | Notes |
|---|---|---|
| Text feedback | ✅ Works | Full support via URLSession bridge |
| Categories | ✅ Works | |
| Star rating | ✅ Works | |
| Screenshot | ⚠️ Partial | The 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 supported | rrweb 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 mode | Domain check | API key check | Network call on init |
|---|---|---|---|
| formId + apiKey (web) | ✅ Required | ✅ Required | POST /api/verify |
| formId + apiKey + native: true | ⛔ Skipped | ✅ On submission | None — 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()fromWKWebView. Make surenative: trueis set (skips/api/verify) and thatonSubmitis set (delegates submission toURLSession). _modalis null after init resolves — Same root cause as above. The SDK's internalfetch()was blocked silently. Usenative: 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 fullWKWebViewheight.