/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* Originally from boring.notch project
* Modified and adapted for Atoll (DynamicIsland)
* See NOTICE for details.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import Foundation
import AppKit
@MainActor
final class ShelfStateViewModel: ObservableObject {
static let shared = ShelfStateViewModel()
@Published private(set) var items: [ShelfItem] = [] {
didSet { ShelfPersistenceService.shared.save(items) }
}
@Published var isLoading: Bool = false
var isEmpty: Bool { items.isEmpty }
// Queue for deferred bookmark updates to avoid publishing during view updates
private var pendingBookmarkUpdates: [ShelfItem.ID: Data] = [:]
private var updateTask: Task?
private init() {
items = ShelfPersistenceService.shared.load()
}
func add(_ newItems: [ShelfItem]) {
guard !newItems.isEmpty else { return }
var merged = items
// Deduplicate by identityKey while preserving order (existing first)
var seen: Set = Set(merged.map { $0.identityKey })
var addedIDs: [String] = []
for it in newItems {
let key = it.identityKey
if !seen.contains(key) {
merged.append(it)
seen.insert(key)
addedIDs.append(it.id.uuidString)
}
}
items = merged
if !addedIDs.isEmpty {
ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: addedIDs, action: "added")
}
}
func remove(_ item: ShelfItem) {
item.cleanupStoredData()
items.removeAll { $0.id == item.id }
ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: [item.id.uuidString], action: "removed")
}
func updateBookmark(for item: ShelfItem, bookmark: Data) {
guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return }
if case .file = items[idx].kind {
items[idx].kind = .file(bookmark: bookmark)
}
}
private func scheduleDeferredBookmarkUpdate(for item: ShelfItem, bookmark: Data) {
pendingBookmarkUpdates[item.id] = bookmark
// Cancel existing task and schedule a new one
updateTask?.cancel()
updateTask = Task { @MainActor [weak self] in
await Task.yield()
guard let self = self else { return }
for (itemID, bookmarkData) in self.pendingBookmarkUpdates {
if let idx = self.items.firstIndex(where: { $0.id == itemID }),
case .file = self.items[idx].kind {
self.items[idx].kind = .file(bookmark: bookmarkData)
}
}
self.pendingBookmarkUpdates.removeAll()
}
}
func load(_ providers: [NSItemProvider]) {
guard !providers.isEmpty else { return }
isLoading = true
Task { [weak self] in
let dropped = await ShelfDropService.items(from: providers)
await MainActor.run {
self?.add(dropped)
self?.isLoading = false
}
}
}
func cleanupInvalidItems() {
Task { [weak self] in
guard let self else { return }
var keep: [ShelfItem] = []
for item in self.items {
switch item.kind {
case .file(let data):
let bookmark = Bookmark(data: data)
if await bookmark.validate() {
keep.append(item)
} else {
item.cleanupStoredData()
}
default:
keep.append(item)
}
}
await MainActor.run { self.items = keep }
}
}
func resolveFileURL(for item: ShelfItem) -> URL? {
guard case .file(let bookmarkData) = item.kind else { return nil }
let bookmark = Bookmark(data: bookmarkData)
let result = bookmark.resolve()
if let refreshed = result.refreshedData, refreshed != bookmarkData {
NSLog("Bookmark for \(item) stale; refreshing")
scheduleDeferredBookmarkUpdate(for: item, bookmark: refreshed)
}
return result.url
}
func resolveAndUpdateBookmark(for item: ShelfItem) -> URL? {
guard case .file(let bookmarkData) = item.kind else { return nil }
let bookmark = Bookmark(data: bookmarkData)
let result = bookmark.resolve()
if let refreshed = result.refreshedData, refreshed != bookmarkData {
NSLog("Bookmark for \(item) stale; refreshing")
updateBookmark(for: item, bookmark: refreshed)
}
return result.url
}
func resolveFileURLs(for items: [ShelfItem]) -> [URL] {
var urls: [URL] = []
for it in items {
if let u = resolveFileURL(for: it) { urls.append(u) }
}
return urls
}
}