Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
included_paths:
included:
- FeedbackApp/
- Sources/
- Tests/

excluded_paths:
excluded:
- .build/
- DerivedData/

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
Expand Down
4 changes: 2 additions & 2 deletions FeedbackApp/Sources/FeedbackApp/Config/AppConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion FeedbackApp/Sources/FeedbackApp/Views/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,9 @@ extension Color {
}

#Preview {
NavigationStack { HomeView() }
if #available(iOS 16.0, *) {
NavigationStack { HomeView() }
} else {
NavigationView { HomeView() }
}
}
8 changes: 6 additions & 2 deletions FeedbackApp/Sources/FeedbackApp/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions Sources/OpenCovenFeedback/Internal/FeedbackWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
76 changes: 50 additions & 26 deletions Sources/OpenCovenFeedback/Internal/JSBridge.swift
Original file line number Diff line number Diff line change
@@ -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 };
})();
"""
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/OpenCovenFeedback/Internal/LauncherButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 16 additions & 6 deletions Sources/OpenCovenFeedback/OpenCovenFeedback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
}

Expand Down Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
23 changes: 22 additions & 1 deletion Sources/OpenCovenFeedback/OpenCovenFeedbackEvent.swift
Original file line number Diff line number Diff line change
@@ -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() }
Expand Down
3 changes: 3 additions & 0 deletions Sources/OpenCovenFeedback/OpenView.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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"
/// 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"
}
Loading
Loading