From aa01e820167973b042df7b65905b3b82740d7828 Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 20:29:11 +0200 Subject: [PATCH 01/10] Use structured operatingSystemVersion for macOS user agent ProcessInfo.operatingSystemVersionString is localized and human-readable ("Version 14.5 (Build 23F79)"), so splitting on spaces and taking index 1 breaks on non-English locales. Build the OS version token from operatingSystemVersion's major/minor/patch components instead. Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index ecd90d0..9b9ae8d 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -72,12 +72,10 @@ internal class DeviceInfo { #endif private static func getMacOSUserAgent() -> String { - let processInfo = ProcessInfo.processInfo - let osVersion = processInfo.operatingSystemVersionString - let versionParts = osVersion.components(separatedBy: " ") - let version = versionParts.count > 1 ? versionParts[1] : "Unknown" - - let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X \(version.replacingOccurrences(of: ".", with: "_"))) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15" + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let version = "\(osVersion.majorVersion)_\(osVersion.minorVersion)_\(osVersion.patchVersion)" + + let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X \(version)) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15" return userAgent + " OpenPanel/\(OpenPanel.sdkVersion)" } From 83519cf5cec3e246cf705b15333b9259abe4b6f8 Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 20:29:22 +0200 Subject: [PATCH 02/10] Align sdkVersion with release version sdkVersion still reported 0.0.1 while the released tag is 1.0.0. This string is sent in the openpanel-sdk-version header and embedded in every user agent, so bump it to the next release version, 1.0.1. Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index 9b9ae8d..a3b455c 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -332,7 +332,7 @@ public class OpenPanel { private var options: Options? public static var sdkVersion: String { - return "0.0.1" + return "1.0.1" } private init() { From 1934db438955f39fd5dc4c2e5a66458c04d66cbe Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 20:29:37 +0200 Subject: [PATCH 03/10] Fix WKWebView user agent fallback never triggering The OpenPanel/ suffix was appended before the isEmpty check, so when the WKWebView JS evaluation failed or timed out the user agent became " OpenPanel/x.y.z" instead of falling back to getBasicUserAgent(). Check for the empty result first, then append the suffix (getBasicUserAgent already appends it itself). Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index a3b455c..75a6726 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -43,13 +43,11 @@ internal class DeviceInfo { _ = semaphore.wait(timeout: .now() + 1.0) - userAgent += " OpenPanel/\(OpenPanel.sdkVersion)" - if userAgent.isEmpty { - userAgent = getBasicUserAgent() + return getBasicUserAgent() } - - return userAgent + + return userAgent + " OpenPanel/\(OpenPanel.sdkVersion)" } else { return getBasicUserAgent() } From d86d00d3181f1998c6b5a21e414351e930fe4c23 Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 20:30:03 +0200 Subject: [PATCH 04/10] Unwrap AnyCodable explicitly when merging identify properties (new as AnyObject).value resolved through AnyObject dynamic lookup, producing an Any?? that was implicitly coerced to Any (compiler warning) and left the merged value double-wrapped. Cast to AnyCodable and unwrap its underlying value instead. Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index 75a6726..e70645a 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -453,7 +453,7 @@ public class OpenPanel { if let global = shared._global { var mergedProperties = global if let payloadProperties = payload.properties { - mergedProperties.merge(payloadProperties) { (_, new) in (new as AnyObject).value } + mergedProperties.merge(payloadProperties) { (_, new) in (new as? AnyCodable)?.value ?? new } } updatedPayload.properties = mergedProperties.mapValues { AnyCodable($0) } } From f3aae89faad2d621cce7169eea5a0d142e13fa59 Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 20:30:19 +0200 Subject: [PATCH 05/10] Synchronize access to the pending event queue send() appended to queue and flush() read/cleared it from arbitrary threads with no synchronization. Route both through the existing globalQueue barrier pattern: barrier-append in send(), atomic snapshot-and-clear in flush(). Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index e70645a..47b3f54 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -383,7 +383,9 @@ public class OpenPanel { } if options.waitForProfile == true, profileId == nil { - queue.append(payload) + globalQueue.async(flags: .barrier) { + self.queue.append(payload) + } return } @@ -482,8 +484,11 @@ public class OpenPanel { } public func flush() { - let currentQueue = queue - queue.removeAll() + let currentQueue = globalQueue.sync(flags: .barrier) { () -> [TrackHandlerPayload] in + let items = queue + queue.removeAll() + return items + } for item in currentQueue { send(item) } From 85ce8dda47739b764de401753c6ed5d65e43f19f Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 20:31:21 +0200 Subject: [PATCH 06/10] Declare tvOS support in Package.swift The source already guards automatic tracking with #if os(iOS) || os(tvOS), but the platform was never declared and UIKit was only imported for iOS. Add .tvOS(.v13) and import UIKit on tvOS so the declared platforms match the code (WebKit is unavailable on tvOS, so the generic user agent path is used there). Co-Authored-By: Claude Fable 5 --- Package.swift | 3 ++- Sources/OpenPanel/OpenPanel.swift | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8c1f4a1..b54da5f 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,8 @@ let package = Package( name: "OpenPanel", platforms: [ .iOS(.v13), - .macOS(.v10_15) + .macOS(.v10_15), + .tvOS(.v13) ], products: [ .library( diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index 47b3f54..57a0818 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -2,6 +2,8 @@ import Foundation #if os(iOS) import UIKit import WebKit +#elseif os(tvOS) +import UIKit #elseif os(macOS) import AppKit import WebKit From ed1045ee6cfe18c421853eee2df2ef426597012d Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 21:46:15 +0200 Subject: [PATCH 07/10] Replace fabricated user agents with a structured OpenPanel UA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK previously impersonated Safari with hand-built Mozilla strings and, on iOS, spun up a WKWebView and blocked on a semaphore to read navigator.userAgent — a guaranteed 1s main-thread stall when initialized on the main thread. The backend's UA parser has a sanctioned format for app traffic (Model=; Manufacturer=), which sets device brand/model and prevents the request from being classified as server traffic. Emit OpenPanel/ (Model=; Manufacturer=Apple) on every platform, using sysctl hw.model on macOS and utsname machine elsewhere (with the SIMULATOR_MODEL_IDENTIFIER override under the simulator). This removes the WebKit dependency entirely. Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 97 ++++++++----------------------- 1 file changed, 25 insertions(+), 72 deletions(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index 57a0818..8e5eae7 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -1,88 +1,41 @@ import Foundation -#if os(iOS) -import UIKit -import WebKit -#elseif os(tvOS) +#if os(iOS) || os(tvOS) import UIKit #elseif os(macOS) import AppKit -import WebKit #endif // MARK: - DeviceInfo internal class DeviceInfo { - static func getUserAgent() -> String { - #if os(iOS) - return getiOSUserAgent() - #elseif os(macOS) - return getMacOSUserAgent() + /// Hardware model identifier, e.g. "iPhone15,2", "MacBookPro18,3", "AppleTV14,1". + static var hardwareModel: String { + // Simulators report the simulator's own hardware; the simulated device is in the environment. + if let simulatorModel = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + return simulatorModel + } + #if os(macOS) + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + guard size > 0 else { return "Mac" } + var buffer = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &buffer, &size, nil, 0) + return String(cString: buffer) #else - return getGenericUserAgent() - #endif - } - - private static func isRunningInExtension() -> Bool { - return Bundle.main.bundlePath.hasSuffix(".appex") - } - - #if os(iOS) - private static func getiOSUserAgent() -> String { - if !isRunningInExtension() { - let webView = WKWebView(frame: .zero) - var userAgent = "" - - let semaphore = DispatchSemaphore(value: 0) - - DispatchQueue.main.async { - webView.evaluateJavaScript("navigator.userAgent") { (result, error) in - if let agent = result as? String { - userAgent = agent - } - semaphore.signal() - } - } - - _ = semaphore.wait(timeout: .now() + 1.0) - - if userAgent.isEmpty { - return getBasicUserAgent() - } - - return userAgent + " OpenPanel/\(OpenPanel.sdkVersion)" - } else { - return getBasicUserAgent() + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { buffer -> String in + guard let base = buffer.baseAddress else { return "" } + return String(cString: base.assumingMemoryBound(to: CChar.self)) } + return machine.isEmpty ? "Apple" : machine + #endif } - private static func getBasicUserAgent() -> String { - let device = UIDevice.current - let systemVersion = device.systemVersion - let model = device.model - let systemName = device.systemName - - // Construct a user agent string manually with more detailed information - var userAgent = "Mozilla/5.0 (\(model); \(systemName) \(systemVersion.replacingOccurrences(of: ".", with: "_")); like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/\(systemVersion)" - - // Append your custom string if necessary - userAgent += " OpenPanel/\(OpenPanel.sdkVersion)" - - return userAgent - } - #endif - - private static func getMacOSUserAgent() -> String { - let osVersion = ProcessInfo.processInfo.operatingSystemVersion - let version = "\(osVersion.majorVersion)_\(osVersion.minorVersion)_\(osVersion.patchVersion)" - - let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X \(version)) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15" - - return userAgent + " OpenPanel/\(OpenPanel.sdkVersion)" - } - - private static func getGenericUserAgent() -> String { - let osName = ProcessInfo.processInfo.operatingSystemVersionString - return "OpenPanel/\(OpenPanel.sdkVersion) (\(osName))" + static func getUserAgent() -> String { + // The Model=/Manufacturer= form is recognized by the OpenPanel backend as + // app traffic, so it is not classified as a server/bot request. + return "OpenPanel/\(OpenPanel.sdkVersion) (Model=\(hardwareModel); Manufacturer=Apple)" } } From 8e44f3c0ff1724feff2d31642a8c18268f22118a Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 21:46:55 +0200 Subject: [PATCH 08/10] Send structured device properties with every event Merge __os, __osVersion, __device, __brand, and __model into global properties at initialize(). The backend prefers these property overrides to user-agent parsing, and the values follow the ua-parser-js v2 vocabulary (macOS/iOS/tvOS/watchOS; mobile/tablet/desktop/smarttv/wearable) so app traffic lands in the same dashboard buckets as web traffic. Device defaults are seeded beneath user-set globals so setGlobalProperties always wins, and clear() re-seeds them instead of wiping them with the user state. Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 62 ++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index 8e5eae7..4c16b83 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -37,6 +37,51 @@ internal class DeviceInfo { // app traffic, so it is not classified as a server/bot request. return "OpenPanel/\(OpenPanel.sdkVersion) (Model=\(hardwareModel); Manufacturer=Apple)" } + + static var osName: String { + // Names must match ua-parser-js v2 vocabulary so app traffic shares + // dashboard buckets with web traffic ("macOS", not "Mac OS"). + #if os(iOS) + return "iOS" + #elseif os(tvOS) + return "tvOS" + #elseif os(watchOS) + return "watchOS" + #elseif os(macOS) + return "macOS" + #else + return "Unknown" + #endif + } + + static var osVersion: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + static var deviceType: String { + // Must match ua-parser-js v2 device types. + #if os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad ? "tablet" : "mobile" + #elseif os(tvOS) + return "smarttv" + #elseif os(watchOS) + return "wearable" + #else + return "desktop" + #endif + } + + /// Property overrides the backend prefers over user-agent parsing. + static func deviceProperties() -> [String: Any] { + return [ + "__os": osName, + "__osVersion": osVersion, + "__device": deviceType, + "__brand": "Apple", + "__model": hardwareModel, + ] + } } // MARK: - Payload Types @@ -296,7 +341,18 @@ public class OpenPanel { public static func initialize(options: Options) { shared.options = options - + + let deviceDefaults = DeviceInfo.deviceProperties() + shared.globalQueue.async(flags: .barrier) { + // Device defaults sit under any user-set globals so explicit + // setGlobalProperties values always win. + var merged = deviceDefaults + if let existing = shared._global { + merged.merge(existing) { _, user in user } + } + shared._global = merged + } + var defaultHeaders: [String: String] = [ "openpanel-client-id": options.clientId, "openpanel-sdk-name": "swift", @@ -433,8 +489,10 @@ public class OpenPanel { public static func clear() { shared.profileId = nil + // Wipe user-set globals but keep the device defaults seeded at initialize. + let deviceDefaults = shared.options != nil ? DeviceInfo.deviceProperties() : nil shared.globalQueue.async(flags: .barrier) { - shared._global = nil + shared._global = deviceDefaults } } From 627498a2193dc6ed619e1f3d76d8f3b9e2e71c06 Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 21:47:38 +0200 Subject: [PATCH 09/10] Add watchOS support The README has always advertised watchOS, but the package never declared the platform and the source had no watch code paths. Declare watchOS 7 (needed for the WKExtension lifecycle notifications), import WatchKit, and register app_opened/app_closed observers on applicationDidBecomeActive and applicationDidEnterBackground. Co-Authored-By: Claude Fable 5 --- Package.swift | 3 ++- README.md | 2 +- Sources/OpenPanel/OpenPanel.swift | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index b54da5f..bd1c52b 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,8 @@ let package = Package( platforms: [ .iOS(.v13), .macOS(.v10_15), - .tvOS(.v13) + .tvOS(.v13), + .watchOS(.v7) ], products: [ .library( diff --git a/README.md b/README.md index ed0636a..8bfb0e7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The OpenPanel Swift SDK allows you to integrate OpenPanel analytics into your iO ## Requirements -- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 6.0+ +- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ - Xcode 12.0+ - Swift 5.3+ diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index 4c16b83..2d0c35a 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -3,6 +3,8 @@ import Foundation import UIKit #elseif os(macOS) import AppKit +#elseif os(watchOS) +import WatchKit #endif // MARK: - DeviceInfo @@ -539,6 +541,19 @@ public class OpenPanel { name: NSApplication.willTerminateNotification, object: nil ) + #elseif os(watchOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: WKExtension.applicationDidBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidEnterBackground), + name: WKExtension.applicationDidEnterBackgroundNotification, + object: nil + ) #endif } @@ -566,6 +581,14 @@ public class OpenPanel { @objc private func appWillTerminate() { OpenPanel.track(name: "app_closed") } + #elseif os(watchOS) + @objc private func appDidBecomeActive() { + OpenPanel.track(name: "app_opened") + } + + @objc private func appDidEnterBackground() { + OpenPanel.track(name: "app_closed") + } #endif private func logError(_ message: String) { From 6fdbea94db128d34620b7bb507e31923eca832c9 Mon Sep 17 00:00:00 2001 From: Tomas Srna Date: Sun, 19 Jul 2026 21:48:02 +0200 Subject: [PATCH 10/10] Serialize event delivery with a task chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OperationQueue with maxConcurrentOperationCount = 1 never serialized anything: each BlockOperation only spawned an unawaited Task and finished immediately, so requests raced and could arrive out of order (a track overtaking the identify before it). Chain sends instead — each Task awaits the previous one before hitting the network, with the chain tail guarded by the same barrier queue as the rest of the shared state. The profile id is now also resolved at track time rather than at network time. Co-Authored-By: Claude Fable 5 --- Sources/OpenPanel/OpenPanel.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/OpenPanel/OpenPanel.swift b/Sources/OpenPanel/OpenPanel.swift index 2d0c35a..b15298e 100644 --- a/Sources/OpenPanel/OpenPanel.swift +++ b/Sources/OpenPanel/OpenPanel.swift @@ -307,7 +307,9 @@ public class OpenPanel { set { globalQueue.async(flags: .barrier) { self._global = newValue } } } private var queue: [TrackHandlerPayload] = [] - private let operationQueue: OperationQueue + // Tail of the in-flight send chain; each new send awaits the previous one + // so events reach the server in the order they were tracked. + private var sendTask: Task? public struct Options { public let clientId: String @@ -337,8 +339,6 @@ public class OpenPanel { private init() { self.api = Api(config: Api.Config(baseUrl: "https://api.openpanel.dev")) - self.operationQueue = OperationQueue() - self.operationQueue.maxConcurrentOperationCount = 1 } public static func initialize(options: Options) { @@ -402,9 +402,11 @@ public class OpenPanel { return } - let operation = BlockOperation { - Task { - let updatedPayload = self.ensureProfileId(payload) + let updatedPayload = ensureProfileId(payload) + globalQueue.async(flags: .barrier) { + let previous = self.sendTask + self.sendTask = Task { + await previous?.value let result = await self.api.fetch(path: "/track", data: updatedPayload) switch result { case .success: @@ -414,7 +416,6 @@ public class OpenPanel { } } } - operationQueue.addOperation(operation) } private func ensureProfileId(_ payload: TrackHandlerPayload) -> TrackHandlerPayload {