diff --git a/Package.resolved b/Package.resolved index dbaa5c5..1eaa4de 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stephencelis/CSQLite.git", + "state" : { + "revision" : "9106e983d5e3d5149ee35281ec089484b0def018", + "version" : "0.0.3" + } + }, { "identity" : "sqlite.swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 2b8da84..687b071 100644 --- a/Package.swift +++ b/Package.swift @@ -3,51 +3,92 @@ import PackageDescription +var products: [Product] = [] +var targets: [Target] = [ + .target( + name: "TimelineCore", + dependencies: [], + path: "TimelineCore/", + sources: [ + "Alerts.swift", + "Logic/FilteredAppsStorage.swift", + "Logic/PureAlignedTimer.swift", + "Logic/PureCounter.swift", + "Logic/Tracker.swift", + "Storage/Model.swift", + "Storage/Storage.swift" + ]), + .target( + name: "testing_utils", + dependencies: ["TimelineCore"], + path: "testing-utils/"), + .testTarget( + name: "TimelineCoreTests", + dependencies: ["TimelineCore", "testing_utils"], + path: "TimelineCoreTests/"), +] + +var dependencies: [Package.Dependency] = [ + .package(url: "https://github.com/stephencelis/CSQLite.git", from: "0.0.3"), + .package(url: "https://github.com/stephencelis/SQLite.swift.git", "0.13.3"..<"0.14.0"), +] + +targets.append( + .target( + name: "SQLiteStorage", + dependencies: [ + "TimelineCore", + .product(name: "SQLite", package: "SQLite.swift"), + .product(name: "CSQLite", package: "CSQLite"), + ], + path: "SQLiteStorage/") +) + +#if os(macOS) +products.append(.executable(name: "Timeline-macOS", targets: ["TimelineCocoa"])) +targets.append( + .executableTarget( + name: "TimelineCocoa", + dependencies: ["TimelineCore", "SQLiteStorage"], + path: "macOS/", + sources: [ + "AppDelegate.swift", + "CocoaApps.swift", + "CocoaTime.swift", + "Date+Extensions.swift", + "main.swift", + "StatisticsView.swift", + "CocoaAlerter.swift" + ], + resources: [ + .copy("macOS.entitlements"), + .copy("timeline--macOS--Info.plist"), + ]) +) +#endif + +#if os(Linux) +dependencies.append(.package(url: "https://github.com/aestesis/X11.git", branch: "master")) +products.append(.executable(name: "Timeline-linux", targets: ["TimelineLinux"])) +targets.append( + .executableTarget( + name: "TimelineLinux", + dependencies: ["TimelineCore", "SQLiteStorage", "X11"], + path: "linux/", + sources: [ + "main.swift", + "X11Apps.swift", + "LinuxAlerter.swift" + ]) +) +#endif let package = Package( name: "timeline", platforms: [ .macOS(.v12) ], - products: [ - .executable(name: "Timeline-macOS", targets: ["TimelineCocoa"]), - .executable(name: "Timeline-linux", targets: ["TimelineLinux"]), - ], - dependencies: [ - .package(url: "https://github.com/stephencelis/SQLite.swift.git", "0.13.3"..<"0.14.0"), - .package(url: "https://github.com/aestesis/X11.git", branch: "master"), - ], - targets: [ - .target( - name: "TimelineCore", - dependencies: [], - path: "TimelineCore/"), - .target( - name: "SQLiteStorage", - dependencies: [ - "TimelineCore", - .product(name: "SQLite", package: "SQLite.swift"), - ], - path: "SQLiteStorage/"), - .target( - name: "testing_utils", - dependencies: ["TimelineCore"], - path: "testing-utils/"), - .testTarget( - name: "TimelineCoreTests", - dependencies: ["TimelineCore", "testing_utils"], - path: "TimelineCoreTests/"), - .executableTarget( - name: "TimelineCocoa", - dependencies: ["TimelineCore", "SQLiteStorage"], - path: "macOS/", - resources: [ - .copy("macOS/macOS.entitlements"), - .copy("macOS/timeline--macOS--Info.plist"), - ]), - .executableTarget( - name: "TimelineLinux", - dependencies: ["TimelineCore", "SQLiteStorage", "X11"], - path: "linux/"), - ] + products: products, + dependencies: dependencies, + targets: targets ) diff --git a/SQLiteStorage/SQLiteStorage.swift b/SQLiteStorage/SQLiteStorage.swift index 479b106..b466597 100644 --- a/SQLiteStorage/SQLiteStorage.swift +++ b/SQLiteStorage/SQLiteStorage.swift @@ -1,5 +1,6 @@ import Foundation import SQLite +import CSQLite import TimelineCore public class SQLiteStorage: Storage { @@ -22,79 +23,113 @@ public class SQLiteStorage: Storage { let appId = Expression("appId") let activityName = Expression("activityName") let duration = Expression("duration") + + private static func mapStorageError(_ error: Error, operation: String) -> StorageError { + if case let Result.error(message, code, _) = error, code == SQLITE_FULL { + return .diskFull(reason: message) + } + let nsError = error as NSError + if nsError.domain == NSPOSIXErrorDomain && nsError.code == ENOSPC { + return .diskFull(reason: nsError.localizedDescription) + } + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileWriteOutOfSpaceError { + return .diskFull(reason: nsError.localizedDescription) + } + return .cantWrite(reason: "\(operation): \(error.localizedDescription)") + } public init(filepath: String) throws { - db = try Connection(filepath) - try db.run(appsTable.create(ifNotExists: true) { - $0.column(idColumn, primaryKey: true) - $0.column(trackingMode) - }) - try db.run(appsTable.createIndex(idColumn, unique: true, ifNotExists: true)) - try db.run(timelinesTable.create(ifNotExists: true) { - $0.column(idColumn, primaryKey: true) - $0.column(deviceName) - $0.column(deviceSystem) - $0.column(timezoneName) - $0.column(timezoneShift) - $0.column(dateStart) - }) - try db.run(timelinesTable.createIndex(idColumn, unique: true, ifNotExists: true)) - try db.run(logsTable.create(ifNotExists: true) { - $0.column(timelineId) - $0.column(timeslotStart) - $0.column(appId) - $0.column(activityName) - $0.column(duration) - $0.foreignKey(timelineId, references: timelinesTable, idColumn) - $0.foreignKey(appId, references: appsTable, idColumn) - }) - try db.run(logsTable.createIndex(timeslotStart, unique: false, ifNotExists: true)) + do { + db = try Connection(filepath) + try db.run(appsTable.create(ifNotExists: true) { + $0.column(idColumn, primaryKey: true) + $0.column(trackingMode) + }) + try db.run(appsTable.createIndex(idColumn, unique: true, ifNotExists: true)) + try db.run(timelinesTable.create(ifNotExists: true) { + $0.column(idColumn, primaryKey: true) + $0.column(deviceName) + $0.column(deviceSystem) + $0.column(timezoneName) + $0.column(timezoneShift) + $0.column(dateStart) + }) + try db.run(timelinesTable.createIndex(idColumn, unique: true, ifNotExists: true)) + try db.run(logsTable.create(ifNotExists: true) { + $0.column(timelineId) + $0.column(timeslotStart) + $0.column(appId) + $0.column(activityName) + $0.column(duration) + $0.foreignKey(timelineId, references: timelinesTable, idColumn) + $0.foreignKey(appId, references: appsTable, idColumn) + }) + try db.run(logsTable.createIndex(timeslotStart, unique: false, ifNotExists: true)) + } catch { + let mapped = Self.mapStorageError(error, operation: "open sqlite") + if case .diskFull = mapped { + throw mapped + } + throw StorageError.cantOpen(reason: error.localizedDescription) + } } - public func store(log: Log) { - try! db.run(logsTable.insert( - timelineId <- log.timelineId, - timeslotStart <- log.timeslotStart, - appId <- log.appId, - activityName <- log.activityName, - duration <- log.duration - )) + public func store(log: Log) throws { + do { + try db.run(logsTable.insert( + timelineId <- log.timelineId, + timeslotStart <- log.timeslotStart, + appId <- log.appId, + activityName <- log.activityName, + duration <- log.duration + )) + } catch { + throw Self.mapStorageError(error, operation: "store log") + } } - public func store(app: App) { - try! db.run(appsTable.upsert( - idColumn <- app.id, - trackingMode <- app.trackingMode, - onConflictOf: idColumn)) + public func store(app: App) throws { + do { + try db.run(appsTable.upsert( + idColumn <- app.id, + trackingMode <- app.trackingMode, + onConflictOf: idColumn)) + } catch { + throw Self.mapStorageError(error, operation: "store app") + } } - public func store(timeline: Timeline) { - try! db.run(timelinesTable.upsert( - idColumn <- timeline.id, - deviceName <- timeline.deviceName, - deviceSystem <- timeline.deviceSystem, - timezoneName <- timeline.timezoneName, - timezoneShift <- timeline.timezoneShift, - dateStart <- timeline.dateStart, - onConflictOf: idColumn)) + public func store(timeline: Timeline) throws { + do { + try db.run(timelinesTable.upsert( + idColumn <- timeline.id, + deviceName <- timeline.deviceName, + deviceSystem <- timeline.deviceSystem, + timezoneName <- timeline.timezoneName, + timezoneShift <- timeline.timezoneShift, + dateStart <- timeline.dateStart, + onConflictOf: idColumn)) + } catch { + throw Self.mapStorageError(error, operation: "store timeline") + } } public func fetchLogs(since: Date, till: Date) -> [Log] { - let logs: [LogStruct] = try! db.prepare(logsTable.filter(timeslotStart >= since && timeslotStart < till)) + let logs: [LogStruct]? = try? db.prepare(logsTable.filter(timeslotStart >= since && timeslotStart < till)) .map { try $0.decode() } - return logs + return logs ?? [] } public func fetchApps() -> [String : App] { - let apps: [AppStruct] = try! db.prepare(appsTable).map { row -> AppStruct in - return AppStruct(id: try! row.get(idColumn), trackingMode: try! row.get(trackingMode)) + let apps: [AppStruct]? = try? db.prepare(appsTable).map { row -> AppStruct in + return AppStruct(id: try row.get(idColumn), trackingMode: try row.get(trackingMode)) } - return Dictionary(grouping: apps) { $0.id } .mapValues { $0[0] } + return Dictionary(grouping: apps ?? []) { $0.id } .mapValues { $0[0] } } public func fetchTimeline(id: String) -> Timeline? { - let timelines: [TimelineStruct] = try! db.prepare(timelinesTable.filter(idColumn == id).limit(1)).map { try $0.decode() } - return timelines.first + let timelines: [TimelineStruct]? = try? db.prepare(timelinesTable.filter(idColumn == id).limit(1)).map { try $0.decode() } + return timelines?.first } } diff --git a/TimelineCore/Alerts.swift b/TimelineCore/Alerts.swift new file mode 100644 index 0000000..5c5231f --- /dev/null +++ b/TimelineCore/Alerts.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol Alerter { + func showAlert(title: String, message: String) +} diff --git a/TimelineCore/Logic/FilteredAppsStorage.swift b/TimelineCore/Logic/FilteredAppsStorage.swift index 0355847..58eb5e9 100644 --- a/TimelineCore/Logic/FilteredAppsStorage.swift +++ b/TimelineCore/Logic/FilteredAppsStorage.swift @@ -9,16 +9,16 @@ public class FilteredAppsStorage: Storage { self.innerStorage = storage } - public func store(log: Log) { - innerStorage.store(log: log) + public func store(log: Log) throws { + try innerStorage.store(log: log) } - public func store(app: App) { - innerStorage.store(app: app) + public func store(app: App) throws { + try innerStorage.store(app: app) } - public func store(timeline: Timeline) { - innerStorage.store(timeline: timeline) + public func store(timeline: Timeline) throws { + try innerStorage.store(timeline: timeline) } public func fetchLogs(since: Date, till: Date) -> [Log] { diff --git a/TimelineCore/Logic/Tracker.swift b/TimelineCore/Logic/Tracker.swift index 29b5420..d82422f 100644 --- a/TimelineCore/Logic/Tracker.swift +++ b/TimelineCore/Logic/Tracker.swift @@ -21,15 +21,18 @@ public class Tracker { private let storage: Storage private let time: TimeDependency private let snapshotter: Snapshotter + private let alerter: Alerter private let timer: AlignedTimer private let alignInterval: TimeInterval + private var lastAlertText: String? public var currentTimelineId: String = UUID().uuidString public var fillTimelineDeviceInfo: (inout TimelineStruct)->() = { _ in } - public init(timeDependency: TimeDependency, storage: Storage, snapshotter: Snapshotter, alignInterval: TimeInterval = 5*60) { + public init(timeDependency: TimeDependency, storage: Storage, snapshotter: Snapshotter, alerter: Alerter, alignInterval: TimeInterval = 5*60) { self.storage = storage self.time = timeDependency self.snapshotter = snapshotter + self.alerter = alerter self.counter = Counter(timeDependency: { return timeDependency.currentTime }) @@ -77,7 +80,12 @@ public class Tracker { private func store(app: AppSnapshot) -> App { let app = AppStruct(id: app.appId, trackingMode: .app) - storage.store(app: app) + do { + try storage.store(app: app) + lastAlertText = nil + } catch { + reportStorageError(error, action: "storing app") + } return app } @@ -89,13 +97,33 @@ public class Tracker { if storage.fetchTimeline(id: currentTimelineId) == nil { var timeline = TimelineStruct(id: currentTimelineId, dateStart: time.currentTime) fillTimelineDeviceInfo(&timeline) - storage.store(timeline: timeline) + do { + try storage.store(timeline: timeline) + lastAlertText = nil + } catch { + reportStorageError(error, action: "storing timeline") + if isDiskFull(error) { + counter.clearAndPause() + if active { + tickAppCounter() + } + return + } + } } for (appKey, duration) in counter.statistics { log.appId = appKey.appId log.activityName = appKey.activity log.duration = duration - storage.store(log: log) + do { + try storage.store(log: log) + lastAlertText = nil + } catch { + reportStorageError(error, action: "storing log") + if isDiskFull(error) { + break + } + } } counter.clearAndPause() if active { @@ -108,6 +136,35 @@ public class Tracker { //TODO: IMPLEMENT } + private func isDiskFull(_ error: Error) -> Bool { + guard let storageError = error as? StorageError else { + return false + } + if case .diskFull = storageError { + return true + } + return false + } + + private func reportStorageError(_ error: Error, action: String) { + let title: String + let message: String + if isDiskFull(error) { + title = "Timeline paused: disk is full" + message = "No space left on disk. Free up space, then Timeline will resume saving data." + } else { + title = "Timeline storage error" + message = "Failed while \(action): \(error.localizedDescription)" + } + let text = title + "|" + message + guard text != lastAlertText else { + return + } + lastAlertText = text + fputs("[timeline] \(title): \(message)\n", stderr) + alerter.showAlert(title: title, message: message) + } + } diff --git a/TimelineCore/Storage/Storage.swift b/TimelineCore/Storage/Storage.swift index 9493012..8f2405f 100644 --- a/TimelineCore/Storage/Storage.swift +++ b/TimelineCore/Storage/Storage.swift @@ -1,9 +1,29 @@ import Foundation +public enum StorageError: Error { + case diskFull(reason: String) + case cantWrite(reason: String) + case cantOpen(reason: String) +} + +extension StorageError: LocalizedError { + public var errorDescription: String? { + switch self { + case .diskFull(let reason): + return reason.isEmpty ? "No space left on disk" : "No space left on disk: \(reason)" + case .cantWrite(let reason): + return "Failed to write tracking data: \(reason)" + case .cantOpen(let reason): + return "Failed to open tracking storage: \(reason)" + } + } +} + public protocol Storage { - func store(log: Log) - func store(app: App) - func store(timeline: Timeline) + func store(log: Log) throws + func store(app: App) throws + func store(timeline: Timeline) throws + func fetchLogs(since: Date, till: Date) -> [Log] func fetchApps() -> [String: App] func fetchTimeline(id: String) -> Timeline? diff --git a/TimelineCoreTests/TrackerTests.swift b/TimelineCoreTests/TrackerTests.swift index 3e56a86..4e49f0d 100644 --- a/TimelineCoreTests/TrackerTests.swift +++ b/TimelineCoreTests/TrackerTests.swift @@ -13,7 +13,7 @@ class TrackerTests: XCTestCase { let apps = AppsMock() func testFlow() { - let tracker = Tracker(timeDependency: timeTravel, storage: storage, snapshotter: apps, alignInterval: 10) + let tracker = Tracker(timeDependency: timeTravel, storage: storage, snapshotter: apps, alerter: NoopAlerter(), alignInterval: 10) tracker.currentTimelineId = "ABC" tracker.active = true timeTravel.currentTime = Date(timeIntervalSinceReferenceDate: 9) @@ -46,7 +46,7 @@ class TrackerTests: XCTestCase { } func testSimplestTrack() { - let tracker = Tracker(timeDependency: timeTravel, storage: storage, snapshotter: apps, alignInterval: 2) + let tracker = Tracker(timeDependency: timeTravel, storage: storage, snapshotter: apps, alerter: NoopAlerter(), alignInterval: 2) tracker.active = true timeTravel.currentTime = Date(timeIntervalSinceReferenceDate: 2) delay(2.5) @@ -74,3 +74,7 @@ class TimeMock: TimeDependency { var currentTime: Date = Date(timeIntervalSinceReferenceDate: 0) var notifySignificantTimeChange: () -> () = {} } + +class NoopAlerter: Alerter { + func showAlert(title: String, message: String) {} +} diff --git a/linux/LinuxAlerter.swift b/linux/LinuxAlerter.swift new file mode 100644 index 0000000..22906f2 --- /dev/null +++ b/linux/LinuxAlerter.swift @@ -0,0 +1,15 @@ +import Foundation +import TimelineCore + +class LinuxAlerter: Alerter { + func showAlert(title: String, message: String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/env") + task.arguments = ["zenity", "--warning", "--title=\(title)", "--text=\(message)"] + do { + try task.run() + } catch { + fputs("[timeline] Failed to show Linux notification: \(error.localizedDescription)\n", stderr) + } + } +} diff --git a/linux/X11Apps.swift b/linux/X11Apps.swift index 12f1782..19acd8d 100644 --- a/linux/X11Apps.swift +++ b/linux/X11Apps.swift @@ -59,7 +59,7 @@ extension SnapshotStruct { } } -class X11MonitorOperation: Operation { +class X11MonitorOperation: Operation, @unchecked Sendable { let notifyChange: ()->() init(closure: @escaping ()->()) { diff --git a/linux/main.swift b/linux/main.swift index dc515f8..758da01 100644 --- a/linux/main.swift +++ b/linux/main.swift @@ -16,7 +16,7 @@ try? FileManager.default.createDirectory(atPath: configPath, withIntermediateDir var storage: Storage = try! SQLiteStorage(filepath: configPath + "/store.sqlite") storage = FilteredAppsStorage(storage, overridenApps: ["{no active pid}": AppStruct(id: "{no active pid}", trackingMode: .skip)]) -let tracker = Tracker(timeDependency: time, storage: storage, snapshotter: try! X11Apps()) +let tracker = Tracker(timeDependency: time, storage: storage, snapshotter: try! X11Apps(), alerter: LinuxAlerter()) let terminalNotifier = try! X11Apps() terminalNotifier.notifyChange = { [weak terminalNotifier] in diff --git a/macOS/AppDelegate.swift b/macOS/AppDelegate.swift index f5b7eb0..b06e4bf 100644 --- a/macOS/AppDelegate.swift +++ b/macOS/AppDelegate.swift @@ -9,6 +9,7 @@ private let alignInterval: TimeInterval = 5*60 class AppDelegate: NSObject, NSApplicationDelegate { let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + let alerter = CocoaAlerter() var tracker: Tracker! var storage: Storage! var currentAppItem: NSMenuItem! @@ -25,11 +26,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { let path = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first! + "/" + Bundle.main.bundleIdentifier! - try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) - let sqlite = try! SQLiteStorage(filepath: path + "/timeline.sqlite") - storage = FilteredAppsStorage(sqlite, overridenApps: ["com.apple.loginwindow": AppStruct(id: "com.apple.loginwindow", trackingMode: .skip)]) + do { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) + let sqlite = try SQLiteStorage(filepath: path + "/timeline.sqlite") + storage = FilteredAppsStorage(sqlite, overridenApps: ["com.apple.loginwindow": AppStruct(id: "com.apple.loginwindow", trackingMode: .skip)]) + } catch { + alerter.showAlert(title: "Timeline storage unavailable", message: error.localizedDescription) + storage = MemoryStorage() + } - tracker = Tracker(timeDependency: CocoaTime(), storage: storage, snapshotter: CocoaApps(), alignInterval: alignInterval) + tracker = Tracker(timeDependency: CocoaTime(), storage: storage, snapshotter: CocoaApps(), alerter: alerter, alignInterval: alignInterval) tracker.active = true createMenu() @@ -89,7 +95,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc func setAppTracking(_ source: NSMenuItem) { let newTracking = [skipTrackingItem: TrackingMode.skip, setAppTrackingItem: .app, setTitleTrackingItem: .titles][source] ?? .app let newApp = AppStruct(id: appProvider.currentApp.appId, trackingMode: newTracking) - storage.store(app: newApp) + do { + try storage.store(app: newApp) + } catch { + alerter.showAlert(title: "Error storing app", message: error.localizedDescription) + } updateCurrentApp() tracker.persist() } diff --git a/macOS/CocoaAlerter.swift b/macOS/CocoaAlerter.swift new file mode 100644 index 0000000..8e282b8 --- /dev/null +++ b/macOS/CocoaAlerter.swift @@ -0,0 +1,54 @@ +import Foundation +import AppKit +import UserNotifications +import TimelineCore + +class CocoaAlerter: NSObject, Alerter, UNUserNotificationCenterDelegate { + private let center = UNUserNotificationCenter.current() + private var didRequestAuthorization = false + private var notificationsAllowed = false + + override init() { + super.init() + center.delegate = self + } + + func showAlert(title: String, message: String) { + authorizeIfNeeded { [weak self] in + self?.postNotification(title: title, message: message) + } + } + + private func authorizeIfNeeded(onAuthorized: @escaping () -> Void) { + if didRequestAuthorization { + if notificationsAllowed { + onAuthorized() + } + return + } + didRequestAuthorization = true + center.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in + DispatchQueue.main.async { + self?.notificationsAllowed = granted + if granted { + onAuthorized() + } + } + } + } + + private func postNotification(title: String, message: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = message + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + center.add(request) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .list, .sound]) + } +} diff --git a/testing-utils/MemoryStorage.swift b/testing-utils/MemoryStorage.swift index 9d81e87..ae724f3 100644 --- a/testing-utils/MemoryStorage.swift +++ b/testing-utils/MemoryStorage.swift @@ -9,7 +9,7 @@ public class MemoryStorage: Storage { public init() {} - public func store(log newLog: Log) { + public func store(log newLog: Log) throws { var log = newLog if let existingIndex = logs.firstIndex(where: { $0.appId == log.appId && $0.timelineId == log.timelineId && $0.timeslotStart == log.timeslotStart && $0.activityName == log.activityName }) { let existing = logs[existingIndex] @@ -22,7 +22,7 @@ public class MemoryStorage: Storage { } } - public func store(app: App) { + public func store(app: App) throws { if let existingIndex = apps.firstIndex(where: { $0.id == app.id }) { apps.remove(at: existingIndex) } @@ -32,7 +32,7 @@ public class MemoryStorage: Storage { } } - public func store(timeline: Timeline) { + public func store(timeline: Timeline) throws { if let existingIndex = timelines.firstIndex(where: { $0.id == timeline.id }) { timelines.remove(at: existingIndex) }