diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d37e611 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test & Lint (SPM) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Show Swift toolchain + run: swift --version + + - name: Run package tests + run: swift test + + - name: Install SwiftLint + run: brew install swiftlint + + - name: SwiftLint (strict) + run: swiftlint lint --strict --config .swiftlint.yml + + ios-build: + name: Build SDK for iOS + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode + run: xcodebuild -version + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate FeedbackApp project + run: xcodegen generate + + - name: Build FeedbackApp (iOS Simulator) + run: | + xcodebuild build \ + -scheme FeedbackApp \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO diff --git a/.swiftlint.yml b/.swiftlint.yml index 156e3e0..4135877 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,9 +1,9 @@ -included_paths: +included: - FeedbackApp/ - Sources/ - Tests/ -excluded_paths: +excluded: - .build/ - DerivedData/ diff --git a/Example/OpenCovenFeedbackExample/OpenCovenFeedbackExampleApp.swift b/Example/OpenCovenFeedbackExample/OpenCovenFeedbackExampleApp.swift index 51f814f..65c531f 100644 --- a/Example/OpenCovenFeedbackExample/OpenCovenFeedbackExampleApp.swift +++ b/Example/OpenCovenFeedbackExample/OpenCovenFeedbackExampleApp.swift @@ -8,7 +8,7 @@ struct OpenCovenFeedbackExampleApp: App { // In production, call OpenCovenFeedback.identify(ssoToken:) with a server-signed token OpenCovenFeedback.identify(userId: "user_example", email: "demo@example.com", name: "Demo User") OpenCovenFeedback.on(.vote) { print("[OpenCovenFeedback] vote:", $0) } - OpenCovenFeedback.on(.submit) { print("[OpenCovenFeedback] submit:", $0) } + OpenCovenFeedback.on(.postCreated) { print("[OpenCovenFeedback] post created:", $0) } OpenCovenFeedback.showLauncher() } var body: some Scene { WindowGroup { ContentView() } } diff --git a/FeedbackApp/Sources/FeedbackApp/Config/AppConfiguration.swift b/FeedbackApp/Sources/FeedbackApp/Config/AppConfiguration.swift index c839a7e..32d2157 100644 --- a/FeedbackApp/Sources/FeedbackApp/Config/AppConfiguration.swift +++ b/FeedbackApp/Sources/FeedbackApp/Config/AppConfiguration.swift @@ -34,9 +34,9 @@ final class AppConfiguration: ObservableObject { } private func setupEventListeners() { - OpenCovenFeedback.on(.submit) { data in + OpenCovenFeedback.on(.postCreated) { data in // Forward to analytics layer when integrated - print("[Feedback] submitted:", data) + print("[Feedback] post created:", data) } OpenCovenFeedback.on(.vote) { data in print("[Feedback] vote:", data) diff --git a/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift b/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift index 657a816..fe20e5a 100644 --- a/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift +++ b/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift @@ -129,5 +129,9 @@ extension Color { } #Preview { - NavigationStack { HomeView() } + if #available(iOS 16.0, *) { + NavigationStack { HomeView() } + } else { + NavigationView { HomeView() } + } } diff --git a/FeedbackApp/Sources/FeedbackApp/Views/RootView.swift b/FeedbackApp/Sources/FeedbackApp/Views/RootView.swift index d18c5d6..7467e09 100644 --- a/FeedbackApp/Sources/FeedbackApp/Views/RootView.swift +++ b/FeedbackApp/Sources/FeedbackApp/Views/RootView.swift @@ -5,8 +5,12 @@ struct RootView: View { @EnvironmentObject private var appConfig: AppConfiguration var body: some View { - NavigationStack { - HomeView() + Group { + if #available(iOS 16.0, *) { + NavigationStack { HomeView() } + } else { + NavigationView { HomeView() } + } } .onAppear { OpenCovenFeedback.showLauncher() diff --git a/Sources/OpenCovenFeedback/Internal/FeedbackWebView.swift b/Sources/OpenCovenFeedback/Internal/FeedbackWebView.swift index 48ac7e7..9650c31 100644 --- a/Sources/OpenCovenFeedback/Internal/FeedbackWebView.swift +++ b/Sources/OpenCovenFeedback/Internal/FeedbackWebView.swift @@ -21,7 +21,7 @@ final class OpenCovenFeedbackWebView: NSObject, WKScriptMessageHandler, WKNaviga let wkConfig = WKWebViewConfiguration() let ucc = WKUserContentController() ucc.addUserScript(WKUserScript(source: JSBridge.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) - ucc.add(self, name: "opencoven-feedback") + ucc.add(self, name: "quackback") wkConfig.userContentController = ucc let wv = WKWebView(frame: .zero, configuration: wkConfig) wv.navigationDelegate = self; wv.isOpaque = false; wv.backgroundColor = .clear @@ -35,16 +35,16 @@ final class OpenCovenFeedbackWebView: NSObject, WKScriptMessageHandler, WKNaviga } func tearDown() { - webView?.configuration.userContentController.removeScriptMessageHandler(forName: "opencoven-feedback") + webView?.configuration.userContentController.removeScriptMessageHandler(forName: "quackback") webView?.stopLoading(); webView = nil; isReady = false; pendingCommands.removeAll() } func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) { - guard message.name == "opencoven-feedback", let body = message.body as? String, + guard message.name == "quackback", let body = message.body as? String, let parsed = JSBridge.parseEvent(body) else { return } if parsed.event == .ready { isReady = true - webView?.evaluateJavaScript(JSBridge.initCommand(config: config)) + // Theme/config arrive via config.json + URL params — there is no init message. if let l = config.locale { webView?.evaluateJavaScript(JSBridge.localeCommand(l)) } pendingCommands.forEach { webView?.evaluateJavaScript($0) }; pendingCommands.removeAll() delegate?.webViewDidBecomeReady(); return diff --git a/Sources/OpenCovenFeedback/Internal/JSBridge.swift b/Sources/OpenCovenFeedback/Internal/JSBridge.swift index 5dee5a7..50fba96 100644 --- a/Sources/OpenCovenFeedback/Internal/JSBridge.swift +++ b/Sources/OpenCovenFeedback/Internal/JSBridge.swift @@ -1,73 +1,97 @@ import Foundation +/// Builds and parses the `quackback:` postMessage protocol shared by every +/// OpenCoven Feedback client. The wire namespace is frozen as `quackback:` for +/// backward compatibility — it is intentionally not rebranded. +/// +/// Source of truth: `lib/shared/widget/types.ts` and `lib/client/widget-bridge.ts` +/// in the OpenCoven/feedback repo. enum JSBridge { struct ParsedEvent { let event: OpenCovenFeedbackEvent; let data: [String: Any] } - static func initCommand(config: OpenCovenFeedbackConfig) -> String { - var p: [String: String] = ["theme": config.theme.rawValue] - if let l = config.locale { p["locale"] = l } - return "window.postMessage({type:'opencoven-feedback:init',data:\(json(p))},'*');" - } + // MARK: - Inbound commands (host -> widget) static func localeCommand(_ locale: String) -> String { - "window.postMessage({type:'opencoven-feedback:locale',data:'\(locale)'},'*');" + let data = try! JSONSerialization.data(withJSONObject: [locale], options: []) + let encoded = String(data: data, encoding: .utf8)!.dropFirst().dropLast() + return "window.postMessage({type:'quackback:locale',data:\(encoded)},'*');" } static func identifyCommand(ssoToken: String) -> String { - "window.postMessage({type:'opencoven-feedback:identify',data:\(json(["ssoToken": ssoToken]))},'*');" + "window.postMessage({type:'quackback:identify',data:\(json(["ssoToken": ssoToken]))},'*');" } static func identifyCommand(userId: String, email: String, name: String?, avatarURL: String?) -> String { var p: [String: String] = ["id": userId, "email": email] if let n = name { p["name"] = n } if let a = avatarURL { p["avatarURL"] = a } - return "window.postMessage({type:'opencoven-feedback:identify',data:\(json(p))},'*');" + return "window.postMessage({type:'quackback:identify',data:\(json(p))},'*');" } static func identifyAnonymousCommand() -> String { - "window.postMessage({type:'opencoven-feedback:identify',data:{\"anonymous\":true}},'*');" + "window.postMessage({type:'quackback:identify',data:{\"anonymous\":true}},'*');" } + static func logoutCommand() -> String { "window.postMessage({type:'quackback:identify',data:null},'*');" } + static func openCommand(view: OpenView? = nil, title: String? = nil, board: String? = nil) -> String { var p: [String: String] = [:] if let v = view { p["view"] = v.rawValue } if let t = title { p["title"] = t } if let b = board { p["board"] = b } - if p.isEmpty { return "window.postMessage({type:'opencoven-feedback:open'},'*');" } - return "window.postMessage({type:'opencoven-feedback:open',data:\(json(p))},'*');" + if p.isEmpty { return "window.postMessage({type:'quackback:open'},'*');" } + return "window.postMessage({type:'quackback:open',data:\(json(p))},'*');" } - static func logoutCommand() -> String { "window.postMessage({type:'opencoven-feedback:identify',data:null},'*');" } - static func metadataCommand(_ patch: [String: String?]) -> String { // nil values mean "remove this key" — the iframe interprets null as delete var dict: [String: Any] = [:] for (k, v) in patch { dict[k] = v as Any? ?? NSNull() } let d = try! JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]) let json = String(data: d, encoding: .utf8)! - return "window.postMessage({type:'opencoven-feedback:metadata',data:\(json)},'*');" + return "window.postMessage({type:'quackback:metadata',data:\(json)},'*');" } + // MARK: - Outbound parsing (widget -> host) + + /// The widget calls `window.__quackbackNative.dispatch(eventType, message)`. + /// The bridge script packs that as `{event: eventType, data: message}`. + /// + /// For `eventType == "event"` the real event name and payload live inside the + /// message (`quackback:event` wrapper). All other types are standalone + /// outbound messages (`ready`, `close`, `navigate`, `identify-result`, + /// `auth-change`) whose data is the message body minus its `type` key. static func parseEvent(_ jsonString: String) -> ParsedEvent? { guard let data = jsonString.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let name = obj["event"] as? String, - let event = OpenCovenFeedbackEvent(rawValue: name) else { return nil } - var eventData: [String: Any] = [:] - if let d = obj["data"] as? [String: Any] { - eventData = (d["payload"] as? [String: Any]) ?? d + let type = obj["event"] as? String else { return nil } + let message = obj["data"] as? [String: Any] ?? [:] + + if type == "event" { + guard let name = message["name"] as? String, + let event = OpenCovenFeedbackEvent(rawValue: name) else { return nil } + return ParsedEvent(event: event, data: message["payload"] as? [String: Any] ?? [:]) } - return ParsedEvent(event: event, data: eventData) + + guard let event = OpenCovenFeedbackEvent(rawValue: type) else { return nil } + var payload = message + payload.removeValue(forKey: "type") + return ParsedEvent(event: event, data: payload) } + // MARK: - Bridge script + + /// Injected at document start. Defines `window.__quackbackNative.dispatch` so + /// the widget routes outbound messages to the native message handler instead + /// of `window.parent.postMessage`. static var bridgeScript: String { """ - (function(){ - var dispatch=function(e,d){ - var m=JSON.stringify({event:e,data:d}); - window.webkit.messageHandlers.opencoven-feedback.postMessage(m); - }; - window.__opencoven-feedbackNative={dispatch:dispatch}; + (function () { + function dispatch(type, message) { + var payload = JSON.stringify({ event: type, data: message }); + window.webkit.messageHandlers.quackback.postMessage(payload); + } + window.__quackbackNative = { dispatch: dispatch }; })(); """ } diff --git a/Sources/OpenCovenFeedback/Internal/LauncherButton.swift b/Sources/OpenCovenFeedback/Internal/LauncherButton.swift index 500f976..91a795f 100644 --- a/Sources/OpenCovenFeedback/Internal/LauncherButton.swift +++ b/Sources/OpenCovenFeedback/Internal/LauncherButton.swift @@ -2,6 +2,10 @@ import UIKit final class LauncherButton: UIButton { + /// Invoked on tap. Set by the owner; the `@objc` selector target lives here + /// (on a class) rather than on the `OpenCovenFeedback` enum namespace. + var onTap: (() -> Void)? + private let position: OpenCovenFeedbackPosition private var isOpen = false private let size: CGFloat = 48 @@ -46,9 +50,13 @@ final class LauncherButton: UIButton { icon.heightAnchor.constraint(equalToConstant: iconSize), ]) } + + addTarget(self, action: #selector(handleTap), for: .touchUpInside) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + @objc private func handleTap() { onTap?() } + func install(in window: UIWindow) { window.addSubview(self) let guide = window.safeAreaLayoutGuide diff --git a/Sources/OpenCovenFeedback/OpenCovenFeedback.swift b/Sources/OpenCovenFeedback/OpenCovenFeedback.swift index 4a45e84..898a7c3 100644 --- a/Sources/OpenCovenFeedback/OpenCovenFeedback.swift +++ b/Sources/OpenCovenFeedback/OpenCovenFeedback.swift @@ -43,14 +43,15 @@ public enum OpenCovenFeedback { ensureWV(config) wvManager?.execute(JSBridge.openCommand(view: view, title: title, board: board)) presentPanel() + emitOpen(view: view, title: title, board: board) } - public static func close() { dismissPanel() } + public static func close() { dismissPanel(emitClose: true) } public static func showLauncher() { guard let config, launcher == nil else { return } let color = resolveColor(config: config) let btn = LauncherButton(position: config.placement, color: color) - btn.addTarget(self, action: #selector(launcherTapped), for: .touchUpInside) + btn.onTap = { launcherToggle() } if let w = keyWindow { btn.install(in: w) }; launcher = btn } public static func hideLauncher() { launcher?.removeFromSuperview(); launcher = nil } @@ -62,7 +63,7 @@ public enum OpenCovenFeedback { public static func off(_ token: EventToken) { emitter.off(token) } public static func destroy() { - dismissPanel(); hideLauncher(); wvManager?.tearDown(); wvManager = nil + dismissPanel(emitClose: false); hideLauncher(); wvManager?.tearDown(); wvManager = nil emitter.removeAll(); config = nil; pendingIdentify = nil; serverThemeColor = nil } @@ -104,10 +105,19 @@ public enum OpenCovenFeedback { guard let top = topVC else { return } top.present(pc, animated: true); isShowing = true; launcher?.setOpen(true); panel = pc } - private static func dismissPanel() { + private static func dismissPanel(emitClose: Bool = false) { + let shouldEmit = emitClose && isShowing panel?.dismiss(animated: true); panel = nil; isShowing = false; launcher?.setOpen(false) + if shouldEmit { emitter.emit(.close, data: [:]) } } - @objc private static func launcherTapped() { if isShowing { close() } else { open() } } + private static func emitOpen(view: OpenView?, title: String?, board: String?) { + var data: [String: Any] = [:] + if let view { data["view"] = view.rawValue } + if let title { data["title"] = title } + if let board { data["board"] = board } + emitter.emit(.open, data: data) + } + private static func launcherToggle() { if isShowing { close() } else { open() } } private static var keyWindow: UIWindow? { UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows).first { $0.isKeyWindow } @@ -124,7 +134,7 @@ public enum OpenCovenFeedback { private final class Delegate: OpenCovenFeedbackWebViewDelegate { static let shared = Delegate() func webViewDidReceiveEvent(_ event: OpenCovenFeedbackEvent, data: [String: Any]) { - if event == .close { dismissPanel() }; emitter.emit(event, data: data) + if event == .close { dismissPanel(emitClose: false) }; emitter.emit(event, data: data) } func webViewDidBecomeReady() { if let js = pendingIdentify { wvManager?.execute(js); pendingIdentify = nil } diff --git a/Sources/OpenCovenFeedback/OpenCovenFeedbackEvent.swift b/Sources/OpenCovenFeedback/OpenCovenFeedbackEvent.swift index 3084b75..c065144 100644 --- a/Sources/OpenCovenFeedback/OpenCovenFeedbackEvent.swift +++ b/Sources/OpenCovenFeedback/OpenCovenFeedbackEvent.swift @@ -1,7 +1,28 @@ import Foundation +/// Events surfaced by the widget. Raw values match the canonical wire contract +/// (`lib/shared/widget/types.ts` in OpenCoven/feedback). public enum OpenCovenFeedbackEvent: String, Sendable { - case ready, vote, submit, close, navigate + /// Widget finished loading — emitted once per session. + case ready + /// Widget panel opened. + case open + /// Widget panel closed. + case close + /// A post was created. Payload: `id`, `title`, `board`, `statusId`. + case postCreated = "post:created" + /// A vote toggled. Payload: `postId`, `voted`, `voteCount`. + case vote + /// A comment was created. Payload: `postId`, `commentId`, `parentId`. + case commentCreated = "comment:created" + /// Identify resolved inside the widget. Payload: `success`, `user`, `anonymous`, `error`. + case identify + /// The widget navigated. Payload: `url`. + case navigate + /// Result of an `identify` command. Payload: `success`, `user`, `error`. + case identifyResult = "identify-result" + /// The signed-in user changed. Payload: `user`. + case authChange = "auth-change" } public struct EventToken: Hashable, Sendable { let id = UUID() } diff --git a/Sources/OpenCovenFeedback/OpenView.swift b/Sources/OpenCovenFeedback/OpenView.swift index 4fd3b76..8ecff46 100644 --- a/Sources/OpenCovenFeedback/OpenView.swift +++ b/Sources/OpenCovenFeedback/OpenView.swift @@ -1,6 +1,7 @@ import Foundation /// A specific view the widget can open to, passed to `OpenCovenFeedback.open(view:...)`. +/// Raw values match the canonical `quackback:open` contract. public enum OpenView: String, Sendable { /// Home — boards/feed list. case home = "home" @@ -8,4 +9,6 @@ public enum OpenView: String, Sendable { case newPost = "new-post" /// Changelog feed. case changelog = "changelog" + /// Help center. + case help = "help" } diff --git a/Tests/OpenCovenFeedbackTests/BridgeContractTests.swift b/Tests/OpenCovenFeedbackTests/BridgeContractTests.swift new file mode 100644 index 0000000..ead53dc --- /dev/null +++ b/Tests/OpenCovenFeedbackTests/BridgeContractTests.swift @@ -0,0 +1,92 @@ +import JavaScriptCore +@testable import OpenCovenFeedback +import XCTest + +/// Executes the injected bridge script in a real JS engine to prove it is valid +/// JavaScript and that `window.__quackbackNative.dispatch` routes messages to the +/// host the same way the web widget (`lib/client/widget-bridge.ts`) calls it. +/// +/// String-`contains` assertions cannot catch an invalid-JS or wrong-global bridge; +/// these tests can. +final class BridgeContractTests: XCTestCase { + /// Builds a JS context that mirrors the WKWebView environment: a `window` + /// with `webkit.messageHandlers.quackback.postMessage` wired back to Swift, + /// then evaluates the bridge script. Returns the context and a capture probe. + private func makeContext(file: StaticString = #filePath, line: UInt = #line) -> (JSContext, () -> String?) { + let context = JSContext()! + var captured: String? + let capture: @convention(block) (String) -> Void = { captured = $0 } + context.setObject(capture, forKeyedSubscript: "__capture" as NSString) + context.evaluateScript(""" + var window = this; + window.webkit = { messageHandlers: { quackback: { postMessage: function (m) { __capture(m); } } } }; + """) + context.evaluateScript(JSBridge.bridgeScript) + XCTAssertNil(context.exception, "bridge script raised: \(String(describing: context.exception))", file: file, line: line) + return (context, { captured }) + } + + func testBridgeScriptDefinesCallableDispatch() { + let (context, _) = makeContext() + let kind = context.evaluateScript("typeof window.__quackbackNative.dispatch") + XCTAssertEqual(kind?.toString(), "function") + } + + func testWidgetCanDetectNativeBridge() { + // Mirrors the web `sendToHost` guard: `window.__quackbackNative?.dispatch`. + let (context, _) = makeContext() + let hasBridge = context.evaluateScript("!!(window.__quackbackNative && window.__quackbackNative.dispatch)") + XCTAssertTrue(hasBridge?.toBool() ?? false) + } + + func testDispatchEventWrapperReachesHostAndParses() { + let (context, captured) = makeContext() + context.evaluateScript(""" + window.__quackbackNative.dispatch('event', { + type: 'quackback:event', + name: 'vote', + payload: { postId: 'p1', voted: true, voteCount: 5 } + }); + """) + XCTAssertNil(context.exception) + guard let message = captured() else { return XCTFail("host received no message") } + let parsed = JSBridge.parseEvent(message) + XCTAssertEqual(parsed?.event, .vote) + XCTAssertEqual(parsed?.data["postId"] as? String, "p1") + XCTAssertEqual(parsed?.data["voteCount"] as? Int, 5) + } + + func testDispatchReadyMessageParses() { + let (context, captured) = makeContext() + context.evaluateScript("window.__quackbackNative.dispatch('ready', { type: 'quackback:ready' });") + XCTAssertEqual(JSBridge.parseEvent(captured()!)?.event, .ready) + } + + func testDispatchCloseMessageParses() { + let (context, captured) = makeContext() + context.evaluateScript("window.__quackbackNative.dispatch('close', { type: 'quackback:close' });") + XCTAssertEqual(JSBridge.parseEvent(captured()!)?.event, .close) + } + + func testDispatchNavigateMessageParses() { + let (context, captured) = makeContext() + context.evaluateScript("window.__quackbackNative.dispatch('navigate', { type: 'quackback:navigate', url: '/boards/bugs' });") + let parsed = JSBridge.parseEvent(captured()!) + XCTAssertEqual(parsed?.event, .navigate) + XCTAssertEqual(parsed?.data["url"] as? String, "/boards/bugs") + } + + func testDispatchIdentifyResultMessageParses() { + let (context, captured) = makeContext() + context.evaluateScript(""" + window.__quackbackNative.dispatch('identify-result', { + type: 'quackback:identify-result', + success: true, + user: { id: 'u1', name: 'Val', email: 'val@example.com', avatarUrl: null } + }); + """) + let parsed = JSBridge.parseEvent(captured()!) + XCTAssertEqual(parsed?.event, .identifyResult) + XCTAssertEqual(parsed?.data["success"] as? Bool, true) + } +} diff --git a/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift b/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift index 22c40ee..ea63b88 100644 --- a/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift +++ b/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift @@ -2,153 +2,200 @@ import XCTest final class JSBridgeTests: XCTestCase { - func testInitCommand() { - let config = OpenCovenFeedbackConfig(instanceUrl: URL(string: "https://x.com")!, theme: .dark, locale: "fr") - let js = JSBridge.initCommand(config: config) - XCTAssertTrue(js.contains("window.postMessage")) - XCTAssertTrue(js.contains("opencoven-feedback:init")) - XCTAssertTrue(js.contains("\"theme\":\"dark\"")) - XCTAssertTrue(js.contains("\"locale\":\"fr\"")) - XCTAssertFalse(js.contains("appId")) - } + // MARK: - Inbound commands (host -> widget) + func testIdentifySSO() { let js = JSBridge.identifyCommand(ssoToken: "tok123") - XCTAssertTrue(js.contains("window.postMessage")); XCTAssertTrue(js.contains("opencoven-feedback:identify")) + XCTAssertTrue(js.contains("window.postMessage")) + XCTAssertTrue(js.contains("quackback:identify")) XCTAssertTrue(js.contains("\"ssoToken\":\"tok123\"")) } + func testIdentifyAttrs() { let js = JSBridge.identifyCommand(userId: "u1", email: "a@b.c", name: "A", avatarURL: nil) - XCTAssertTrue(js.contains("window.postMessage")); XCTAssertTrue(js.contains("opencoven-feedback:identify")) - XCTAssertTrue(js.contains("\"id\":\"u1\"")); XCTAssertTrue(js.contains("\"email\":\"a@b.c\"")) + XCTAssertTrue(js.contains("quackback:identify")) + XCTAssertTrue(js.contains("\"id\":\"u1\"")) + XCTAssertTrue(js.contains("\"email\":\"a@b.c\"")) + } + + func testIdentifyAttrsWithAvatarURL() { + let js = JSBridge.identifyCommand(userId: "u1", email: "a@b.c", name: "A", avatarURL: "https://img.com/a.png") + XCTAssertTrue(js.contains("\"avatarURL\"")) + XCTAssertTrue(js.contains("img.com")) + } + + func testIdentifyAttrsWithoutName() { + let js = JSBridge.identifyCommand(userId: "u1", email: "a@b.c", name: nil, avatarURL: nil) + XCTAssertTrue(js.contains("\"id\":\"u1\"")) + XCTAssertFalse(js.contains("\"name\"")) + XCTAssertFalse(js.contains("\"avatarURL\"")) } + func testIdentifyAnonymousCommand() { let js = JSBridge.identifyAnonymousCommand() - XCTAssertTrue(js.contains("window.postMessage")); XCTAssertTrue(js.contains("opencoven-feedback:identify")) + XCTAssertTrue(js.contains("quackback:identify")) XCTAssertTrue(js.contains("\"anonymous\":true")) } + + func testLogout() { + XCTAssertEqual(JSBridge.logoutCommand(), "window.postMessage({type:'quackback:identify',data:null},'*');") + } + + func testLocaleCommand() { + let js = JSBridge.localeCommand("fr") + XCTAssertTrue(js.contains("quackback:locale")) + XCTAssertTrue(js.contains("'fr'") || js.contains("\"fr\"")) + } + func testOpenBoard() { let js = JSBridge.openCommand(board: "bugs") - XCTAssertTrue(js.contains("window.postMessage")); XCTAssertTrue(js.contains("opencoven-feedback:open")) + XCTAssertTrue(js.contains("quackback:open")) XCTAssertTrue(js.contains("\"board\":\"bugs\"")) } + func testOpenView() { let js = JSBridge.openCommand(view: .newPost, title: "Bug:") XCTAssertTrue(js.contains("\"view\":\"new-post\"")) XCTAssertTrue(js.contains("\"title\":\"Bug:\"")) } + func testOpenViewAndBoard() { let js = JSBridge.openCommand(view: .newPost, title: "Crash", board: "bugs") XCTAssertTrue(js.contains("\"view\":\"new-post\"")) XCTAssertTrue(js.contains("\"board\":\"bugs\"")) XCTAssertTrue(js.contains("\"title\":\"Crash\"")) } + + func testOpenChangelogAndHelpViews() { + XCTAssertTrue(JSBridge.openCommand(view: .changelog).contains("\"view\":\"changelog\"")) + XCTAssertTrue(JSBridge.openCommand(view: .help).contains("\"view\":\"help\"")) + } + func testOpenEmpty() { - XCTAssertEqual(JSBridge.openCommand(), "window.postMessage({type:'opencoven-feedback:open'},'*');") + XCTAssertEqual(JSBridge.openCommand(), "window.postMessage({type:'quackback:open'},'*');") } - func testLogout() { XCTAssertEqual(JSBridge.logoutCommand(), "window.postMessage({type:'opencoven-feedback:identify',data:null},'*');") } + func testMetadataCommand() { let js = JSBridge.metadataCommand(["page": "/settings", "version": "2.4.1"]) - XCTAssertTrue(js.contains("opencoven-feedback:metadata")) + XCTAssertTrue(js.contains("quackback:metadata")) XCTAssertTrue(js.contains("\"page\":\"\\/settings\"") || js.contains("\"page\":\"/settings\"")) XCTAssertTrue(js.contains("\"version\":\"2.4.1\"")) } + func testMetadataRemoveKey() { let js = JSBridge.metadataCommand(["stale": nil]) - XCTAssertTrue(js.contains("opencoven-feedback:metadata")) + XCTAssertTrue(js.contains("quackback:metadata")) XCTAssertTrue(js.contains("\"stale\":null")) } - func testParseVoteEvent() { - let json = #"{"event":"vote","data":{"type":"opencoven-feedback:event","name":"vote","payload":{"postId":"post_abc"}}}"# + + func testCommandsEndWithSemicolon() { + XCTAssertTrue(JSBridge.identifyCommand(ssoToken: "t").hasSuffix(";")) + XCTAssertTrue(JSBridge.identifyCommand(userId: "u", email: "e", name: nil, avatarURL: nil).hasSuffix(";")) + XCTAssertTrue(JSBridge.identifyAnonymousCommand().hasSuffix(";")) + XCTAssertTrue(JSBridge.localeCommand("fr").hasSuffix(";")) + XCTAssertTrue(JSBridge.openCommand(board: "b").hasSuffix(";")) + XCTAssertTrue(JSBridge.openCommand().hasSuffix(";")) + XCTAssertTrue(JSBridge.logoutCommand().hasSuffix(";")) + XCTAssertTrue(JSBridge.metadataCommand(["k": "v"]).hasSuffix(";")) + } + + func testCommandsUseQuackbackNamespace() { + XCTAssertFalse(JSBridge.identifyAnonymousCommand().contains("opencoven-feedback")) + XCTAssertFalse(JSBridge.openCommand().contains("opencoven-feedback")) + XCTAssertFalse(JSBridge.metadataCommand(["k": "v"]).contains("opencoven-feedback")) + } + + // MARK: - Outbound parsing (widget -> host) + // Mirrors the real dispatch format: dispatch(eventType, fullMessage), packed + // by the bridge script as {event: eventType, data: fullMessage}. + + func testParseVoteEventWrapper() { + let json = #"{"event":"event","data":{"type":"quackback:event","name":"vote","payload":{"postId":"post_abc","voted":true,"voteCount":3}}}"# let p = JSBridge.parseEvent(json)! - XCTAssertEqual(p.event, .vote); XCTAssertEqual(p.data["postId"] as? String, "post_abc") + XCTAssertEqual(p.event, .vote) + XCTAssertEqual(p.data["postId"] as? String, "post_abc") } - func testParseReady() { - XCTAssertEqual(JSBridge.parseEvent(#"{"event":"ready","data":{"type":"opencoven-feedback:ready"}}"#)!.event, .ready) + + func testParsePostCreatedEventWrapper() { + let json = #"{"event":"event","data":{"type":"quackback:event","name":"post:created","payload":{"id":"post_xyz","title":"Crash"}}}"# + let p = JSBridge.parseEvent(json)! + XCTAssertEqual(p.event, .postCreated) + XCTAssertEqual(p.data["id"] as? String, "post_xyz") } - func testParseInvalid() { XCTAssertNil(JSBridge.parseEvent("bad")) } - func testInitCommandWithoutLocale() { - let config = OpenCovenFeedbackConfig(instanceUrl: URL(string: "https://x.com")!, theme: .light) - let js = JSBridge.initCommand(config: config) - XCTAssertTrue(js.contains("\"theme\":\"light\"")) - XCTAssertFalse(js.contains("locale")) + func testParseCommentCreatedEventWrapper() { + let json = #"{"event":"event","data":{"type":"quackback:event","name":"comment:created","payload":{"postId":"p1","commentId":"c1","parentId":null}}}"# + let p = JSBridge.parseEvent(json)! + XCTAssertEqual(p.event, .commentCreated) + XCTAssertEqual(p.data["commentId"] as? String, "c1") } - func testInitCommandSystemTheme() { - let config = OpenCovenFeedbackConfig(instanceUrl: URL(string: "https://x.com")!) - let js = JSBridge.initCommand(config: config) - XCTAssertTrue(js.contains("\"theme\":\"user\"")) + func testParseIdentifyEventWrapper() { + let json = #"{"event":"event","data":{"type":"quackback:event","name":"identify","payload":{"success":true,"anonymous":false}}}"# + let p = JSBridge.parseEvent(json)! + XCTAssertEqual(p.event, .identify) + XCTAssertEqual(p.data["success"] as? Bool, true) } - func testIdentifyAttrsWithAvatarURL() { - let js = JSBridge.identifyCommand(userId: "u1", email: "a@b.c", name: "A", avatarURL: "https://img.com/a.png") - XCTAssertTrue(js.contains("\"avatarURL\"")) - XCTAssertTrue(js.contains("img.com")) + func testParseOpenEventWrapper() { + let json = #"{"event":"event","data":{"type":"quackback:event","name":"open","payload":{}}}"# + XCTAssertEqual(JSBridge.parseEvent(json)?.event, .open) } - func testIdentifyAttrsWithoutName() { - let js = JSBridge.identifyCommand(userId: "u1", email: "a@b.c", name: nil, avatarURL: nil) - XCTAssertTrue(js.contains("\"id\":\"u1\"")) - XCTAssertTrue(js.contains("\"email\":\"a@b.c\"")) - XCTAssertFalse(js.contains("\"name\"")) - XCTAssertFalse(js.contains("\"avatarURL\"")) + func testParseReadyMessage() { + XCTAssertEqual(JSBridge.parseEvent(#"{"event":"ready","data":{"type":"quackback:ready"}}"#)?.event, .ready) + } + + func testParseCloseMessage() { + XCTAssertEqual(JSBridge.parseEvent(#"{"event":"close","data":{"type":"quackback:close"}}"#)?.event, .close) } - func testParseCloseEvent() { - let json = #"{"event":"close","data":{"type":"opencoven-feedback:close"}}"# + func testParseNavigateMessage() { + let json = #"{"event":"navigate","data":{"type":"quackback:navigate","url":"/boards/bugs"}}"# let p = JSBridge.parseEvent(json)! - XCTAssertEqual(p.event, .close) + XCTAssertEqual(p.event, .navigate) + XCTAssertEqual(p.data["url"] as? String, "/boards/bugs") } - func testParseSubmitEvent() { - let json = #"{"event":"submit","data":{"type":"opencoven-feedback:event","name":"submit","payload":{"postId":"post_xyz"}}}"# + func testParseIdentifyResultMessage() { + let json = #"{"event":"identify-result","data":{"type":"quackback:identify-result","success":false,"error":"TOKEN_INVALID"}}"# let p = JSBridge.parseEvent(json)! - XCTAssertEqual(p.event, .submit) - XCTAssertEqual(p.data["postId"] as? String, "post_xyz") + XCTAssertEqual(p.event, .identifyResult) + XCTAssertEqual(p.data["error"] as? String, "TOKEN_INVALID") } - func testParseNavigateEvent() { - let json = #"{"event":"navigate","data":{"type":"opencoven-feedback:navigate","payload":{"path":"/boards/bugs"}}}"# + func testParseAuthChangeMessage() { + let json = #"{"event":"auth-change","data":{"type":"quackback:auth-change","user":null}}"# + XCTAssertEqual(JSBridge.parseEvent(json)?.event, .authChange) + } + + func testParseStripsTypeFromStandaloneData() { + let json = #"{"event":"navigate","data":{"type":"quackback:navigate","url":"/x"}}"# let p = JSBridge.parseEvent(json)! - XCTAssertEqual(p.event, .navigate) - XCTAssertEqual(p.data["path"] as? String, "/boards/bugs") + XCTAssertNil(p.data["type"]) } - func testParseEmptyJSON() { + func testParseInvalid() { + XCTAssertNil(JSBridge.parseEvent("bad")) XCTAssertNil(JSBridge.parseEvent("")) XCTAssertNil(JSBridge.parseEvent("{}")) } - func testParseUnknownEventType() { - let json = #"{"event":"unknown_event","data":{}}"# + func testParseUnknownEventName() { + let json = #"{"event":"event","data":{"type":"quackback:event","name":"nope","payload":{}}}"# XCTAssertNil(JSBridge.parseEvent(json)) } - func testBridgeScriptContainsDispatch() { - XCTAssertTrue(JSBridge.bridgeScript.contains("__opencoven-feedbackNative")) - XCTAssertTrue(JSBridge.bridgeScript.contains("messageHandlers")) - XCTAssertTrue(JSBridge.bridgeScript.contains("opencoven-feedback")) + func testParseUnknownMessageType() { + XCTAssertNil(JSBridge.parseEvent(#"{"event":"made-up","data":{}}"#)) } - func testCommandsEndWithSemicolon() { - let config = OpenCovenFeedbackConfig(instanceUrl: URL(string: "https://x.com")!) - XCTAssertTrue(JSBridge.initCommand(config: config).hasSuffix(";")) - XCTAssertTrue(JSBridge.identifyCommand(ssoToken: "t").hasSuffix(";")) - XCTAssertTrue(JSBridge.identifyCommand(userId: "u", email: "e", name: nil, avatarURL: nil).hasSuffix(";")) - XCTAssertTrue(JSBridge.identifyAnonymousCommand().hasSuffix(";")) - XCTAssertTrue(JSBridge.openCommand(board: "b").hasSuffix(";")) - XCTAssertTrue(JSBridge.openCommand().hasSuffix(";")) - XCTAssertTrue(JSBridge.logoutCommand().hasSuffix(";")) - XCTAssertTrue(JSBridge.metadataCommand(["k": "v"]).hasSuffix(";")) - } + // MARK: - Bridge script - func testCommandsStartWithPostMessage() { - let config = OpenCovenFeedbackConfig(instanceUrl: URL(string: "https://x.com")!) - XCTAssertTrue(JSBridge.initCommand(config: config).contains("window.postMessage")) - XCTAssertTrue(JSBridge.identifyCommand(ssoToken: "t").contains("window.postMessage")) - XCTAssertTrue(JSBridge.identifyAnonymousCommand().contains("window.postMessage")) - XCTAssertTrue(JSBridge.openCommand().contains("window.postMessage")) - XCTAssertTrue(JSBridge.logoutCommand().contains("window.postMessage")) - XCTAssertTrue(JSBridge.metadataCommand(["k": "v"]).contains("window.postMessage")) + func testBridgeScriptUsesValidQuackbackGlobal() { + let script = JSBridge.bridgeScript + XCTAssertTrue(script.contains("__quackbackNative")) + XCTAssertTrue(script.contains("messageHandlers")) + XCTAssertFalse(script.contains("opencoven-feedback"), "bridge must use the frozen quackback wire protocol") } } diff --git a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift index 230d435..126c3d6 100644 --- a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift +++ b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift @@ -1,6 +1,23 @@ @testable import OpenCovenFeedback import XCTest +/// Thread-safe counter so it can be captured by the `@Sendable` event handler +/// without mutating captured local state. +private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { lock.lock(); value += 1; lock.unlock() } + var count: Int { lock.lock(); defer { lock.unlock() }; return value } +} + +/// Thread-safe ordered log of received events, for the same reason as `Counter`. +private final class EventLog: @unchecked Sendable { + private let lock = NSLock() + private var events: [OpenCovenFeedbackEvent] = [] + func append(_ event: OpenCovenFeedbackEvent) { lock.lock(); events.append(event); lock.unlock() } + var all: [OpenCovenFeedbackEvent] { lock.lock(); defer { lock.unlock() }; return events } +} + final class OpenCovenFeedbackEventTests: XCTestCase { func testAddAndFire() { let emitter = EventEmitter() @@ -15,41 +32,41 @@ final class OpenCovenFeedbackEventTests: XCTestCase { func testRemove() { let emitter = EventEmitter() - var count = 0 - let token = emitter.on(.submit) { _ in count += 1 } - emitter.emit(.submit, data: [:]); XCTAssertEqual(count, 1) + let counter = Counter() + let token = emitter.on(.postCreated) { _ in counter.increment() } + emitter.emit(.postCreated, data: [:]); XCTAssertEqual(counter.count, 1) emitter.off(token) - emitter.emit(.submit, data: [:]); XCTAssertEqual(count, 1) + emitter.emit(.postCreated, data: [:]); XCTAssertEqual(counter.count, 1) } func testRemoveAll() { let emitter = EventEmitter() - var count = 0 - emitter.on(.vote) { _ in count += 1 } - emitter.on(.submit) { _ in count += 1 } - emitter.emit(.vote, data: [:]); emitter.emit(.submit, data: [:]) - XCTAssertEqual(count, 2) + let counter = Counter() + emitter.on(.vote) { _ in counter.increment() } + emitter.on(.postCreated) { _ in counter.increment() } + emitter.emit(.vote, data: [:]); emitter.emit(.postCreated, data: [:]) + XCTAssertEqual(counter.count, 2) emitter.removeAll() - emitter.emit(.vote, data: [:]); XCTAssertEqual(count, 2) + emitter.emit(.vote, data: [:]); XCTAssertEqual(counter.count, 2) } func testMultipleListenersSameEvent() { let emitter = EventEmitter() - var count1 = 0, count2 = 0 - emitter.on(.vote) { _ in count1 += 1 } - emitter.on(.vote) { _ in count2 += 1 } + let first = Counter(), second = Counter() + emitter.on(.vote) { _ in first.increment() } + emitter.on(.vote) { _ in second.increment() } emitter.emit(.vote, data: [:]) - XCTAssertEqual(count1, 1) - XCTAssertEqual(count2, 1) + XCTAssertEqual(first.count, 1) + XCTAssertEqual(second.count, 1) } func testOffWithNonExistentToken() { let emitter = EventEmitter() - var count = 0 - emitter.on(.vote) { _ in count += 1 } + let counter = Counter() + emitter.on(.vote) { _ in counter.increment() } emitter.off(EventToken()) // non-existent token emitter.emit(.vote, data: [:]) - XCTAssertEqual(count, 1) // listener still fires + XCTAssertEqual(counter.count, 1) // listener still fires } func testEmitWithNoListeners() { @@ -60,30 +77,39 @@ final class OpenCovenFeedbackEventTests: XCTestCase { func testRemoveOnlyTargetListener() { let emitter = EventEmitter() - var count1 = 0, count2 = 0 - emitter.on(.vote) { _ in count1 += 1 } - let token2 = emitter.on(.vote) { _ in count2 += 1 } + let first = Counter(), second = Counter() + emitter.on(.vote) { _ in first.increment() } + let token2 = emitter.on(.vote) { _ in second.increment() } emitter.off(token2) emitter.emit(.vote, data: [:]) - XCTAssertEqual(count1, 1) - XCTAssertEqual(count2, 0) + XCTAssertEqual(first.count, 1) + XCTAssertEqual(second.count, 0) } func testAllEventTypes() { let emitter = EventEmitter() - var received: [OpenCovenFeedbackEvent] = [] - for event in [OpenCovenFeedbackEvent.ready, .vote, .submit, .close, .navigate] { - emitter.on(event) { _ in received.append(event) } + let log = EventLog() + let all: [OpenCovenFeedbackEvent] = [ + .ready, .open, .close, .postCreated, .vote, .commentCreated, + .identify, .navigate, .identifyResult, .authChange, + ] + for event in all { + emitter.on(event) { _ in log.append(event) } emitter.emit(event, data: [:]) } - XCTAssertEqual(received, [.ready, .vote, .submit, .close, .navigate]) + XCTAssertEqual(log.all, all) } - func testEventRawValues() { + func testEventRawValuesMatchContract() { XCTAssertEqual(OpenCovenFeedbackEvent.ready.rawValue, "ready") - XCTAssertEqual(OpenCovenFeedbackEvent.vote.rawValue, "vote") - XCTAssertEqual(OpenCovenFeedbackEvent.submit.rawValue, "submit") + XCTAssertEqual(OpenCovenFeedbackEvent.open.rawValue, "open") XCTAssertEqual(OpenCovenFeedbackEvent.close.rawValue, "close") + XCTAssertEqual(OpenCovenFeedbackEvent.postCreated.rawValue, "post:created") + XCTAssertEqual(OpenCovenFeedbackEvent.vote.rawValue, "vote") + XCTAssertEqual(OpenCovenFeedbackEvent.commentCreated.rawValue, "comment:created") + XCTAssertEqual(OpenCovenFeedbackEvent.identify.rawValue, "identify") XCTAssertEqual(OpenCovenFeedbackEvent.navigate.rawValue, "navigate") + XCTAssertEqual(OpenCovenFeedbackEvent.identifyResult.rawValue, "identify-result") + XCTAssertEqual(OpenCovenFeedbackEvent.authChange.rawValue, "auth-change") } } diff --git a/docs/superpowers/specs/2026-05-28-ios-sdk-conformance-native-design.md b/docs/superpowers/specs/2026-05-28-ios-sdk-conformance-native-design.md new file mode 100644 index 0000000..3e3a16e --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-ios-sdk-conformance-native-design.md @@ -0,0 +1,275 @@ +# OpenCoven Feedback iOS SDK — Conformance + Native Layer (Plan B) + +**Date:** 2026-05-28 +**Status:** Design approved, pending spec review +**Author:** Andrew Peltekci (with Claude) + +## 1. Summary + +The `feedback-mobile` repo ships an iOS SDK (`OpenCovenFeedback`) that wraps the +OpenCoven Feedback web widget in a `WKWebView` and bridges native↔web over +`postMessage`. An audit found the SDK is **functionally dead against a real +instance**: a botched "Quackback → OpenCoven" rebrand renamed the *wire +protocol*, but the web widget's protocol is frozen on the `quackback:` namespace. +Every command is sent on the wrong channel, wrapped in invalid JavaScript, and +the demo app does not compile. + +This plan restores conformance to the canonical widget contract, then adds the +native ergonomics that justify a native SDK over a raw WebView: typed Swift +events, a SwiftUI surface, hardened identity (including anonymous→identified +merge), config-driven behavior, robust WebView UX, and the contract tests + CI +that would have caught these bugs. + +The widget remains the rendering and network layer. The SDK stays a thin, +provably-correct native host. Native REST screens, push, Android, and offline +caching are explicitly out of scope (that is Plan C). + +## 2. Goals / Non-goals + +### Goals +- The SDK conforms to the canonical widget protocol and provably communicates + with a live instance. +- Public event surface is strongly typed (no `[String: Any]` at the boundary). +- Identity supports verified (JWT), unverified named, anonymous, and clear, plus + anonymous→identified session merge. +- Idiomatic SwiftUI entry point in addition to the imperative UIKit API. +- Server config (`config.json`) gates behavior (tabs, image upload, verified-only). +- Contract tests execute the bridge JS; CI builds the package *and* the app target. +- A publishable, tagged 1.0 SPM package. + +### Non-goals (YAGNI) +- Native REST-driven screens for boards/posts/comments/changelog (Plan C). +- Push notifications, offline content caching. +- Android SDK changes. +- Any change to the web `OpenCoven/feedback` repo. The contract there is the + source of truth; we conform to it, we do not modify it. + +## 3. Canonical contract (source of truth) + +Defined in `OpenCoven/feedback`. These are the facts the SDK must match. The +wire protocol stays `quackback:`-namespaced even though the product is rebranded. + +### Native bridge (`apps/web/src/lib/client/widget-bridge.ts`) +- The widget calls `window.__quackbackNative.dispatch(eventType, message)` when + present (two arguments: the event type string with the `quackback:` prefix + stripped, and the full message object). Otherwise it falls back to + `window.parent.postMessage` — which goes nowhere in a top-frame WKWebView. +- Native context is detected by the `?source=native` query param (the SDK + already sets `source=native&platform=ios`). + +### Inbound — host → widget (`WidgetInboundMessages`) +- `quackback:identify` — `{ anonymous: true } | { id, email, name?, avatarURL? } | { ssoToken } | null` +- `quackback:metadata` — `Record` (null value on a key = delete) +- `quackback:locale` — `string` +- `quackback:open` — `{ view?: 'home' | 'new-post' | 'changelog' | 'help', title?, board? } | undefined` +- There is **no** `init` message. Theme/config is delivered via `config.json` + and URL params, not a postMessage. + +### Outbound — widget → host (`WidgetOutboundMessages`) +- `quackback:ready` — handshake; flush the queued commands on receipt. +- `quackback:close` — user closed the widget. +- `quackback:navigate` — `{ url }`. +- `quackback:identify-result` — `{ success, user | null, error? }`. +- `quackback:auth-change` — `{ user: { id, name, email, avatarUrl } | null }`. +- `quackback:event` — `{ name, payload }` where `name` ∈ `WidgetEventName`. + +### Event names + payloads (`WidgetEventMap`) +- `ready` — `{}` +- `open` — `{}` +- `close` — `{}` +- `post:created` — `{ id, title, board: { id, name, slug }, statusId: string | null }` +- `vote` — `{ postId, voted, voteCount }` +- `comment:created` — `{ postId, commentId, parentId: string | null }` +- `identify` — `{ success, user: { id, name, email } | null, anonymous, error? }` + +### Identity (`routes/api/widget/identify.ts`, `lib/server/widget/identity-token.ts`) +- `ssoToken` is an **HS256 JWT** signed with the widget secret. Claims: + `{ sub|id, email, name?, avatarURL?, iat, exp }`; non-reserved claims become + user attributes. Default TTL 5 minutes. This is the verified path. +- Unverified named identify (`{ id, email, name?, avatarURL? }`) is accepted + **only** when verified-identity-only mode is off. +- `{ anonymous: true }` starts/continues an anonymous session. +- `null` clears (logout). +- `previousToken` (a prior widget session token) merges anonymous activity + (votes, comments) into the identified user. +- The SDK does **not** call `/api/widget/identify` directly; it sends + `quackback:identify` and the widget performs the network call. + +### Config (`routes/api/widget/config[.]json.ts`) +- `GET {instanceUrl}/api/widget/config.json` → + `{ enabled, theme?: { lightPrimary, lightPrimaryForeground, darkPrimary, darkPrimaryForeground, radius, themeMode }, tabs?: { feedback, changelog, help }, imageUploadsInWidget?, hmacRequired? }`. +- Colors are normalized to hex server-side for cross-client (web/iOS/Android) + consumption. The SDK already reads `theme.lightPrimary` correctly. + +## 4. Current-state gaps (what changes) + +| Concern | Contract | SDK today | Action | +|---|---|---|---| +| Message prefix | `quackback:` | `opencoven-feedback:` | restore `quackback:` | +| Native global | `window.__quackbackNative` | `window.__opencoven-feedbackNative` (invalid JS) | restore + fix JS | +| Bridge JS validity | callable `dispatch` | hyphen → SyntaxError | rewrite with valid identifiers / bracket access | +| `init` command | none | SDK sends theme via `init` | remove | +| `open` views | `home`, `new-post`, `changelog`, `help` | `home`, `new-post`, `changelog`, `help` | preserve parity | +| Events | ready, open, close, post:created, vote, comment:created, identify | ready, vote, submit, close, navigate | re-map to contract | +| `submit` | `post:created` | `submit` | rename/map | +| `navigate` | outbound `{ url }` | treated as `event` | parse as message, typed `{ url }` | +| `identify-result` / `auth-change` | outbound | unhandled | surface as events | +| `.open` event | exists | missing enum case (compile error) | add typed `open` event | +| `previousToken` merge | supported | unhandled | persist + send | +| verified-only | `hmacRequired` | ignored | gate + error event | + +What is already correct and stays: `?source=native&platform=ios`, the +`config.json` theme fetch (`lightPrimary`), `avatarURL` field naming, SPM +structure, and the security tooling (gitleaks, swiftlint, xcconfig blocking). + +## 5. Architecture + +Reorganize into focused units, each independently understandable and testable. + +- **Protocol layer** (replaces `Internal/JSBridge.swift`): the single source of + truth for the `quackback:` contract. Builds inbound command strings; parses + outbound messages into typed values. Pure, Foundation-only. This is where the + contract lives and where contract tests point. +- **Bridge layer** (`Internal/FeedbackWebView.swift` + injected JS): owns the + `WKWebView`, injects a *valid* script defining + `window.__quackbackNative.dispatch`, registers the message handler, runs the + `ready` handshake and command queue, and manages loading/offline/error states + and external-link handling. +- **Identity** (`Identity.swift` + session-token store): models ssoToken / + named / anonymous / clear, and persists the widget session token for + `previousToken` merge. +- **Events** (`OpenCovenFeedbackEvent.swift`): a typed enum with associated, + decoded payload structs. Delivered through callback tokens **and** an + `AsyncStream`. +- **Public API** (`OpenCovenFeedback.swift`): the imperative facade + (configure / identify / metadata / open / close / launcher / on / off / + destroy), preserved where already correct. +- **SwiftUI surface** (new): a `.feedbackLauncher(config:)` view modifier and a + `FeedbackButton`, wrapping the imperative API so SwiftUI hosts never touch + window plumbing. +- **Config** (`OpenCovenFeedbackConfig.swift` + a `ServerConfig` model): the + fetched `config.json` (theme, `tabs`, `imageUploadsInWidget`, `hmacRequired`) + consumed to gate behavior. + +The cross-platform `#else` (non-UIKit) stub in `OpenCovenFeedback.swift` stays so +the package builds and unit-tests on macOS hosts (and so the Foundation-only +protocol/event/identity layers are testable without a simulator). + +## 6. Detailed work by milestone + +### M1 — Foundation (conformance + safety net) +Deliverable: the SDK provably talks to a live instance, and CI prevents +regressions. + +1. **Wire protocol**: restore `quackback:` prefix across all inbound commands; + remove `init`. Update all string assertions in `JSBridgeTests` to the + `quackback:` namespace. +2. **Bridge JS**: rewrite the injected script so it is valid JavaScript — + `window.__quackbackNative = { dispatch: function(type, msg) { ... } }` and + `window.webkit.messageHandlers["quackback"].postMessage(...)` (bracket + access; no hyphenated member names). Register the handler under a valid name + (`quackback`) matching what the script posts to. +3. **Handshake**: on `quackback:ready`, flush queued commands and notify ready + (theme already arrives via `config.json`; no `init` send). +4. **Events re-map**: parse outbound `quackback:event{name,payload}`, + `quackback:close`, `quackback:navigate{url}`, `quackback:identify-result`, + `quackback:auth-change`. Replace the `submit` case with `post:created`; add + `open`, `identify`, `comment:created`. Fix the `.open` compile error in + `FeedbackApp/.../AppConfiguration.swift` and `README.md` by introducing the + real `open` event. +5. **OpenView**: keep the SDK enum aligned with `OpenOptions` (`home`, + `new-post`, `changelog`, `help`) so native callers do not lose web parity. +6. **Contract tests**: execute the bridge JS in JavaScriptCore to prove + `window.__quackbackNative.dispatch` is defined and callable, and that built + command strings parse to the expected `quackback:` shapes. Add a + parse-roundtrip test per outbound message type using fixtures shaped like + `types.ts`. +7. **CI**: GitHub Actions on a macOS runner running `swift test`, + `swiftlint --strict`, and `xcodegen generate` + `xcodebuild` of the + `FeedbackApp` target so app-target compile breaks are caught. + +### M2 — Native value +Deliverable: the SDK is worth choosing over a raw WebView. + +1. **Typed events**: each public event carries a decoded Swift struct — e.g. + `vote(VoteEvent{ postId, voted, voteCount })`, + `postCreated(PostCreatedEvent{ id, title, board, statusId })`, + `commentCreated(...)`, `identify(IdentifyEvent{ success, user, anonymous, error })`, + `navigate(URL)`. Decode centrally in the protocol layer. +2. **AsyncStream**: expose `OpenCovenFeedback.events` as an + `AsyncStream` alongside the existing `on`/`off` + callbacks. +3. **Identity hardening**: + - Surface `identify-result` and `auth-change` so hosts learn who is signed in + and why an identify failed. + - Persist the widget session token; send it as `previousToken` on the next + named identify to merge anonymous activity. + - Consume `hmacRequired` from `config.json`: if a host calls unverified named + identify while the server requires a token, emit a clear error event + instead of silently failing. +4. **Config gating**: model the full `config.json`; expose `tabs` so hosts and + the demo can avoid opening disabled surfaces; honor `imageUploadsInWidget`. + +### M3 — Polish + release +Deliverable: a shippable 1.0. + +1. **SwiftUI surface**: `.feedbackLauncher(config:)` modifier + `FeedbackButton`. +2. **WebView UX**: loading indicator, offline/load-failure state with retry, + correct safe-area, accessibility (launcher label/traits, Dynamic Type). +3. **Image upload**: verify file-input → photo-picker works in `WKWebView` with + the right Info.plist entitlements when `imageUploadsInWidget` is on. +4. **Cleanup**: collapse `Example/` and `FeedbackApp/` into one demo that + doubles as the integration harness; fix the duplicate `AppConfiguration` + (singleton vs `@StateObject`); correct the stray `quackback-android` README + link. +5. **Docs + release**: update `README.md` to the corrected API surface; tag a + 1.0.0 SPM release. + +## 7. Testing strategy + +- **Protocol unit tests**: command builders produce exact `quackback:` strings; + outbound parsers decode each message/event type from `types.ts`-shaped + fixtures into typed values. +- **Contract/bridge tests (new)**: run the injected bridge script in + JavaScriptCore; assert `window.__quackbackNative.dispatch` exists, is callable + with `(type, msg)`, and routes to the handler. This is the test class that + would have caught the invalid-JS and wrong-prefix bugs that string + `contains()` checks missed. +- **Protocol snapshot**: a checked-in list of contract message/event names + (mirroring `types.ts`) that tests assert against, so future web-contract drift + surfaces as a test failure rather than silent breakage. +- **App-target build in CI**: `xcodegen` + `xcodebuild` so `.open`-style compile + breaks in the host app cannot recur. +- **Identity precedence tests**: anonymous/named/clear/`previousToken` behavior + mirrors the web `identify-precedence.ts` rules. + +## 8. Risks & mitigations + +- **Live-instance verification needs a running widget.** Mitigation: contract + tests cover protocol correctness offline; manual verification against a + self-hosted or `localhost:3000` instance gates M1 sign-off. +- **Contract drift.** The web protocol could change. Mitigation: the protocol + snapshot test makes drift visible; the SDK pins to documented `quackback:` + shapes and treats unknown events as ignorable. +- **JavaScriptCore vs WKWebView differences.** Contract tests prove the script + parses and `dispatch` is callable, not full WebKit behavior. Mitigation: pair + with a manual WKWebView smoke test in the demo harness during M1. +- **xcodegen/Xcode availability on CI runners.** Mitigation: pin runner image + + install `xcodegen` in the workflow; the package layer still tests via + `swift test` independently. + +## 9. Open questions + +- Distribution: SPM only for 1.0, or also CocoaPods? (Assumed SPM-only.) +- Minimum iOS: stay at 15.0? (Assumed yes.) +- Should the SDK expose `tabs`/`config.json` to hosts as public API, or consume + it internally only? (Assumed internal for M2, can promote later.) + +## 10. Definition of done + +- `swift test` and the new contract tests pass; `swiftlint --strict` clean. +- CI builds the package and the app target on macOS and runs all tests. +- A manual smoke test against a live instance shows the launcher opening the + widget, identify taking effect, and at least one typed event (e.g. `vote`) + reaching the host. +- `README.md` matches the real API; a 1.0.0 tag is cut. diff --git a/project.yml b/project.yml index 4ecb987..5cb8f81 100644 --- a/project.yml +++ b/project.yml @@ -4,6 +4,7 @@ options: deploymentTarget: iOS: "15.0" xcodeVersion: "15" + projectFormat: xcode15_3 generateEmptyDirectories: true packages: @@ -56,14 +57,10 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedback INFOPLIST_FILE: FeedbackApp/Sources/FeedbackApp/Info.plist - FeedbackAppTests: - type: bundle.unit-test - platform: iOS - deploymentTarget: "15.0" - sources: - - FeedbackApp/Tests - dependencies: - - target: FeedbackApp - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedback.tests +schemes: + FeedbackApp: + build: + targets: + FeedbackApp: all + run: + config: Debug