mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:05:20 +00:00
107 lines
3.8 KiB
Swift
107 lines
3.8 KiB
Swift
//
|
|
// NostrProcessedEventStore.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
/// Disk persistence for processed private-envelope event IDs. BitChat
|
|
/// randomizes envelope timestamps, so DM subscriptions must look back
|
|
/// generously (24h) and relays redeliver the same events on every launch —
|
|
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
|
|
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
|
///
|
|
/// Contents are event IDs already visible to every relay, so
|
|
/// until-first-unlock file protection is the right at-rest posture — the
|
|
/// file must also load during a locked-background restoration relaunch.
|
|
/// Wiped on panic via the dedup service's clear paths.
|
|
final class NostrProcessedEventStore {
|
|
private let fileURL: URL?
|
|
// All file access is serialized here: appends are read-modify-write, and
|
|
// overlapping debounced flushes would otherwise race and drop IDs.
|
|
private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility)
|
|
|
|
init(fileURL: URL? = nil) {
|
|
self.fileURL = fileURL ?? Self.defaultFileURL()
|
|
}
|
|
|
|
/// Processed event IDs, oldest first (insertion order).
|
|
func load() -> [String] {
|
|
ioQueue.sync { loadLocked() }
|
|
}
|
|
|
|
/// Merge new IDs onto the persisted record, oldest-first, trimming from
|
|
/// the front past `cap`. Append-merge (not snapshot-overwrite) so the
|
|
/// in-memory cache being cleared transiently (channel switches) can
|
|
/// never shrink the on-disk record.
|
|
func append(_ newIDs: [String], cap: Int) {
|
|
guard !newIDs.isEmpty else { return }
|
|
ioQueue.async { [self] in
|
|
var merged = loadLocked()
|
|
var known = Set(merged)
|
|
for id in newIDs where !known.contains(id) {
|
|
merged.append(id)
|
|
known.insert(id)
|
|
}
|
|
if merged.count > cap {
|
|
merged.removeFirst(merged.count - cap)
|
|
}
|
|
saveLocked(merged)
|
|
}
|
|
}
|
|
|
|
func wipe() {
|
|
ioQueue.async { [self] in
|
|
guard let fileURL else { return }
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
}
|
|
|
|
private func loadLocked() -> [String] {
|
|
guard let fileURL,
|
|
let data = try? Data(contentsOf: fileURL),
|
|
let ids = try? JSONDecoder().decode([String].self, from: data) else {
|
|
return []
|
|
}
|
|
return ids
|
|
}
|
|
|
|
private func saveLocked(_ eventIDs: [String]) {
|
|
guard let fileURL else { return }
|
|
guard !eventIDs.isEmpty else {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
return
|
|
}
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: fileURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
let data = try JSONEncoder().encode(eventIDs)
|
|
var options: Data.WritingOptions = [.atomic]
|
|
#if os(iOS)
|
|
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
|
#endif
|
|
try data.write(to: fileURL, options: options)
|
|
} catch {
|
|
SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session)
|
|
}
|
|
}
|
|
|
|
private static func defaultFileURL() -> URL? {
|
|
guard let base = try? FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
) else { return nil }
|
|
return base
|
|
.appendingPathComponent("nostr", isDirectory: true)
|
|
.appendingPathComponent("processed-events.json")
|
|
}
|
|
}
|