// inMOLA Analytics — iOS SDK (tek dosya, bağımlılık yok). iOS 13+ / macOS 10.15+. // // Kurulum: bu dosyayı Xcode projenize sürükleyin. // AppDelegate / @main App init: InmolaAnalytics.configure(siteKey: "SITE_ANAHTARI") // Ekran: InmolaAnalytics.screen("Ürün Detay") (SwiftUI: .inmolaScreen("Ürün Detay")) // Olay: InmolaAnalytics.event("sepete_ekle", detail: "SKU-1") // Satın alma: InmolaAnalytics.purchase(transactionId: "SIP-1001", value: 1499.90, currency: "TRY", // items: [.init(id: "SKU-1", name: "Ürün", price: 1499.90, quantity: 1)]) // // Gizlilik: cihaz kimliği (IDFA/IDFV) KULLANILMAZ, cihaza hiçbir şey yazılmaz, kişisel veri gönderilmez. // App Tracking Transparency izni GEREKMEZ. Kayıtlar bellekte toplanır, ~10 sn'de bir ve uygulama arka plana geçerken gönderilir. import Foundation #if canImport(UIKit) import UIKit #endif #if canImport(SwiftUI) import SwiftUI #endif // Tüm iç durum tek bir seri kuyrukta (q) değişir → iş parçacığı güvenli (Swift 6 için @unchecked Sendable). // nonisolated: Xcode 26'nın "Default Actor Isolation = MainActor" ayarında sınıf ana iş parçacığına bağlanıp // kuyrukta çalışırken çalışma anında çöküyordu (simülatörde doğrulandı). Xcode 16.3+ gerekir. nonisolated public final class InmolaAnalytics: @unchecked Sendable { public struct Item { public let id: String, name: String, price: Double, quantity: Int public init(id: String, name: String, price: Double, quantity: Int = 1) { self.id = id; self.name = name; self.price = price; self.quantity = quantity } } static let shared = InmolaAnalytics() private let q = DispatchQueue(label: "com.inmola.analytics") private var key = "" private var endpoint = URL(string: "https://inmola-analytics.iletisim-845.workers.dev/m")! private var pending: [[String: Any]] = [] private var screenName: String? private var screenStart = Date() private var screenEntry = false private var newSession = true private var backgroundAt: Date? private var timer: DispatchSourceTimer? private var base: [String: Any] = [:] private var on = true /// Gönderim kapalı (ör. kullanıcı analitiği reddettiyse). Kapalıyken hiçbir şey toplanmaz. public static var enabled: Bool { get { shared.q.sync { shared.on } } set { shared.q.async { shared.on = newValue } } } // MARK: - Genel API /// bundleId: yalnız Bundle.main'in kimliği panele kaydedilenden farklıysa (ör. test hedefi) verin. public static func configure(siteKey: String, endpoint: String? = nil, bundleId: String? = nil) { // Ekran bilgisi ana iş parçacığında okunur; kuyruk ana iş parçacığını HİÇ beklemez (kilitlenme olmaz) let ui: UIInfo if Thread.isMainThread { ui = MainActor.assumeIsolated { UIInfo.read() } } else { ui = DispatchQueue.main.sync { UIInfo.read() } } shared.q.async { shared.setup(siteKey, endpoint, bundleId, ui) } } struct UIInfo: Sendable { let os: String, tablet: Int, width: Int @MainActor static func read() -> UIInfo { #if canImport(UIKit) && !os(watchOS) return UIInfo(os: UIDevice.current.systemVersion, tablet: UIDevice.current.userInterfaceIdiom == .pad ? 1 : 0, width: Int(UIScreen.main.bounds.width)) #else return UIInfo(os: ProcessInfo.processInfo.operatingSystemVersionString, tablet: 0, width: 0) #endif } } public static func screen(_ name: String) { let now = Date() shared.q.async { shared.showScreen(name, at: now) } } public static func event(_ name: String, detail: String? = nil) { shared.q.async { shared.add(["t": "ev", "sc": shared.screenName ?? "", "n": name, "dd": detail ?? ""]) } } public static func purchase(transactionId: String, value: Double, currency: String, items: [Item] = []) { nonisolated(unsafe) let it: [[String: Any]] = items.prefix(50).map { ["n": $0.name, "id": $0.id, "p": $0.price, "q": $0.quantity] } shared.q.async { shared.add(["t": "ev", "sc": shared.screenName ?? "", "n": "purchase", "dd": transactionId, "v": value, "cur": currency.uppercased(), "it": it]) shared.flush() } } // MARK: - İç işleyiş private func setup(_ siteKey: String, _ ep: String?, _ bid: String?, _ ui: UIInfo) { key = siteKey if let ep = ep, let u = URL(string: ep) { endpoint = u } var b: [String: Any] = ["s": key, "b": (bid ?? Bundle.main.bundleIdentifier ?? "").lowercased(), "av": (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "", "l": Locale.preferredLanguages.first ?? "", "dm": Self.model(), "p": "ios", "ov": ui.os, "tab": ui.tablet] if ui.width > 0 { b["w"] = ui.width } #if canImport(UIKit) && !os(watchOS) let nc = NotificationCenter.default nc.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in // Arka plana geçerken gönderim bitene kadar süre iste (ana iş parçacığında, kuyruğu beklemeden) let task = MainActor.assumeIsolated { UIApplication.shared.beginBackgroundTask(expirationHandler: nil) } InmolaAnalytics.shared.q.async { InmolaAnalytics.shared.toBackground(task) } } nc.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in InmolaAnalytics.shared.q.async { InmolaAnalytics.shared.toForeground() } } #endif base = b let t = DispatchSource.makeTimerSource(queue: q) t.schedule(deadline: .now() + 10, repeating: 10) t.setEventHandler { [weak self] in self?.flush() } t.resume() timer = t } private func showScreen(_ name: String, at now: Date) { guard name != screenName else { return } endScreen(next: true, at: now) screenName = name screenStart = now screenEntry = newSession add(["t": "pv", "sc": name, "ent": newSession ? 1 : 0]) newSession = false } private func endScreen(next: Bool, at now: Date) { guard let s = screenName else { return } let sec = Int(now.timeIntervalSince(screenStart)) add(["t": "en", "sc": s, "sec": min(sec, 3600), "nx": next ? 1 : 0, "ent": screenEntry ? 1 : 0]) } #if canImport(UIKit) && !os(watchOS) private func toBackground(_ task: UIBackgroundTaskIdentifier) { backgroundAt = Date() endScreen(next: false, at: Date()) flush { DispatchQueue.main.async { if task != .invalid { UIApplication.shared.endBackgroundTask(task) } } } } #endif private func toForeground() { let away = backgroundAt.map { Date().timeIntervalSince($0) } ?? 0 backgroundAt = nil guard let s = screenName else { return } // 30 dk'dan uzun arka planda kaldıysa yeni oturum; değilse aynı ekran yeniden açılmış sayılır newSession = away > 1800 screenName = nil showScreen(s, at: Date()) } private func add(_ e: [String: Any]) { guard on, !key.isEmpty else { return } pending.append(e) if pending.count >= 20 { flush() } } private func flush(_ done: (@Sendable () -> Void)? = nil) { guard !pending.isEmpty, !key.isEmpty else { done?(); return } nonisolated(unsafe) let batch = Array(pending.prefix(50)) pending.removeFirst(batch.count) var body = base body["ev"] = batch guard let data = try? JSONSerialization.data(withJSONObject: body) else { done?(); return } var r = URLRequest(url: endpoint) r.httpMethod = "POST" r.setValue("text/plain", forHTTPHeaderField: "Content-Type") r.httpBody = data r.timeoutInterval = 15 URLSession.shared.dataTask(with: r) { _, resp, err in // Ağ yoksa bir kez geri koy (bellekte en fazla 200 kayıt), sonraki turda tekrar denenir if err != nil || (resp as? HTTPURLResponse).map({ $0.statusCode >= 500 }) == true { let s = InmolaAnalytics.shared s.q.async { if s.pending.count < 200 { s.pending.insert(contentsOf: batch, at: 0) } } } done?() }.resume() } private static func model() -> String { var u = utsname() uname(&u) return withUnsafePointer(to: &u.machine) { $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) } } } /// Test için: bekleyen kayıtları hemen gönder. public static func flushNow(_ done: (@Sendable () -> Void)? = nil) { shared.q.async { shared.flush(done) } } } #if canImport(SwiftUI) @available(iOS 13.0, macOS 10.15, *) public extension View { /// Ekran açıldığında inMOLA Analytics'e bildirir: .inmolaScreen("Ana Sayfa") func inmolaScreen(_ name: String) -> some View { onAppear { InmolaAnalytics.screen(name) } } } #endif