mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 17:10:20 +08:00
379 lines
13 KiB
Swift
379 lines
13 KiB
Swift
import Combine
|
|
import CryptoKit
|
|
import Foundation
|
|
|
|
struct Announcement: Decodable, Identifiable, Equatable {
|
|
let id: Int64
|
|
let revision: Int64
|
|
let title: String
|
|
let body: String
|
|
let updated_at: Int64
|
|
var read = false
|
|
|
|
// Reading state belongs to this device, never to the server response.
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, revision, title, body, updated_at
|
|
}
|
|
|
|
var date: Date { Date(timeIntervalSince1970: TimeInterval(updated_at)) }
|
|
}
|
|
|
|
struct AnnouncementSnapshot: Equatable {
|
|
var items: [Announcement] = []
|
|
var total = 0
|
|
var unread = 0
|
|
var loaded = false
|
|
var loading = false
|
|
var failed = false
|
|
var readSaveFailed = false
|
|
var deleteFailed = false
|
|
}
|
|
|
|
/// Main-thread inbox using the desktop announcements_list protocol and local
|
|
/// revision semantics. Summary pages count all unread items; bodies load in 20s.
|
|
final class AnnouncementInbox: ObservableObject {
|
|
@Published private(set) var state = AnnouncementSnapshot()
|
|
var send: ((Data, String) -> Void)?
|
|
|
|
private struct Entry: Decodable {
|
|
let id: Int64
|
|
let revision: Int64
|
|
}
|
|
|
|
private struct Response: Decodable {
|
|
let request_id: String
|
|
let summary_only: Bool
|
|
let offset: Int
|
|
let total: Int
|
|
let catalog_revision: Int64
|
|
let items: [Entry]
|
|
}
|
|
|
|
private struct BodyResponse: Decodable { let items: [Announcement] }
|
|
private struct Request: Encodable {
|
|
let type = "announcements_list"
|
|
let request_id: String
|
|
let summary_only: Bool
|
|
let offset: Int
|
|
}
|
|
|
|
private struct LocalState: Codable {
|
|
let version: Int
|
|
let scope: String
|
|
let reads: [String: Int64]
|
|
let dismissed: [String: Int64]?
|
|
}
|
|
|
|
private let directory: URL
|
|
private var scope = ""
|
|
private var file: URL?
|
|
private var reads: [Int64: Int64] = [:]
|
|
private var dismissed: [Int64: Int64] = [:]
|
|
private var catalog: [Int64: Int64] = [:]
|
|
private var pendingCatalog: [Int64: Int64] = [:]
|
|
private var pageIDs: [Int: [Int64]] = [:]
|
|
private var pageBodies: [Int: [Announcement]] = [:]
|
|
private var catalogRevision: Int64 = -1
|
|
private var pendingRevision: Int64 = -1
|
|
private var pendingTotal = 0
|
|
private var pageOffset = 0
|
|
private var visibleLimit = 20
|
|
private var summary = true
|
|
private var connected = false
|
|
private var requestID: String?
|
|
private var timeout: Timer?
|
|
|
|
init(directory: URL = FileManager.default.urls(
|
|
for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("AnnouncementReads", isDirectory: true)) {
|
|
self.directory = directory
|
|
}
|
|
|
|
deinit { timeout?.invalidate() }
|
|
|
|
func configure(host: String, port: Int, deviceID: String) {
|
|
guard !host.isEmpty, port > 0, !deviceID.isEmpty else { return }
|
|
var host = host.lowercased()
|
|
if host.hasSuffix(".") { host.removeLast() }
|
|
guard let data = try? JSONSerialization.data(withJSONObject: [host, port, deviceID],
|
|
options: [.fragmentsAllowed]),
|
|
let nextScope = String(data: data, encoding: .utf8),
|
|
scope != nextScope else { return }
|
|
let wasConnected = connected
|
|
reset()
|
|
connected = wasConnected
|
|
scope = nextScope
|
|
let name = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
|
file = directory.appendingPathComponent(name + ".json")
|
|
loadLocalState()
|
|
refresh()
|
|
}
|
|
|
|
func reset() {
|
|
cancelRequest()
|
|
state = AnnouncementSnapshot()
|
|
scope = ""
|
|
file = nil
|
|
reads = [:]
|
|
dismissed = [:]
|
|
catalog = [:]
|
|
pendingCatalog = [:]
|
|
pageIDs = [:]
|
|
pageBodies = [:]
|
|
catalogRevision = -1
|
|
pendingRevision = -1
|
|
pendingTotal = 0
|
|
visibleLimit = 20
|
|
connected = false
|
|
}
|
|
|
|
func setConnected(_ value: Bool) {
|
|
connected = value
|
|
refresh()
|
|
}
|
|
|
|
func refresh() {
|
|
cancelRequest()
|
|
pendingCatalog = [:]
|
|
pendingRevision = -1
|
|
pendingTotal = 0
|
|
summary = true
|
|
request(offset: 0)
|
|
}
|
|
|
|
func loadMore() {
|
|
guard connected, !state.loading, !state.failed, catalogRevision >= 0,
|
|
visibleLimit < state.total else { return }
|
|
visibleLimit += min(20, state.total - visibleLimit)
|
|
rebuildVisibleItems()
|
|
}
|
|
|
|
func markRead(_ item: Announcement) {
|
|
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }),
|
|
reads[item.id] != item.revision else { return }
|
|
var updated = reads
|
|
updated[item.id] = item.revision
|
|
guard saveLocalState(reads: updated, dismissed: dismissed) else {
|
|
state.readSaveFailed = true
|
|
return
|
|
}
|
|
reads = updated
|
|
state.readSaveFailed = false
|
|
state.deleteFailed = false
|
|
updateReadingState()
|
|
}
|
|
|
|
func dismiss(_ item: Announcement) {
|
|
// A confirmation can outlive the displayed revision.
|
|
guard state.items.contains(where: { $0.id == item.id && $0.revision == item.revision }) else {
|
|
state.deleteFailed = true
|
|
return
|
|
}
|
|
var updated = dismissed
|
|
updated[item.id] = item.revision
|
|
guard saveLocalState(reads: reads, dismissed: updated) else {
|
|
state.deleteFailed = true
|
|
return
|
|
}
|
|
dismissed = updated
|
|
state.deleteFailed = false
|
|
state.readSaveFailed = false
|
|
if summary && state.loading {
|
|
state.items.removeAll { dismissed[$0.id] == $0.revision }
|
|
updateReadingState()
|
|
} else {
|
|
cancelRequest()
|
|
rebuildVisibleItems()
|
|
}
|
|
}
|
|
|
|
func failed(requestID: String) {
|
|
guard self.requestID == requestID else { return }
|
|
cancelRequest()
|
|
state.failed = true
|
|
}
|
|
|
|
func receive(_ data: Data) {
|
|
guard let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
|
let id = object["request_id"] as? String, id == requestID else { return }
|
|
let decoder = JSONDecoder()
|
|
guard object["error"] == nil, let response = try? decoder.decode(Response.self, from: data) else {
|
|
failed(requestID: id)
|
|
return
|
|
}
|
|
let limit = summary ? 200 : 20
|
|
guard response.summary_only == summary, response.offset >= 0,
|
|
response.total >= response.offset, response.total <= Int32.max,
|
|
response.catalog_revision >= 0,
|
|
response.items.count == min(limit, response.total - response.offset),
|
|
response.items.allSatisfy({ $0.id > 0 && $0.revision > 0 }) else {
|
|
failed(requestID: id)
|
|
return
|
|
}
|
|
let expectedRevision = summary ? pendingRevision : catalogRevision
|
|
if expectedRevision >= 0 && expectedRevision != response.catalog_revision {
|
|
refresh()
|
|
return
|
|
}
|
|
if summary {
|
|
guard response.offset == pendingCatalog.count,
|
|
pendingRevision < 0 || pendingTotal == response.total else {
|
|
failed(requestID: id)
|
|
return
|
|
}
|
|
for item in response.items {
|
|
guard pendingCatalog.updateValue(item.revision, forKey: item.id) == nil else {
|
|
failed(requestID: id)
|
|
return
|
|
}
|
|
}
|
|
pendingRevision = response.catalog_revision
|
|
pendingTotal = response.total
|
|
cancelRequest()
|
|
if pendingCatalog.count < pendingTotal {
|
|
request(offset: pendingCatalog.count)
|
|
return
|
|
}
|
|
if catalogRevision != pendingRevision {
|
|
pageIDs = [:]
|
|
pageBodies = [:]
|
|
}
|
|
catalog = pendingCatalog
|
|
catalogRevision = pendingRevision
|
|
reads = reads.filter { catalog[$0.key] != nil }
|
|
dismissed = dismissed.filter { catalog[$0.key] != nil }
|
|
} else {
|
|
guard response.offset == pageOffset, response.total == catalog.count,
|
|
Set(response.items.map(\.id)).count == response.items.count,
|
|
let bodies = try? decoder.decode(BodyResponse.self, from: data).items,
|
|
bodies.allSatisfy({ item in
|
|
catalog[item.id] == item.revision &&
|
|
(1...32_503_680_000).contains(item.updated_at) &&
|
|
item.title.utf8.count <= 240 && item.body.utf8.count <= 8000
|
|
}) else {
|
|
failed(requestID: id)
|
|
return
|
|
}
|
|
cancelRequest()
|
|
pageIDs[response.offset] = bodies.map(\.id)
|
|
pageBodies[response.offset] = bodies
|
|
}
|
|
state.failed = false
|
|
rebuildVisibleItems()
|
|
}
|
|
|
|
private func cancelRequest() {
|
|
timeout?.invalidate()
|
|
timeout = nil
|
|
requestID = nil
|
|
state.loading = false
|
|
}
|
|
|
|
private func request(offset: Int) {
|
|
guard connected, !scope.isEmpty else { return }
|
|
let id = UUID().uuidString
|
|
guard let data = try? JSONEncoder().encode(Request(
|
|
request_id: id, summary_only: summary, offset: offset)) else { return }
|
|
requestID = id
|
|
state.loading = true
|
|
state.failed = false
|
|
let timer = Timer(timeInterval: 12, repeats: false) { [weak self] _ in
|
|
self?.failed(requestID: id)
|
|
}
|
|
timeout = timer
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
send?(data, id)
|
|
}
|
|
|
|
private func updateReadingState() {
|
|
let visible = catalog.filter { dismissed[$0.key] != $0.value }
|
|
state.total = visible.count
|
|
state.unread = visible.filter { reads[$0.key] != $0.value }.count
|
|
state.items = state.items.map { item in
|
|
var item = item
|
|
item.read = reads[item.id] == item.revision
|
|
return item
|
|
}
|
|
}
|
|
|
|
private func rebuildVisibleItems() {
|
|
updateReadingState()
|
|
let wanted = min(visibleLimit, state.total)
|
|
var visible: [Announcement] = []
|
|
var retainedPages: Set<Int> = []
|
|
var position = 0
|
|
var nextOffset: Int?
|
|
summary = false
|
|
if wanted > 0 {
|
|
for offset in stride(from: 0, to: catalog.count, by: 20) {
|
|
guard let ids = pageIDs[offset] else { nextOffset = offset; break }
|
|
var selection: Set<Int64> = []
|
|
for id in ids {
|
|
guard let revision = catalog[id], dismissed[id] != revision else { continue }
|
|
if position < wanted { selection.insert(id) }
|
|
position += 1
|
|
}
|
|
if selection.isEmpty { pageBodies[offset] = nil; continue }
|
|
retainedPages.insert(offset)
|
|
guard let bodies = pageBodies[offset] else { nextOffset = offset; break }
|
|
visible += bodies.filter { selection.contains($0.id) }
|
|
if visible.count == wanted { break }
|
|
}
|
|
}
|
|
if visible.count == wanted {
|
|
pageBodies = pageBodies.filter { retainedPages.contains($0.key) }
|
|
} else {
|
|
// Retain unchanged bodies while fetching a refreshed catalog.
|
|
var retained = Set(visible.map(\.id))
|
|
for item in state.items where catalog[item.id] == item.revision &&
|
|
dismissed[item.id] != item.revision && retained.insert(item.id).inserted {
|
|
visible.append(item)
|
|
if visible.count == wanted { break }
|
|
}
|
|
}
|
|
state.items = visible
|
|
state.loaded = true
|
|
updateReadingState()
|
|
if let nextOffset {
|
|
pageOffset = nextOffset
|
|
request(offset: nextOffset)
|
|
}
|
|
}
|
|
|
|
private func loadLocalState() {
|
|
guard let file,
|
|
let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
|
size <= 16 * 1024 * 1024,
|
|
let data = try? Data(contentsOf: file),
|
|
let saved = try? JSONDecoder().decode(LocalState.self, from: data),
|
|
saved.scope == scope, [1, 2].contains(saved.version) else { return }
|
|
func revisions(_ entries: [String: Int64]) -> [Int64: Int64] {
|
|
var result: [Int64: Int64] = [:]
|
|
for (key, revision) in entries {
|
|
if let id = Int64(key), id > 0, revision > 0 { result[id] = revision }
|
|
}
|
|
return result
|
|
}
|
|
reads = revisions(saved.reads)
|
|
dismissed = revisions(saved.dismissed ?? [:])
|
|
}
|
|
|
|
private func saveLocalState(reads: [Int64: Int64], dismissed: [Int64: Int64]) -> Bool {
|
|
guard let file else { return false }
|
|
do {
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
var directory = directory
|
|
var values = URLResourceValues()
|
|
values.isExcludedFromBackup = true
|
|
try directory.setResourceValues(values)
|
|
let saved = LocalState(version: 2, scope: scope,
|
|
reads: Dictionary(uniqueKeysWithValues: reads.map { (String($0.key), $0.value) }),
|
|
dismissed: Dictionary(uniqueKeysWithValues: dismissed.map { (String($0.key), $0.value) }))
|
|
try JSONEncoder().encode(saved).write(to: file, options: .atomic)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|