107 lines
3.4 KiB
Swift
107 lines
3.4 KiB
Swift
import Foundation
|
|
import AppKit
|
|
|
|
/// A minimal pull-based announcements fetcher that shows one-time in-app banners.
|
|
final class AnnouncementsService {
|
|
static let shared = AnnouncementsService()
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Configuration
|
|
|
|
// Hosted via GitHub Pages for this repo
|
|
private let announcementsURL = URL(string: "https://beingpax.github.io/VoiceInk/announcements.json")!
|
|
|
|
// Fetch every 4 hours
|
|
private let refreshInterval: TimeInterval = 4 * 60 * 60
|
|
|
|
private let dismissedKey = "dismissedAnnouncementIds"
|
|
private let maxDismissedToKeep = 2
|
|
private var timer: Timer?
|
|
|
|
// MARK: - Public API
|
|
|
|
func start() {
|
|
timer?.invalidate()
|
|
timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in
|
|
self?.fetchAndMaybeShow()
|
|
}
|
|
// Do an initial fetch shortly after launch
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
|
|
self?.fetchAndMaybeShow()
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
}
|
|
|
|
// MARK: - Core Logic
|
|
|
|
private func fetchAndMaybeShow() {
|
|
let request = URLRequest(url: announcementsURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10)
|
|
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
guard let self = self else { return }
|
|
guard error == nil, let data = data else { return }
|
|
guard let announcements = try? JSONDecoder().decode([RemoteAnnouncement].self, from: data) else { return }
|
|
|
|
let now = Date()
|
|
let notDismissed = announcements.filter { !self.isDismissed($0.id) }
|
|
let valid = notDismissed.filter { $0.isActive(at: now) }
|
|
|
|
guard let next = valid.first else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
let url = next.url.flatMap { URL(string: $0) }
|
|
AnnouncementManager.shared.showAnnouncement(
|
|
title: next.title,
|
|
description: next.description,
|
|
learnMoreURL: url,
|
|
onDismiss: { self.markDismissed(next.id) }
|
|
)
|
|
}
|
|
}
|
|
task.resume()
|
|
}
|
|
|
|
private func isDismissed(_ id: String) -> Bool {
|
|
let set = UserDefaults.standard.stringArray(forKey: dismissedKey) ?? []
|
|
return set.contains(id)
|
|
}
|
|
|
|
private func markDismissed(_ id: String) {
|
|
var ids = UserDefaults.standard.stringArray(forKey: dismissedKey) ?? []
|
|
if !ids.contains(id) {
|
|
ids.append(id)
|
|
}
|
|
// Keep only the most recent N ids
|
|
if ids.count > maxDismissedToKeep {
|
|
let overflow = ids.count - maxDismissedToKeep
|
|
ids.removeFirst(overflow)
|
|
}
|
|
UserDefaults.standard.set(ids, forKey: dismissedKey)
|
|
}
|
|
}
|
|
|
|
// MARK: - Models
|
|
|
|
private struct RemoteAnnouncement: Decodable {
|
|
let id: String
|
|
let title: String
|
|
let description: String?
|
|
let url: String?
|
|
let startAt: String?
|
|
let endAt: String?
|
|
|
|
func isActive(at date: Date) -> Bool {
|
|
let formatter = ISO8601DateFormatter()
|
|
if let startAt = startAt, let start = formatter.date(from: startAt), date < start { return false }
|
|
if let endAt = endAt, let end = formatter.date(from: endAt), date > end { return false }
|
|
return true
|
|
}
|
|
|
|
}
|
|
|
|
|