aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Managers
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/Managers
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/Managers')
-rw-r--r--Hotline/Managers/ChatStore.swift272
-rw-r--r--Hotline/Managers/SoundEffects.swift40
2 files changed, 312 insertions, 0 deletions
diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift
new file mode 100644
index 0000000..59c9484
--- /dev/null
+++ b/Hotline/Managers/ChatStore.swift
@@ -0,0 +1,272 @@
+import Foundation
+import CryptoKit
+import Security
+
+actor ChatStore {
+ static let shared = ChatStore()
+ static let historyClearedNotification = Notification.Name("ChatStoreHistoryCleared")
+
+ struct SessionKey: Hashable {
+ let address: String
+ let port: Int
+
+ var identifier: String { "\(address):\(port)" }
+ }
+
+ struct Metadata: Codable {
+ let address: String
+ let port: Int
+ var serverName: String?
+ var createdAt: Date
+ var updatedAt: Date
+
+ mutating func update(serverName: String?, timestamp: Date) {
+ if let serverName, !serverName.isEmpty {
+ self.serverName = serverName
+ }
+ self.updatedAt = timestamp
+ }
+ }
+
+ struct EntryMetadata: Codable {
+ var images: [ImageMetadata]?
+
+ struct ImageMetadata: Codable {
+ let url: String
+ let width: CGFloat?
+ let height: CGFloat?
+ }
+ }
+
+ struct Entry: Codable {
+ let id: UUID
+ let body: String
+ let username: String?
+ let type: String
+ let date: Date
+ var metadata: EntryMetadata?
+ }
+
+ struct LoadResult {
+ let entries: [Entry]
+ let metadata: Metadata?
+ }
+
+ private struct LogFile: Codable {
+ var metadata: Metadata
+ var entries: [Entry]
+ }
+
+ private enum StoreError: Error {
+ case encryptionFailed
+ case invalidCombinedCiphertext
+ case keyGenerationFailed
+ }
+
+ private let keychainKey = "chatlog-encryption-key"
+ private let applicationFolderName = "Hotline"
+ private let logsFolderName = "ChatLogs"
+ private let fileExtension = "hlchat"
+ private let maxEntries = 2000
+
+ private var cache: [SessionKey: LogFile] = [:]
+ private var cachedDirectory: URL?
+ private var cachedKey: SymmetricKey?
+
+ func append(entry: Entry, for key: SessionKey, serverName: String?) async {
+ do {
+ var logFile = try loadLogFile(for: key) ?? newLogFile(for: key, serverName: serverName)
+
+ logFile.entries.append(entry)
+ if logFile.entries.count > maxEntries {
+ logFile.entries = Array(logFile.entries.suffix(maxEntries))
+ }
+
+ logFile.metadata.update(serverName: serverName, timestamp: entry.date)
+ cache[key] = logFile
+
+ try persist(logFile, for: key)
+ }
+ catch {
+ print("ChatStore: failed to append entry —", error)
+ }
+ }
+
+ func updateMetadata(_ metadata: EntryMetadata, for entryID: UUID, key: SessionKey) async {
+ do {
+ guard var logFile = try loadLogFile(for: key) else { return }
+
+ if let index = logFile.entries.firstIndex(where: { $0.id == entryID }) {
+ logFile.entries[index].metadata = metadata
+ cache[key] = logFile
+ try persist(logFile, for: key)
+ }
+ }
+ catch {
+ print("ChatStore: failed to update metadata —", error)
+ }
+ }
+
+ func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult {
+ do {
+ let logFile = try loadLogFile(for: key)
+ guard let logFile else {
+ return LoadResult(entries: [], metadata: nil)
+ }
+
+ let entries: [Entry]
+ if let limit, limit < logFile.entries.count {
+ entries = Array(logFile.entries.suffix(limit))
+ }
+ else {
+ entries = logFile.entries
+ }
+
+ return LoadResult(entries: entries, metadata: logFile.metadata)
+ }
+ catch {
+ print("ChatStore: failed to load history —", error)
+ return LoadResult(entries: [], metadata: nil)
+ }
+ }
+
+ func clearAll() async {
+ let fm = FileManager.default
+ if let dir = try? directoryURL(), fm.fileExists(atPath: dir.path) {
+ do {
+ try fm.removeItem(at: dir)
+ }
+ catch {
+ print("ChatStore: failed to clear chat logs —", error)
+ }
+ }
+
+ cache.removeAll()
+ cachedDirectory = nil
+
+ await MainActor.run {
+ NotificationCenter.default.post(name: Self.historyClearedNotification, object: nil)
+ }
+ }
+
+ static func digest(for string: String) -> String {
+ let hash = SHA256.hash(data: Data(string.utf8))
+ return hash.compactMap { String(format: "%02x", $0) }.joined()
+ }
+
+ private func newLogFile(for key: SessionKey, serverName: String?) -> LogFile {
+ let now = Date()
+ var metadata = Metadata(address: key.address, port: key.port, serverName: nil, createdAt: now, updatedAt: now)
+ metadata.update(serverName: serverName, timestamp: now)
+ let logFile = LogFile(metadata: metadata, entries: [])
+ cache[key] = logFile
+ return logFile
+ }
+
+ private func loadLogFile(for key: SessionKey) throws -> LogFile? {
+ if let cached = cache[key] {
+ return cached
+ }
+
+ let url = try fileURL(for: key)
+ let fm = FileManager.default
+ guard fm.fileExists(atPath: url.path) else {
+ return nil
+ }
+
+ let encryptedData = try Data(contentsOf: url)
+ let decryptedData = try decrypt(encryptedData)
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let logFile = try decoder.decode(LogFile.self, from: decryptedData)
+ cache[key] = logFile
+ return logFile
+ }
+
+ private func persist(_ logFile: LogFile, for key: SessionKey) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode(logFile)
+ let encrypted = try encrypt(data)
+ let url = try fileURL(for: key)
+ let fm = FileManager.default
+ let directory = url.deletingLastPathComponent()
+ if !fm.fileExists(atPath: directory.path) {
+ try fm.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+ try encrypted.write(to: url, options: .atomic)
+ }
+
+ private func directoryURL() throws -> URL {
+ if let cachedDirectory {
+ return cachedDirectory
+ }
+
+ let fm = FileManager.default
+ guard let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ throw StoreError.keyGenerationFailed
+ }
+
+ let appDirectory = base.appendingPathComponent(applicationFolderName, isDirectory: true)
+ let logsDirectory = appDirectory.appendingPathComponent(logsFolderName, isDirectory: true)
+
+ if !fm.fileExists(atPath: appDirectory.path) {
+ try fm.createDirectory(at: appDirectory, withIntermediateDirectories: true)
+ }
+ if !fm.fileExists(atPath: logsDirectory.path) {
+ try fm.createDirectory(at: logsDirectory, withIntermediateDirectories: true)
+ }
+
+ cachedDirectory = logsDirectory
+ return logsDirectory
+ }
+
+ private func fileURL(for key: SessionKey) throws -> URL {
+ let directory = try directoryURL()
+ let digest = Self.digest(for: key.identifier)
+ return directory.appendingPathComponent(digest).appendingPathExtension(fileExtension)
+ }
+
+ private func encrypt(_ data: Data) throws -> Data {
+ let key = try symmetricKey()
+ let sealedBox = try AES.GCM.seal(data, using: key)
+ guard let combined = sealedBox.combined else {
+ throw StoreError.encryptionFailed
+ }
+ return combined
+ }
+
+ private func decrypt(_ data: Data) throws -> Data {
+ let key = try symmetricKey()
+ let sealedBox = try AES.GCM.SealedBox(combined: data)
+ return try AES.GCM.open(sealedBox, using: key)
+ }
+
+ private func symmetricKey() throws -> SymmetricKey {
+ if let cachedKey {
+ return cachedKey
+ }
+
+ if let stored = DAKeychain.shared[keychainKey],
+ let storedData = Data(base64Encoded: stored),
+ storedData.count == 32 {
+ let key = SymmetricKey(data: storedData)
+ cachedKey = key
+ return key
+ }
+
+ var bytes = [UInt8](repeating: 0, count: 32)
+ let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ guard status == errSecSuccess else {
+ throw StoreError.keyGenerationFailed
+ }
+
+ let data = Data(bytes)
+ let key = SymmetricKey(data: data)
+ DAKeychain.shared[keychainKey] = data.base64EncodedString()
+ cachedKey = key
+ return key
+ }
+}
diff --git a/Hotline/Managers/SoundEffects.swift b/Hotline/Managers/SoundEffects.swift
new file mode 100644
index 0000000..85a1c0e
--- /dev/null
+++ b/Hotline/Managers/SoundEffects.swift
@@ -0,0 +1,40 @@
+import Foundation
+import AppKit
+
+enum SoundEffect: String {
+ case loggedIn = "logged-in"
+ case chatMessage = "chat-message"
+ case transferComplete = "transfer-complete"
+ case userLogin = "user-login"
+ case userLogout = "user-logout"
+ case newNews = "new-news"
+ case serverMessage = "server-message"
+ case error = "error"
+
+ static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error]
+}
+
+class SoundEffects {
+ static let shared = SoundEffects()
+
+ static func play(_ name: SoundEffect) {
+ Self.shared.play(name)
+ }
+
+ private var preloadedSounds: [SoundEffect: NSSound] = [:]
+
+ private init() {
+ // Preload sound effects
+ for effect in SoundEffect.all {
+ if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"),
+ let sound = NSSound(contentsOf: soundFileURL, byReference: true) {
+ sound.volume = 0.75
+ self.preloadedSounds[effect] = sound
+ }
+ }
+ }
+
+ func play(_ name: SoundEffect) {
+ self.preloadedSounds[name]?.play()
+ }
+}