From f0506a4e40e6cc7b7dde4ba14c6161702079a382 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Thu, 28 May 2026 02:19:58 -0700 Subject: [PATCH 1/7] fix: conform iOS SDK to the quackback: widget protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quackback→OpenCoven rebrand renamed the wire protocol, but the web widget's protocol is frozen on the `quackback:` namespace. As shipped, the SDK was functionally dead against a live instance: every command was sent on the wrong channel wrapped in invalid JavaScript, and the demo app referenced a non-existent `.open` event so it could not compile. This restores conformance to the canonical contract (lib/shared/widget/types.ts, lib/client/widget-bridge.ts): - Bridge script emits valid JS defining `window.__quackbackNative.dispatch`; message handler registered as `quackback`. - All inbound commands use the `quackback:` prefix; the dead `init` message is removed (theme comes from config.json + URL params). - `parseEvent` decodes the real dispatch format — the `quackback:event` wrapper (name/payload) plus standalone `ready`/`close`/`navigate`/ `identify-result`/`auth-change` messages. - Event enum matches the contract: ready, open, close, post:created, vote, comment:created, identify, navigate, identify-result, auth-change. - `OpenView` constrained to `home`/`new-post` per the `quackback:open` contract. Adds the safety net that would have caught these bugs: - BridgeContractTests run the bridge script in JavaScriptCore, proving the JS is valid and `dispatch` routes vote/ready/navigate/identify-result to the host end-to-end (string `contains()` checks could not). - GitHub Actions CI: swift test + swiftlint on macOS, plus xcodegen + xcodebuild of the FeedbackApp target so app-target compile breaks recur in CI. Implements milestone M1 of the design at docs/superpowers/specs/2026-05-28-ios-sdk-conformance-native-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 50 ++++ .../OpenCovenFeedbackExampleApp.swift | 2 +- .../FeedbackApp/Config/AppConfiguration.swift | 4 +- .../Sources/FeedbackApp/Views/HomeView.swift | 3 +- .../Internal/FeedbackWebView.swift | 8 +- .../OpenCovenFeedback/Internal/JSBridge.swift | 74 +++-- .../OpenCovenFeedbackEvent.swift | 23 +- Sources/OpenCovenFeedback/OpenView.swift | 4 +- .../BridgeContractTests.swift | 92 ++++++ .../JSBridgeTests.swift | 202 ++++++++----- .../OpenCovenFeedbackEventTests.swift | 26 +- ...05-28-ios-sdk-conformance-native-design.md | 275 ++++++++++++++++++ 12 files changed, 636 insertions(+), 127 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Tests/OpenCovenFeedbackTests/BridgeContractTests.swift create mode 100644 docs/superpowers/specs/2026-05-28-ios-sdk-conformance-native-design.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5230447 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +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 + + app-build: + name: Build iOS app target + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Build FeedbackApp (simulator) + run: | + xcodebuild build \ + -project FeedbackApp.xcodeproj \ + -scheme FeedbackApp \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO 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..853c887 100644 --- a/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift +++ b/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift @@ -63,7 +63,8 @@ struct HomeView: View { icon: "newspaper.fill", color: .secondary ) { - OpenCovenFeedback.open(view: .changelog) + // Changelog is a tab inside the widget — open the widget to reach it. + OpenCovenFeedback.open() } } } 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..80c47df 100644 --- a/Sources/OpenCovenFeedback/Internal/JSBridge.swift +++ b/Sources/OpenCovenFeedback/Internal/JSBridge.swift @@ -1,73 +1,95 @@ 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)'},'*');" + "window.postMessage({type:'quackback:locale',data:'\(locale)'},'*');" } 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/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..f58cf31 100644 --- a/Sources/OpenCovenFeedback/OpenView.swift +++ b/Sources/OpenCovenFeedback/OpenView.swift @@ -1,11 +1,11 @@ import Foundation /// A specific view the widget can open to, passed to `OpenCovenFeedback.open(view:...)`. +/// Matches the `quackback:open` contract (`home` | `new-post`); other surfaces +/// (changelog, help) are reached as tabs once the widget is open. public enum OpenView: String, Sendable { /// Home — boards/feed list. case home = "home" /// New-post form — pre-fill `title` and/or `board` to prime the submission. case newPost = "new-post" - /// Changelog feed. - case changelog = "changelog" } 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..73a03e9 100644 --- a/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift +++ b/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift @@ -2,153 +2,195 @@ 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 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 testParseAuthChangeMessage() { + let json = #"{"event":"auth-change","data":{"type":"quackback:auth-change","user":null}}"# + XCTAssertEqual(JSBridge.parseEvent(json)?.event, .authChange) } - func testParseNavigateEvent() { - let json = #"{"event":"navigate","data":{"type":"opencoven-feedback:navigate","payload":{"path":"/boards/bugs"}}}"# + 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..32f07ec 100644 --- a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift +++ b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift @@ -16,18 +16,18 @@ 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 token = emitter.on(.postCreated) { _ in count += 1 } + emitter.emit(.postCreated, data: [:]); XCTAssertEqual(count, 1) emitter.off(token) - emitter.emit(.submit, data: [:]); XCTAssertEqual(count, 1) + emitter.emit(.postCreated, data: [:]); XCTAssertEqual(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: [:]) + emitter.on(.postCreated) { _ in count += 1 } + emitter.emit(.vote, data: [:]); emitter.emit(.postCreated, data: [:]) XCTAssertEqual(count, 2) emitter.removeAll() emitter.emit(.vote, data: [:]); XCTAssertEqual(count, 2) @@ -72,18 +72,24 @@ final class OpenCovenFeedbackEventTests: XCTestCase { func testAllEventTypes() { let emitter = EventEmitter() var received: [OpenCovenFeedbackEvent] = [] - for event in [OpenCovenFeedbackEvent.ready, .vote, .submit, .close, .navigate] { + let all: [OpenCovenFeedbackEvent] = [.ready, .open, .close, .postCreated, .vote, .commentCreated, .identify, .navigate, .identifyResult, .authChange] + for event in all { emitter.on(event) { _ in received.append(event) } emitter.emit(event, data: [:]) } - XCTAssertEqual(received, [.ready, .vote, .submit, .close, .navigate]) + XCTAssertEqual(received, 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..f26f8f9 --- /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', 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` | adds `changelog` | drop `changelog` as a view; gate tabs | +| 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**: constrain to `home` / `new-post`; update `HomeView.swift`'s + `.changelog` open call (changelog is reached as a tab, not an open-view). +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. From 3bc57ce1c3bbe1176924004f58fb9e9c3e2218c8 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Thu, 28 May 2026 02:33:52 -0700 Subject: [PATCH 2/7] fix(ci): green up the pipeline First CI run surfaced four issues, none reproducible on the newer local toolchain: - Event tests captured and mutated a local `var` inside the `@Sendable` event handler, which the runner's compiler rejects ("mutation of captured var in concurrently-executing code"). Replaced with thread-safe, Sendable `Counter`/`EventLog` holders captured by `let`. - project.yml declared a `FeedbackAppTests` target pointing at a directory that never existed, failing xcodegen validation. Removed the dead target. - xcodegen generated no shared scheme, so `xcodebuild -scheme FeedbackApp` could not resolve. Added an explicit `FeedbackApp` scheme. - .swiftlint.yml used invalid keys `included_paths`/`excluded_paths` (correct keys are `included`/`excluded`), so the `.build/` exclusion never applied and `--strict` linted SwiftPM-generated files. Fixed the keys. Verified locally: swift test (52 pass), xcodegen generate, scheme resolution, and swiftlint --strict (0 violations, 17 files). Co-Authored-By: Claude Opus 4.7 (1M context) --- .swiftlint.yml | 4 +- .../OpenCovenFeedbackEventTests.swift | 67 ++++++++++++------- project.yml | 18 ++--- 3 files changed, 51 insertions(+), 38 deletions(-) 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/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift index 32f07ec..e79c86a 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(.postCreated) { _ in count += 1 } - emitter.emit(.postCreated, 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(.postCreated, 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(.postCreated) { _ in count += 1 } + let counter = Counter() + emitter.on(.vote) { _ in counter.increment() } + emitter.on(.postCreated) { _ in counter.increment() } emitter.emit(.vote, data: [:]); emitter.emit(.postCreated, data: [:]) - XCTAssertEqual(count, 2) + 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,24 +77,24 @@ 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] = [] + let log = EventLog() let all: [OpenCovenFeedbackEvent] = [.ready, .open, .close, .postCreated, .vote, .commentCreated, .identify, .navigate, .identifyResult, .authChange] for event in all { - emitter.on(event) { _ in received.append(event) } + emitter.on(event) { _ in log.append(event) } emitter.emit(event, data: [:]) } - XCTAssertEqual(received, all) + XCTAssertEqual(log.all, all) } func testEventRawValuesMatchContract() { diff --git a/project.yml b/project.yml index 4ecb987..81dee61 100644 --- a/project.yml +++ b/project.yml @@ -56,14 +56,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 From e80b3d92447816f6123594213028c411d9af4f1f Mon Sep 17 00:00:00 2001 From: apeltekci Date: Thu, 28 May 2026 02:37:59 -0700 Subject: [PATCH 3/7] fix(ci): build SDK for iOS via SPM instead of XcodeGen XcodeGen 2.45.4 emits objectVersion 77, which the runner's Xcode 15.4 cannot read ("future Xcode project file format"). Rather than couple CI to a specific XcodeGen/Xcode pairing, build the package directly with xcodebuild for the iOS Simulator. This compiles the `#if canImport(UIKit)` path (WebView, launcher, panel) that `swift test` on macOS skips, using the runner's own project format. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5230447..501137b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,23 +28,22 @@ jobs: - name: SwiftLint (strict) run: swiftlint lint --strict --config .swiftlint.yml - app-build: - name: Build iOS app target + ios-build: + name: Build SDK for iOS runs-on: macos-14 steps: - uses: actions/checkout@v4 - - name: Install XcodeGen - run: brew install xcodegen + - name: Show Xcode + run: xcodebuild -version - - name: Generate Xcode project - run: xcodegen generate - - - name: Build FeedbackApp (simulator) + # Builds the package for the iOS Simulator SDK, compiling the + # `#if canImport(UIKit)` path (WebView, launcher, panel) that `swift test` + # on macOS does not. xcodebuild generates the package project in the + # runner's own compatible format — no XcodeGen version coupling. + - name: Build OpenCovenFeedback (iOS Simulator) run: | xcodebuild build \ - -project FeedbackApp.xcodeproj \ - -scheme FeedbackApp \ - -sdk iphonesimulator \ + -scheme OpenCovenFeedback \ -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO From 51d1bcfe088a3e01ef9dcbaac8a799feb0b8ee72 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Thu, 28 May 2026 02:40:52 -0700 Subject: [PATCH 4/7] fix: launcher tap handling (@objc cannot live on the enum namespace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OpenCovenFeedback` is a caseless enum used as a namespace, so its `@objc launcherTapped` + `#selector` + `addTarget(self,...)` could never compile for iOS — `@objc`/`#selector` require a class. This latent break was invisible because there was no iOS CI and `swift test` on macOS skips the `#if canImport(UIKit)` path; the new iOS build job surfaced it. Move the tap target onto `LauncherButton` (a UIButton subclass) via an `onTap` closure; the enum sets the closure instead of acting as the target. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/OpenCovenFeedback/Internal/LauncherButton.swift | 8 ++++++++ Sources/OpenCovenFeedback/OpenCovenFeedback.swift | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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..dcac37d 100644 --- a/Sources/OpenCovenFeedback/OpenCovenFeedback.swift +++ b/Sources/OpenCovenFeedback/OpenCovenFeedback.swift @@ -50,7 +50,7 @@ public enum OpenCovenFeedback { 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 } @@ -107,7 +107,7 @@ public enum OpenCovenFeedback { private static func dismissPanel() { panel?.dismiss(animated: true); panel = nil; isShowing = false; launcher?.setOpen(false) } - @objc private static func launcherTapped() { if isShowing { close() } else { open() } } + 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 } From 72a330fd62eedc7ff56593f4937ee9aeb62bd383 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:51:13 -0500 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/OpenCovenFeedback/Internal/JSBridge.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/OpenCovenFeedback/Internal/JSBridge.swift b/Sources/OpenCovenFeedback/Internal/JSBridge.swift index 80c47df..50fba96 100644 --- a/Sources/OpenCovenFeedback/Internal/JSBridge.swift +++ b/Sources/OpenCovenFeedback/Internal/JSBridge.swift @@ -12,7 +12,9 @@ enum JSBridge { // MARK: - Inbound commands (host -> widget) static func localeCommand(_ locale: String) -> String { - "window.postMessage({type:'quackback: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 { From 6fd7c0cf60fd75f18f8109eba456698fb621e0cc Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:51:48 -0500 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 501137b..d37e611 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,13 +37,15 @@ jobs: - name: Show Xcode run: xcodebuild -version - # Builds the package for the iOS Simulator SDK, compiling the - # `#if canImport(UIKit)` path (WebView, launcher, panel) that `swift test` - # on macOS does not. xcodebuild generates the package project in the - # runner's own compatible format — no XcodeGen version coupling. - - name: Build OpenCovenFeedback (iOS Simulator) + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate FeedbackApp project + run: xcodegen generate + + - name: Build FeedbackApp (iOS Simulator) run: | xcodebuild build \ - -scheme OpenCovenFeedback \ + -scheme FeedbackApp \ -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO From a138a5d349629372ce8ca06708bab78a3864ae44 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 29 May 2026 14:15:41 -0500 Subject: [PATCH 7/7] fix: address iOS SDK PR review findings --- .../Sources/FeedbackApp/Views/HomeView.swift | 9 ++++++--- .../Sources/FeedbackApp/Views/RootView.swift | 8 ++++++-- .../OpenCovenFeedback/OpenCovenFeedback.swift | 18 ++++++++++++++---- Sources/OpenCovenFeedback/OpenView.swift | 7 +++++-- .../OpenCovenFeedbackTests/JSBridgeTests.swift | 5 +++++ .../OpenCovenFeedbackEventTests.swift | 5 ++++- ...-05-28-ios-sdk-conformance-native-design.md | 8 ++++---- project.yml | 1 + 8 files changed, 45 insertions(+), 16 deletions(-) diff --git a/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift b/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift index 853c887..fe20e5a 100644 --- a/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift +++ b/FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift @@ -63,8 +63,7 @@ struct HomeView: View { icon: "newspaper.fill", color: .secondary ) { - // Changelog is a tab inside the widget — open the widget to reach it. - OpenCovenFeedback.open() + OpenCovenFeedback.open(view: .changelog) } } } @@ -130,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/OpenCovenFeedback.swift b/Sources/OpenCovenFeedback/OpenCovenFeedback.swift index dcac37d..898a7c3 100644 --- a/Sources/OpenCovenFeedback/OpenCovenFeedback.swift +++ b/Sources/OpenCovenFeedback/OpenCovenFeedback.swift @@ -43,8 +43,9 @@ 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 } @@ -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,8 +105,17 @@ 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: [:]) } + } + 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() } } @@ -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/OpenView.swift b/Sources/OpenCovenFeedback/OpenView.swift index f58cf31..8ecff46 100644 --- a/Sources/OpenCovenFeedback/OpenView.swift +++ b/Sources/OpenCovenFeedback/OpenView.swift @@ -1,11 +1,14 @@ import Foundation /// A specific view the widget can open to, passed to `OpenCovenFeedback.open(view:...)`. -/// Matches the `quackback:open` contract (`home` | `new-post`); other surfaces -/// (changelog, help) are reached as tabs once the widget is open. +/// Raw values match the canonical `quackback:open` contract. public enum OpenView: String, Sendable { /// Home — boards/feed list. case home = "home" /// New-post form — pre-fill `title` and/or `board` to prime the submission. case newPost = "new-post" + /// Changelog feed. + case changelog = "changelog" + /// Help center. + case help = "help" } diff --git a/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift b/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift index 73a03e9..ea63b88 100644 --- a/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift +++ b/Tests/OpenCovenFeedbackTests/JSBridgeTests.swift @@ -66,6 +66,11 @@ final class JSBridgeTests: XCTestCase { 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:'quackback:open'},'*');") } diff --git a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift index e79c86a..126c3d6 100644 --- a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift +++ b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift @@ -89,7 +89,10 @@ final class OpenCovenFeedbackEventTests: XCTestCase { func testAllEventTypes() { let emitter = EventEmitter() let log = EventLog() - let all: [OpenCovenFeedbackEvent] = [.ready, .open, .close, .postCreated, .vote, .commentCreated, .identify, .navigate, .identifyResult, .authChange] + 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: [:]) 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 index f26f8f9..3e3a16e 100644 --- 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 @@ -61,7 +61,7 @@ wire protocol stays `quackback:`-namespaced even though the product is rebranded - `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', title?, board? } | undefined` +- `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. @@ -109,7 +109,7 @@ wire protocol stays `quackback:`-namespaced even though the product is rebranded | 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` | adds `changelog` | drop `changelog` as a view; gate tabs | +| `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 }` | @@ -177,8 +177,8 @@ regressions. `open`, `identify`, `comment:created`. Fix the `.open` compile error in `FeedbackApp/.../AppConfiguration.swift` and `README.md` by introducing the real `open` event. -5. **OpenView**: constrain to `home` / `new-post`; update `HomeView.swift`'s - `.changelog` open call (changelog is reached as a tab, not an open-view). +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 diff --git a/project.yml b/project.yml index 81dee61..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: