mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Somewhat working file tray
This commit is contained in:
parent
4c7e79b954
commit
30422c3d95
29 changed files with 4032 additions and 65 deletions
94
DynamicIsland/components/Shelf/Models/Bookmark.swift
Normal file
94
DynamicIsland/components/Shelf/Models/Bookmark.swift
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//
|
||||
// Bookmark.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-08.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
struct Bookmark: Sendable, Equatable, Codable {
|
||||
let data: Data
|
||||
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
init(url: URL) throws {
|
||||
guard url.isFileURL, FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw NSError(domain: "Bookmark", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not a valid file URL or file does not exist at \(url.path)"])
|
||||
}
|
||||
do {
|
||||
let bookmark = try url.bookmarkData(
|
||||
options: .withSecurityScope,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
NSLog("✅ Successfully created bookmark for \(url.path)")
|
||||
self.data = bookmark
|
||||
} catch {
|
||||
NSLog("❌ Failed to create bookmark for \(url.path): \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func resolve() -> (url: URL?, refreshedData: Data?) {
|
||||
guard !data.isEmpty else { return (nil, nil) }
|
||||
var isStale = false
|
||||
do {
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [.withSecurityScope],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
if isStale, let newData = try? url.bookmarkData(options: [.withSecurityScope]) {
|
||||
NSLog("⚠️ Bookmark was stale for \(url.path), refreshed")
|
||||
return (url, newData)
|
||||
}
|
||||
return (url, nil)
|
||||
} catch {
|
||||
NSLog("❌ Failed to resolve bookmark: \(error.localizedDescription)")
|
||||
return (nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveURL() -> URL? {
|
||||
return resolve().url
|
||||
}
|
||||
|
||||
var refreshedData: Data? {
|
||||
return resolve().refreshedData
|
||||
}
|
||||
|
||||
static func update(in items: inout [ShelfItem], for item: ShelfItem, newBookmark: Data) {
|
||||
guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
guard case .file = items[idx].kind else { return }
|
||||
items[idx].kind = ShelfItemKind.file(bookmark: newBookmark)
|
||||
}
|
||||
|
||||
func validate() async -> Bool {
|
||||
let (url, _) = resolve()
|
||||
guard let url = url else { return false }
|
||||
return url.accessSecurityScopedResource { url in
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
func withAccess<T: Sendable>(_ block: @Sendable (URL) async throws -> T) async rethrows -> T? {
|
||||
let url = resolveURL()
|
||||
guard let url = url else { return nil }
|
||||
return try await url.accessSecurityScopedResource { url in
|
||||
try await block(url)
|
||||
}
|
||||
}
|
||||
|
||||
func withAccess<T>(_ block: (URL) throws -> T) rethrows -> T? {
|
||||
let url = resolveURL()
|
||||
guard let url = url else { return nil }
|
||||
return try url.accessSecurityScopedResource { url in
|
||||
try block(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
228
DynamicIsland/components/Shelf/Models/ShelfItem.swift
Normal file
228
DynamicIsland/components/Shelf/Models/ShelfItem.swift
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//
|
||||
// ShelfItem.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum ShelfItemKind: Codable, Equatable, Sendable {
|
||||
case file(bookmark: Data)
|
||||
case text(string: String)
|
||||
case link(url: URL)
|
||||
|
||||
enum CodingKeys: String, CodingKey { case type, value }
|
||||
|
||||
enum KindTag: String, Codable { case file, text, link }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(KindTag.self, forKey: .type)
|
||||
switch type {
|
||||
case .file:
|
||||
let data = try container.decode(Data.self, forKey: .value)
|
||||
self = .file(bookmark: data)
|
||||
case .text:
|
||||
self = .text(string: try container.decode(String.self, forKey: .value))
|
||||
case .link:
|
||||
self = .link(url: try container.decode(URL.self, forKey: .value))
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .file(let bookmark):
|
||||
try container.encode(KindTag.file, forKey: .type)
|
||||
try container.encode(bookmark, forKey: .value)
|
||||
case .text(let string):
|
||||
try container.encode(KindTag.text, forKey: .type)
|
||||
try container.encode(string, forKey: .value)
|
||||
case .link(let url):
|
||||
try container.encode(KindTag.link, forKey: .type)
|
||||
try container.encode(url, forKey: .value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ShelfItem: Identifiable, Codable, Equatable, Sendable {
|
||||
let id: UUID
|
||||
var kind: ShelfItemKind
|
||||
var isTemporary: Bool
|
||||
init(id: UUID = UUID(), kind: ShelfItemKind, isTemporary: Bool = false) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.isTemporary = isTemporary
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch kind {
|
||||
case .file(let bookmarkData):
|
||||
let bookmark = Bookmark(data: bookmarkData)
|
||||
guard let resolvedURL = bookmark.resolveURL() else { return "" }
|
||||
|
||||
// Check for stored data files (text blocks, weblocs, etc.) to provide friendly names
|
||||
if resolvedURL.pathExtension.lowercased() == "json" && resolvedURL.path.contains("TextBlocks") {
|
||||
do {
|
||||
let data = try Data(contentsOf: resolvedURL)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
struct TextBlockData: Codable {
|
||||
let content: String
|
||||
let title: String?
|
||||
var displayTitle: String {
|
||||
if let title = title, !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let firstLine = content.components(separatedBy: .newlines).first ?? content
|
||||
if firstLine.count > 50 {
|
||||
return String(firstLine.prefix(47)) + "..."
|
||||
}
|
||||
return firstLine
|
||||
}
|
||||
}
|
||||
if let textData = try? decoder.decode(TextBlockData.self, from: data) {
|
||||
return textData.displayTitle
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default naming
|
||||
}
|
||||
} else if resolvedURL.pathExtension.lowercased() == "webloc" && resolvedURL.path.contains("WebLocs") {
|
||||
do {
|
||||
let data = try Data(contentsOf: resolvedURL)
|
||||
if let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
|
||||
let urlString = plist["URL"] as? String {
|
||||
let title = plist["Title"] as? String
|
||||
return title ?? urlString
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default naming
|
||||
}
|
||||
}
|
||||
return (try? resolvedURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? resolvedURL.lastPathComponent
|
||||
case .text(let string):
|
||||
return string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
case .link(let url):
|
||||
let s = url.absoluteString
|
||||
if s.hasPrefix("https://") {
|
||||
return String(s.dropFirst("https://".count))
|
||||
} else if s.hasPrefix("http://") {
|
||||
return String(s.dropFirst("http://".count))
|
||||
} else {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fileURL: URL? {
|
||||
guard case .file = kind else { return nil }
|
||||
return ShelfStateViewModel.shared.resolveFileURL(for: self)
|
||||
}
|
||||
|
||||
var URL: URL? {
|
||||
if case let .file(bookmark) = kind { return resolvedContext(for: bookmark)?.url }
|
||||
else if case let .link(url) = kind { return url }
|
||||
else { return nil }
|
||||
}
|
||||
|
||||
var icon: NSImage {
|
||||
guard case .file = kind else {
|
||||
return Self.thumbnailSymbolImage(systemName: kind.iconSymbolName) ?? NSImage()
|
||||
}
|
||||
if let resolvedURL = ShelfStateViewModel.shared.resolveFileURL(for: self) {
|
||||
return NSWorkspace.shared.icon(forFile: resolvedURL.path)
|
||||
}
|
||||
return NSImage()
|
||||
}
|
||||
|
||||
|
||||
func cleanupStoredData() {
|
||||
guard case let .file(bookmark) = kind,
|
||||
let context = resolvedContext(for: bookmark) else { return }
|
||||
|
||||
let url = context.url
|
||||
|
||||
// Handle temporary files
|
||||
if isTemporary {
|
||||
TemporaryFileStorageService.shared.removeTemporaryFileIfNeeded(at: url)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ShelfItem {
|
||||
static func thumbnailSymbolImage(
|
||||
systemName: String,
|
||||
size: CGSize = CGSize(width: 64, height: 80),
|
||||
symbolPointSize: CGFloat = 38,
|
||||
backgroundColor: NSColor = NSColor.white,
|
||||
symbolColor: NSColor = NSColor.labelColor
|
||||
) -> NSImage? {
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
defer { image.unlockFocus() }
|
||||
|
||||
let rect = CGRect(origin: .zero, size: size)
|
||||
let cornerRadius = min(size.width, size.height) * 0.06
|
||||
let path = NSBezierPath(roundedRect: rect.insetBy(dx: 2, dy: 2), xRadius: cornerRadius, yRadius: cornerRadius)
|
||||
backgroundColor.setFill()
|
||||
path.fill()
|
||||
|
||||
if let symbol = NSImage(systemSymbolName: systemName, accessibilityDescription: nil) {
|
||||
let symbolSize = CGSize(width: symbolPointSize, height: symbolPointSize)
|
||||
let symbolOrigin = CGPoint(
|
||||
x: (size.width - symbolSize.width) / 2,
|
||||
y: (size.height - symbolSize.height) / 2
|
||||
)
|
||||
let symbolRect = CGRect(origin: symbolOrigin, size: symbolSize)
|
||||
symbol.draw(in: symbolRect)
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity key for deduplication
|
||||
extension ShelfItem {
|
||||
var identityKey: String {
|
||||
switch kind {
|
||||
case .file(let bookmark):
|
||||
if let url = resolvedContext(for: bookmark)?.url {
|
||||
return "file://" + url.standardizedFileURL.path
|
||||
}
|
||||
return "file://missing/" + bookmark.base64EncodedString()
|
||||
case .link(let u):
|
||||
return "link://" + u.absoluteString
|
||||
case .text(let s):
|
||||
return "text://" + s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
private extension ShelfItemKind {
|
||||
var iconSymbolName: String {
|
||||
switch self {
|
||||
case .file:
|
||||
return "questionmark.circle"
|
||||
case .text:
|
||||
return "text.justifyleft"
|
||||
case .link:
|
||||
return "link"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ShelfItem {
|
||||
func resolvedContext(for bookmarkData: Data) -> (url: URL, bookmark: Data)? {
|
||||
let bookmark = Bookmark(data: bookmarkData)
|
||||
if let url = bookmark.resolveURL() {
|
||||
return (url, bookmark.refreshedData ?? bookmarkData)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
//
|
||||
// ImageProcessingService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import CoreImage
|
||||
import CoreGraphics
|
||||
import Vision
|
||||
import PDFKit
|
||||
import UniformTypeIdentifiers
|
||||
import ImageIO
|
||||
|
||||
/// Options for image conversion
|
||||
struct ImageConversionOptions {
|
||||
enum ImageFormat {
|
||||
case png, jpeg, heic, tiff, bmp
|
||||
|
||||
var utType: UTType {
|
||||
switch self {
|
||||
case .png: return .png
|
||||
case .jpeg: return .jpeg
|
||||
case .heic: return .heic
|
||||
case .tiff: return .tiff
|
||||
case .bmp: return .bmp
|
||||
}
|
||||
}
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .png: return "png"
|
||||
case .jpeg: return "jpg"
|
||||
case .heic: return "heic"
|
||||
case .tiff: return "tiff"
|
||||
case .bmp: return "bmp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let format: ImageFormat
|
||||
let compressionQuality: Double // 0.0 to 1.0, only applies to JPEG/HEIC
|
||||
let maxDimension: CGFloat? // Max width or height, nil for no scaling
|
||||
let removeMetadata: Bool
|
||||
}
|
||||
|
||||
/// Service for processing images (background removal, conversion, PDF creation)
|
||||
@MainActor
|
||||
final class ImageProcessingService {
|
||||
static let shared = ImageProcessingService()
|
||||
|
||||
private init() {}
|
||||
private let ciContext = CIContext(options: nil)
|
||||
|
||||
// MARK: - Remove Background
|
||||
|
||||
/// Removes the background from an image using Vision framework
|
||||
func removeBackground(from url: URL) async throws -> URL? {
|
||||
guard let inputImage = NSImage(contentsOf: url) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
guard let cgImage = inputImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
let request = VNGenerateForegroundInstanceMaskRequest()
|
||||
let handler = VNImageRequestHandler(cgImage: cgImage)
|
||||
|
||||
try handler.perform([request])
|
||||
|
||||
guard let result = request.results?.first else {
|
||||
throw ImageProcessingError.backgroundRemovalFailed
|
||||
}
|
||||
|
||||
let mask = try result.generateScaledMaskForImage(forInstances: result.allInstances, from: handler)
|
||||
|
||||
let output = try await applyMask(mask, to: cgImage)
|
||||
|
||||
let processedImage = NSImage(cgImage: output, size: inputImage.size)
|
||||
|
||||
// Create temporary file
|
||||
let originalName = url.deletingPathExtension().lastPathComponent
|
||||
let newName = "\(originalName)_no_bg.png"
|
||||
|
||||
guard let pngData = processedImage.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: pngData),
|
||||
let finalData = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
guard let tempURL = await TemporaryFileStorageService.shared.createTempFile(
|
||||
for: .data(finalData, suggestedName: newName)
|
||||
) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
return tempURL
|
||||
}
|
||||
|
||||
private func applyMask(_ mask: CVPixelBuffer, to image: CGImage) async throws -> CGImage {
|
||||
let ciImage = CIImage(cgImage: image)
|
||||
let maskImage = CIImage(cvPixelBuffer: mask)
|
||||
|
||||
let filter = CIFilter.blendWithMask()
|
||||
filter.inputImage = ciImage
|
||||
filter.maskImage = maskImage
|
||||
filter.backgroundImage = CIImage.empty()
|
||||
|
||||
guard let output = filter.outputImage else {
|
||||
throw ImageProcessingError.backgroundRemovalFailed
|
||||
}
|
||||
|
||||
let context = CIContext()
|
||||
guard let result = context.createCGImage(output, from: output.extent) else {
|
||||
throw ImageProcessingError.backgroundRemovalFailed
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Convert Image
|
||||
|
||||
/// Converts an image with specified options
|
||||
func convertImage(from url: URL, options: ImageConversionOptions) async throws -> URL? {
|
||||
guard var inputImage = NSImage(contentsOf: url) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
// Scale image if needed
|
||||
if let maxDim = options.maxDimension {
|
||||
inputImage = scaleImage(inputImage, maxDimension: maxDim)
|
||||
}
|
||||
|
||||
// Get image data based on format
|
||||
let imageData: Data?
|
||||
|
||||
if options.removeMetadata {
|
||||
// Create new image without metadata
|
||||
guard let cgImage = inputImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
let newImage = NSImage(cgImage: cgImage, size: inputImage.size)
|
||||
imageData = try convertToFormat(newImage, format: options.format, quality: options.compressionQuality)
|
||||
} else {
|
||||
imageData = try convertToFormat(inputImage, format: options.format, quality: options.compressionQuality)
|
||||
}
|
||||
|
||||
guard let data = imageData else {
|
||||
throw ImageProcessingError.conversionFailed
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let originalName = url.deletingPathExtension().lastPathComponent
|
||||
let newName = "\(originalName)_converted.\(options.format.fileExtension)"
|
||||
|
||||
guard let tempURL = await TemporaryFileStorageService.shared.createTempFile(
|
||||
for: .data(data, suggestedName: newName)
|
||||
) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
return tempURL
|
||||
}
|
||||
|
||||
private func convertToFormat(_ image: NSImage, format: ImageConversionOptions.ImageFormat, quality: Double) throws -> Data? {
|
||||
guard let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case .png:
|
||||
return bitmap.representation(using: .png, properties: [:])
|
||||
case .jpeg:
|
||||
let properties: [NSBitmapImageRep.PropertyKey: Any] = [
|
||||
.compressionFactor: quality
|
||||
]
|
||||
return bitmap.representation(using: .jpeg, properties: properties)
|
||||
case .tiff:
|
||||
let properties: [NSBitmapImageRep.PropertyKey: Any] = [
|
||||
.compressionMethod: NSNumber(value: NSBitmapImageRep.TIFFCompression.lzw.rawValue)
|
||||
]
|
||||
return bitmap.representation(using: .tiff, properties: properties)
|
||||
case .bmp:
|
||||
return bitmap.representation(using: .bmp, properties: [:])
|
||||
case .heic:
|
||||
// HEIC requires using CIContext
|
||||
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
let ciImage = CIImage(cgImage: cgImage)
|
||||
let context = CIContext()
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let options: [CIImageRepresentationOption: Any] = [
|
||||
CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String): quality
|
||||
]
|
||||
return try? context.heifRepresentation(of: ciImage, format: .RGBA8, colorSpace: colorSpace, options: options)
|
||||
}
|
||||
}
|
||||
|
||||
private func scaleImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||
guard maxDimension > 0 else { return image }
|
||||
|
||||
guard let srcCG = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return image
|
||||
}
|
||||
|
||||
let srcMax = max(srcCG.width, srcCG.height)
|
||||
if CGFloat(srcMax) <= maxDimension {
|
||||
return image // no downscaling needed
|
||||
}
|
||||
|
||||
let scale = maxDimension / CGFloat(srcMax)
|
||||
|
||||
let ciImage = CIImage(cgImage: srcCG)
|
||||
let lanczos = CIFilter.lanczosScaleTransform()
|
||||
lanczos.inputImage = ciImage
|
||||
lanczos.scale = Float(scale)
|
||||
lanczos.aspectRatio = 1.0
|
||||
|
||||
guard let output = lanczos.outputImage else {
|
||||
return image
|
||||
}
|
||||
|
||||
// Preserve the source color space for exact color matching
|
||||
let colorSpace = srcCG.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let ciContext = CIContext(options: [.workingColorSpace: colorSpace])
|
||||
|
||||
// Render using the CIContext with matching color space
|
||||
guard let dstCG = ciContext.createCGImage(output, from: output.extent, format: .RGBA8, colorSpace: colorSpace) else {
|
||||
return image
|
||||
}
|
||||
|
||||
return NSImage(cgImage: dstCG, size: NSSize(width: dstCG.width, height: dstCG.height))
|
||||
}
|
||||
|
||||
// MARK: - Create PDF
|
||||
|
||||
/// Creates a PDF from multiple image URLs
|
||||
func createPDF(from imageURLs: [URL], outputName: String? = nil) async throws -> URL? {
|
||||
guard !imageURLs.isEmpty else {
|
||||
throw ImageProcessingError.noImagesProvided
|
||||
}
|
||||
|
||||
let pdfDocument = PDFDocument()
|
||||
|
||||
for (index, url) in imageURLs.enumerated() {
|
||||
guard let image = NSImage(contentsOf: url) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let pdfPage = PDFPage(image: image)
|
||||
if let page = pdfPage {
|
||||
pdfDocument.insert(page, at: index)
|
||||
}
|
||||
}
|
||||
|
||||
guard pdfDocument.pageCount > 0 else {
|
||||
throw ImageProcessingError.pdfCreationFailed
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let name = outputName ?? "images_\(Date().timeIntervalSince1970).pdf"
|
||||
let pdfName = name.hasSuffix(".pdf") ? name : "\(name).pdf"
|
||||
|
||||
guard let pdfData = pdfDocument.dataRepresentation() else {
|
||||
throw ImageProcessingError.pdfCreationFailed
|
||||
}
|
||||
|
||||
guard let tempURL = await TemporaryFileStorageService.shared.createTempFile(
|
||||
for: .data(pdfData, suggestedName: pdfName)
|
||||
) else {
|
||||
throw ImageProcessingError.saveFailed
|
||||
}
|
||||
|
||||
return tempURL
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Checks if a URL is an image file
|
||||
func isImageFile(_ url: URL) -> Bool {
|
||||
guard let contentType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType else {
|
||||
return false
|
||||
}
|
||||
return contentType.conforms(to: .image)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum ImageProcessingError: LocalizedError {
|
||||
case invalidImage
|
||||
case backgroundRemovalFailed
|
||||
case conversionFailed
|
||||
case pdfCreationFailed
|
||||
case noImagesProvided
|
||||
case saveFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImage:
|
||||
return "The file is not a valid image"
|
||||
case .backgroundRemovalFailed:
|
||||
return "Failed to remove background from image"
|
||||
case .conversionFailed:
|
||||
return "Failed to convert image format"
|
||||
case .pdfCreationFailed:
|
||||
return "Failed to create PDF from images"
|
||||
case .noImagesProvided:
|
||||
return "No images were provided"
|
||||
case .saveFailed:
|
||||
return "Failed to save processed file"
|
||||
}
|
||||
}
|
||||
}
|
||||
134
DynamicIsland/components/Shelf/Services/QuickLookService.swift
Normal file
134
DynamicIsland/components/Shelf/Services/QuickLookService.swift
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//
|
||||
// QuickLookService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-07.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import SwiftUI
|
||||
import QuickLookUI
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class QuickLookService: ObservableObject {
|
||||
@Published var urls: [URL] = []
|
||||
@Published var selectedURL: URL?
|
||||
|
||||
@Published var isQuickLookOpen: Bool = false
|
||||
|
||||
private var previewPanel: QLPreviewPanel?
|
||||
private var dataSource: QuickLookDataSource?
|
||||
private var accessingURLs: [URL] = []
|
||||
private var previewPanelObserver: Any?
|
||||
|
||||
func show(urls: [URL], selectFirst: Bool = true, slideshow: Bool = false) {
|
||||
guard !urls.isEmpty else { return }
|
||||
stopAccessingCurrentURLs()
|
||||
accessingURLs = urls.filter { url in
|
||||
if url.isFileURL {
|
||||
return url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
return true
|
||||
}
|
||||
self.urls = accessingURLs
|
||||
self.isQuickLookOpen = true
|
||||
if selectFirst {
|
||||
self.selectedURL = accessingURLs.first
|
||||
}
|
||||
// Observe the shared Quick Look preview panel closing so we can relinquish security scope
|
||||
let panel = QLPreviewPanel.shared()
|
||||
// Remove any existing observer for previous panel
|
||||
if let prev = previewPanel {
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: prev)
|
||||
}
|
||||
previewPanel = panel
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(previewPanelWillClose(_:)), name: NSWindow.willCloseNotification, object: panel)
|
||||
}
|
||||
|
||||
func hide() {
|
||||
stopAccessingCurrentURLs()
|
||||
selectedURL = nil
|
||||
urls.removeAll()
|
||||
isQuickLookOpen = false
|
||||
if let panel = previewPanel, panel.isVisible {
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
if let panel = previewPanel {
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel)
|
||||
previewPanel = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAccessingCurrentURLs() {
|
||||
NSLog("Stopping access to \(accessingURLs.count) URLs")
|
||||
for url in accessingURLs where url.isFileURL {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
accessingURLs.removeAll()
|
||||
// If Quick Look panel was closed externally, also remove observer and clear reference
|
||||
if let panel = previewPanel {
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel)
|
||||
previewPanel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func showQuickLook(urls: [URL]) {
|
||||
show(urls: urls, selectFirst: true, slideshow: false)
|
||||
}
|
||||
|
||||
func updateSelection(urls: [URL]) {
|
||||
guard isQuickLookOpen else { return }
|
||||
show(urls: urls, selectFirst: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension QuickLookService {
|
||||
@objc private func previewPanelWillClose(_ notification: Notification) {
|
||||
guard let panel = notification.object as? QLPreviewPanel, panel === previewPanel else { return }
|
||||
// Ensure cleanup happens on main actor
|
||||
Task { @MainActor in
|
||||
stopAccessingCurrentURLs()
|
||||
selectedURL = nil
|
||||
urls.removeAll()
|
||||
isQuickLookOpen = false
|
||||
// Remove observer and clear reference
|
||||
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel)
|
||||
previewPanel = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct QuickLookPresenter: ViewModifier {
|
||||
@ObservedObject var service: QuickLookService
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.quickLookPreview($service.selectedURL, in: service.urls)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func quickLookPresenter(using service: QuickLookService) -> some View {
|
||||
self.modifier(QuickLookPresenter(service: service))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class QuickLookDataSource: NSObject, QLPreviewPanelDataSource, QLPreviewPanelDelegate {
|
||||
private let urls: [URL]
|
||||
|
||||
init(urls: [URL]) {
|
||||
self.urls = urls
|
||||
super.init()
|
||||
}
|
||||
|
||||
nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
|
||||
return urls.count
|
||||
}
|
||||
nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! {
|
||||
guard index >= 0 && index < urls.count else { return nil }
|
||||
return urls[index] as QLPreviewItem
|
||||
}
|
||||
}
|
||||
211
DynamicIsland/components/Shelf/Services/QuickShareService.swift
Normal file
211
DynamicIsland/components/Shelf/Services/QuickShareService.swift
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//
|
||||
// QuickShareService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Dynamic representation of a sharing provider discovered at runtime
|
||||
struct QuickShareProvider: Identifiable, Hashable, Sendable {
|
||||
var id: String
|
||||
var imageData: Data?
|
||||
var supportsRawText: Bool
|
||||
}
|
||||
|
||||
class QuickShareService: ObservableObject {
|
||||
static let shared = QuickShareService()
|
||||
|
||||
@Published var availableProviders: [QuickShareProvider] = []
|
||||
@Published var isPickerOpen = false
|
||||
private var cachedServices: [String: NSSharingService] = [:]
|
||||
// Hold security-scoped URLs during sharing
|
||||
private var sharingAccessingURLs: [URL] = []
|
||||
private var lifecycleDelegate: SharingLifecycleDelegate?
|
||||
|
||||
init() {
|
||||
Task {
|
||||
await discoverAvailableProviders()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider Discovery
|
||||
|
||||
@MainActor
|
||||
func discoverAvailableProviders() async {
|
||||
let finder = ShareServiceFinder()
|
||||
|
||||
// Use simple test items without creating actual temp files
|
||||
// This avoids issues with the Share Sheet retaining references to deleted files
|
||||
let testItems: [Any] = [
|
||||
URL(string:"http://example.com") ?? URL(fileURLWithPath: "/"),
|
||||
"Test Text" as NSString
|
||||
]
|
||||
|
||||
let services = await finder.findApplicableServices(for: testItems)
|
||||
|
||||
var providers: [QuickShareProvider] = []
|
||||
|
||||
for svc in services {
|
||||
let title = svc.title
|
||||
let imgData = svc.image.tiffRepresentation
|
||||
let supportsRawText = svc.canPerform(withItems: ["Test Text"])
|
||||
let provider = QuickShareProvider(id: title, imageData: imgData, supportsRawText: supportsRawText)
|
||||
if !providers.contains(provider) {
|
||||
providers.append(provider)
|
||||
cachedServices[title] = svc
|
||||
}
|
||||
}
|
||||
|
||||
if let idx = providers.firstIndex(where: { $0.id == "AirDrop" }) {
|
||||
let ad = providers.remove(at: idx)
|
||||
providers.insert(ad, at: 0)
|
||||
}
|
||||
|
||||
if !providers.contains(where: { $0.id == "System Share Menu" }) {
|
||||
providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true))
|
||||
}
|
||||
|
||||
self.availableProviders = providers
|
||||
|
||||
}
|
||||
|
||||
// MARK: - File Picker
|
||||
@MainActor
|
||||
func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async {
|
||||
guard !isPickerOpen else {
|
||||
print("⚠️ QuickShareService: File picker already open")
|
||||
return
|
||||
}
|
||||
|
||||
isPickerOpen = true
|
||||
SharingStateManager.shared.beginInteraction()
|
||||
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = true
|
||||
panel.title = "Select Files for \(provider.id)"
|
||||
panel.message = "Choose files to share via \(provider.id)"
|
||||
|
||||
let completion: (NSApplication.ModalResponse) -> Void = { [weak self] response in
|
||||
defer {
|
||||
self?.isPickerOpen = false
|
||||
SharingStateManager.shared.endInteraction()
|
||||
}
|
||||
|
||||
if response == .OK && !panel.urls.isEmpty {
|
||||
Task {
|
||||
await self?.shareFilesOrText(panel.urls, using: provider, from: view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = panel.runModal()
|
||||
completion(response)
|
||||
}
|
||||
|
||||
// MARK: - Sharing
|
||||
@MainActor
|
||||
func shareFilesOrText(_ items: [Any], using provider: QuickShareProvider, from view: NSView?) async {
|
||||
let fileURLs = items.compactMap { $0 as? URL }.filter { $0.isFileURL }
|
||||
// Stop any previous sharing access
|
||||
stopSharingAccessingURLs()
|
||||
// Start security-scoped access for all file URLs
|
||||
sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() }
|
||||
|
||||
// Setup lifecycle delegate to keep notch open during picker/service
|
||||
let delegate = SharingStateManager.shared.makeDelegate { [weak self] in
|
||||
self?.lifecycleDelegate = nil
|
||||
self?.stopSharingAccessingURLs()
|
||||
}
|
||||
lifecycleDelegate = delegate
|
||||
|
||||
if let svc = cachedServices[provider.id], svc.canPerform(withItems: items) {
|
||||
// For direct service path, explicitly mark service interaction start
|
||||
delegate.markServiceBegan()
|
||||
svc.delegate = delegate
|
||||
svc.perform(withItems: items)
|
||||
} else {
|
||||
let picker = NSSharingServicePicker(items: items)
|
||||
picker.delegate = delegate
|
||||
delegate.markPickerBegan()
|
||||
if let view {
|
||||
picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopSharingAccessingURLs() {
|
||||
NSLog("Stopping sharing access to URLs")
|
||||
for url in sharingAccessingURLs {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
sharingAccessingURLs.removeAll()
|
||||
}
|
||||
// MARK: - SharingServiceDelegate
|
||||
|
||||
private class SharingServiceDelegate: NSObject {}
|
||||
|
||||
func shareDroppedFiles(_ providers: [NSItemProvider], using shareProvider: QuickShareProvider, from view: NSView?) async {
|
||||
var itemsToShare: [Any] = []
|
||||
var foundText: String?
|
||||
|
||||
for provider in providers {
|
||||
if let webURL = await provider.extractURL() {
|
||||
itemsToShare.append(webURL)
|
||||
} else if foundText == nil, let text = await provider.extractText() {
|
||||
foundText = text
|
||||
} else if let itemFileURL = await provider.extractItem() {
|
||||
let resolvedURL = await resolveShelfItemBookmark(for: itemFileURL) ?? itemFileURL
|
||||
itemsToShare.append(resolvedURL)
|
||||
}
|
||||
}
|
||||
|
||||
// If text was found, prioritize sharing it.
|
||||
if let text = foundText {
|
||||
if shareProvider.supportsRawText {
|
||||
await shareFilesOrText([text], using: shareProvider, from: view)
|
||||
} else {
|
||||
if let tempTextURL = await TemporaryFileStorageService.shared.createTempFile(for: .text(text)) {
|
||||
await shareFilesOrText([tempTextURL], using: shareProvider, from: view)
|
||||
TemporaryFileStorageService.shared.removeTemporaryFileIfNeeded(at: tempTextURL)
|
||||
} else {
|
||||
await shareFilesOrText([text], using: shareProvider, from: view)
|
||||
}
|
||||
}
|
||||
} else if !itemsToShare.isEmpty {
|
||||
await shareFilesOrText(itemsToShare, using: shareProvider, from: view)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveShelfItemBookmark(for fileURL: URL) async -> URL? {
|
||||
let items = await ShelfStateViewModel.shared.items
|
||||
|
||||
for itm in items {
|
||||
if let resolved = await ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: itm) {
|
||||
if resolved.standardizedFileURL.path == fileURL.standardizedFileURL.path {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
print("❌ Failed to resolve bookmark for shelf item")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Storage Extension for Provider Selection
|
||||
|
||||
extension QuickShareProvider {
|
||||
static var defaultProvider: QuickShareProvider {
|
||||
let svc = QuickShareService.shared
|
||||
|
||||
if let airdrop = svc.availableProviders.first(where: { $0.id == "AirDrop" }) {
|
||||
return airdrop
|
||||
}
|
||||
return svc.availableProviders.first ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// ShareServiceFinder.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-06.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class ShareServiceFinder: NSObject, NSSharingServicePickerDelegate {
|
||||
|
||||
@MainActor
|
||||
private var onServicesCaptured: (([NSSharingService]) -> Void)?
|
||||
|
||||
/// Returns share services asynchronously without blocking the UI
|
||||
@MainActor
|
||||
func findApplicableServices(for items: [Any], timeout: TimeInterval = 2.0) async -> [NSSharingService] {
|
||||
|
||||
let dummyView = NSView(frame: .zero)
|
||||
let picker = NSSharingServicePicker(items: items)
|
||||
picker.delegate = self
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
var didResume = false
|
||||
|
||||
// Capture services callback
|
||||
Task { @MainActor in
|
||||
self.onServicesCaptured = { services in
|
||||
guard !didResume else { return }
|
||||
didResume = true
|
||||
continuation.resume(returning: services)
|
||||
}
|
||||
}
|
||||
|
||||
picker.show(relativeTo: dummyView.bounds, of: dummyView, preferredEdge: .minY)
|
||||
|
||||
|
||||
// Timeout task
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
guard !didResume else { return }
|
||||
didResume = true
|
||||
print("Warning: timed out waiting for sharing services")
|
||||
continuation.resume(returning: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NSSharingServicePickerDelegate
|
||||
|
||||
func sharingServicePicker(_ picker: NSSharingServicePicker,
|
||||
sharingServicesForItems items: [Any],
|
||||
proposedSharingServices proposed: [NSSharingService]) -> [NSSharingService] {
|
||||
Task { @MainActor in
|
||||
self.onServicesCaptured?(proposed)
|
||||
}
|
||||
return proposed
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// ShelfActionService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-07.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// A service providing common actions for `ShelfItem`s, such as opening, revealing, or copying paths.
|
||||
@MainActor
|
||||
enum ShelfActionService {
|
||||
|
||||
static func open(_ item: ShelfItem) {
|
||||
switch item.kind {
|
||||
case .file(let bookmark):
|
||||
handleBookmarkedFile(bookmark) { url in
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
case .link(let url):
|
||||
NSWorkspace.shared.open(url)
|
||||
case .text(let string):
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(string, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
static func reveal(_ item: ShelfItem) {
|
||||
guard case .file(let bookmark) = item.kind else { return }
|
||||
handleBookmarkedFile(bookmark) { url in
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
}
|
||||
|
||||
static func copyPath(_ item: ShelfItem) {
|
||||
guard case .file(let bookmark) = item.kind else { return }
|
||||
handleBookmarkedFile(bookmark) { url in
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url.path, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
static func remove(_ item: ShelfItem) {
|
||||
ShelfStateViewModel.shared.remove(item)
|
||||
}
|
||||
|
||||
private static func handleBookmarkedFile(_ bookmarkData: Data, action: @escaping @Sendable (URL) -> Void) {
|
||||
Task {
|
||||
let bookmark = Bookmark(data: bookmarkData)
|
||||
if let url = bookmark.resolveURL() {
|
||||
url.accessSecurityScopedResource { accessibleURL in
|
||||
action(accessibleURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// ShelfDropService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-26.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ShelfDropService {
|
||||
static func items(from providers: [NSItemProvider]) async -> [ShelfItem] {
|
||||
var results: [ShelfItem] = []
|
||||
|
||||
for provider in providers {
|
||||
if let item = await processProvider(provider) {
|
||||
results.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private static func processProvider(_ provider: NSItemProvider) async -> ShelfItem? {
|
||||
if let actualFileURL = await provider.extractFileURL() {
|
||||
if let bookmark = createBookmark(for: actualFileURL) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let url = await provider.extractURL() {
|
||||
if url.isFileURL {
|
||||
if let bookmark = createBookmark(for: url) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false)
|
||||
}
|
||||
} else {
|
||||
return await ShelfItem(kind: .link(url: url), isTemporary: false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let text = await provider.extractText() {
|
||||
return await ShelfItem(kind: .text(string: text), isTemporary: false)
|
||||
}
|
||||
|
||||
if let data = await provider.loadData() {
|
||||
if let tempDataURL = await TemporaryFileStorageService.shared.createTempFile(for: .data(data, suggestedName: provider.suggestedName)),
|
||||
let bookmark = createBookmark(for: tempDataURL) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let fileURL = await provider.extractItem() {
|
||||
if let bookmark = createBookmark(for: fileURL) {
|
||||
return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func createBookmark(for url: URL) -> Data? {
|
||||
return (try? Bookmark(url: url))?.data
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// ShelfPersistenceService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Access model types
|
||||
@_exported import struct Foundation.URL
|
||||
|
||||
|
||||
final class ShelfPersistenceService {
|
||||
static let shared = ShelfPersistenceService()
|
||||
|
||||
private let fileURL: URL
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private init() {
|
||||
let fm = FileManager.default
|
||||
let support = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let dir = (support ?? fm.temporaryDirectory).appendingPathComponent("DynamicIsland", isDirectory: true).appendingPathComponent("Shelf", isDirectory: true)
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("items.json")
|
||||
encoder.outputFormatting = [.prettyPrinted]
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
func load() -> [ShelfItem] {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return [] }
|
||||
|
||||
// Try to decode as array first (normal case)
|
||||
if let items = try? decoder.decode([ShelfItem].self, from: data) {
|
||||
return items
|
||||
}
|
||||
|
||||
// If array decoding fails, try to decode individual items
|
||||
do {
|
||||
// Parse as JSON array to get individual item data
|
||||
guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any] else {
|
||||
print("⚠️ Shelf persistence file is not a valid JSON array")
|
||||
return []
|
||||
}
|
||||
|
||||
var validItems: [ShelfItem] = []
|
||||
var failedCount = 0
|
||||
|
||||
for (index, jsonItem) in jsonArray.enumerated() {
|
||||
do {
|
||||
let itemData = try JSONSerialization.data(withJSONObject: jsonItem)
|
||||
let item = try decoder.decode(ShelfItem.self, from: itemData)
|
||||
validItems.append(item)
|
||||
} catch {
|
||||
failedCount += 1
|
||||
print("⚠️ Failed to decode shelf item at index \(index): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
if failedCount > 0 {
|
||||
print("📦 Successfully loaded \(validItems.count) shelf items, discarded \(failedCount) corrupted items")
|
||||
}
|
||||
|
||||
return validItems
|
||||
} catch {
|
||||
print("❌ Failed to parse shelf persistence file: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ items: [ShelfItem]) {
|
||||
do {
|
||||
let data = try encoder.encode(items)
|
||||
try data.write(to: fileURL, options: Data.WritingOptions.atomic)
|
||||
} catch {
|
||||
print("Failed to save shelf items: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
//
|
||||
// TemporaryFileStorageService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum TempFileType {
|
||||
case data(Data, suggestedName: String?)
|
||||
case text(String)
|
||||
case url(URL)
|
||||
}
|
||||
|
||||
class TemporaryFileStorageService {
|
||||
static let shared = TemporaryFileStorageService()
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Creates a temporary file and tracks it for manual cleanup
|
||||
func createTempFile(for type: TempFileType) async -> URL? {
|
||||
return await withCheckedContinuation { continuation in
|
||||
let result = createTempFile(for: type)
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
func removeTemporaryFileIfNeeded(at url: URL) {
|
||||
let tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
|
||||
guard url.path.hasPrefix(tempDirectory.path) else {
|
||||
print("Attempted to remove temporary file outside temp directory: \(url.path)")
|
||||
return
|
||||
}
|
||||
|
||||
let folderURL = url.deletingLastPathComponent()
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
print("Deleted file: \(url.path)")
|
||||
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: folderURL.path)
|
||||
if contents.isEmpty {
|
||||
try FileManager.default.removeItem(at: folderURL)
|
||||
print("Folder was empty, deleted folder: \(folderURL.path)")
|
||||
} else {
|
||||
print("Folder not deleted — it still contains \(contents.count) item(s).")
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Implementation
|
||||
|
||||
private func createTempFile(for type: TempFileType) -> URL? {
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let uuid = UUID().uuidString
|
||||
|
||||
switch type {
|
||||
case .data(let data, let suggestedName):
|
||||
let filename = suggestedName ?? ".dat"
|
||||
let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true)
|
||||
let fileURL = dirURL.appendingPathComponent(filename)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
case .text(let string):
|
||||
let filename = "\(uuid).txt"
|
||||
let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true)
|
||||
let fileURL = dirURL.appendingPathComponent(filename)
|
||||
|
||||
guard let data = string.data(using: .utf8) else {
|
||||
print("❌ Failed to convert text to data")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
case .url(let url):
|
||||
let filename = "\(url.host ?? uuid).webloc"
|
||||
let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true)
|
||||
let fileURL = dirURL.appendingPathComponent(filename)
|
||||
|
||||
let weblocContent = createWeblocContent(for: url)
|
||||
guard let data = weblocContent.data(using: String.Encoding.utf8) else {
|
||||
print("❌ Failed to create webloc data")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createFile(at url: URL, data: Data) -> URL? {
|
||||
do {
|
||||
try data.write(to: url)
|
||||
return url
|
||||
} catch {
|
||||
print("❌ Failed to create temp file at \(url.path): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func createZip(from urls: [URL], suggestedName: String? = nil) async -> URL? {
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let uuid = UUID().uuidString
|
||||
let workingDir = tempDir.appendingPathComponent("zip_\(uuid)", isDirectory: true)
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
print("❌ Failed to create zip working directory: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to run zip process
|
||||
func runZip(arguments: [String], currentDirectory: URL) -> Bool {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
proc.arguments = arguments
|
||||
proc.currentDirectoryURL = currentDirectory
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
return proc.terminationStatus == 0
|
||||
} catch {
|
||||
print("❌ Failed to run zip: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Single-item optimization: do not copy contents into the working dir.
|
||||
if urls.count == 1, let src = urls.first {
|
||||
let isDir = (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
let baseName = src.lastPathComponent
|
||||
let archiveName: String
|
||||
if isDir {
|
||||
// Folder: name as FolderName.zip and include the folder itself in the archive
|
||||
archiveName = "\(baseName).zip"
|
||||
let archiveURL = workingDir.appendingPathComponent(archiveName)
|
||||
// Run zip from the parent directory so the folder is stored as top-level entry
|
||||
let parent = src.deletingLastPathComponent()
|
||||
let args = ["-r", "-q", archiveURL.path, baseName]
|
||||
let ok = runZip(arguments: args, currentDirectory: parent)
|
||||
if ok {
|
||||
return archiveURL
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// File: include the file only (no parent folders). Name should include original extension.
|
||||
archiveName = "\(baseName).zip"
|
||||
let archiveURL = workingDir.appendingPathComponent(archiveName)
|
||||
let parent = src.deletingLastPathComponent()
|
||||
// -j to junk paths and store only the file
|
||||
let args = ["-j", "-q", archiveURL.path, baseName]
|
||||
let ok = runZip(arguments: args, currentDirectory: parent)
|
||||
if ok {
|
||||
return archiveURL
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-item: copy items into working dir (so their relative structure is preserved), zip, then remove copies.
|
||||
for src in urls {
|
||||
let dest = workingDir.appendingPathComponent(src.lastPathComponent)
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
// Avoid collision by appending a suffix
|
||||
let unique = "\(UUID().uuidString)_\(src.lastPathComponent)"
|
||||
try FileManager.default.copyItem(at: src, to: workingDir.appendingPathComponent(unique))
|
||||
} else {
|
||||
try FileManager.default.copyItem(at: src, to: dest)
|
||||
}
|
||||
} catch {
|
||||
print("⚠️ Failed to copy \(src.path) to working dir: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let archiveName = suggestedName ?? "Archive.zip"
|
||||
let archiveURL = workingDir.appendingPathComponent(archiveName)
|
||||
let args = ["-r", "-q", archiveURL.path, "."]
|
||||
let ok = runZip(arguments: args, currentDirectory: workingDir)
|
||||
if ok {
|
||||
// Remove the copied (uncompressed) items so the temp folder contains only the archive
|
||||
do {
|
||||
let contents = try FileManager.default.contentsOfDirectory(at: workingDir, includingPropertiesForKeys: nil)
|
||||
for file in contents {
|
||||
if file.standardizedFileURL != archiveURL.standardizedFileURL {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("⚠️ Failed to cleanup working directory after zip: \(error)")
|
||||
}
|
||||
return archiveURL
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Creation Helpers
|
||||
|
||||
|
||||
private func createWeblocContent(for url: URL) -> String {
|
||||
return """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>URL</key>
|
||||
<string>\(url.absoluteString)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// ThumbnailService.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-07.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import QuickLookThumbnailing
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
actor ThumbnailService {
|
||||
static let shared = ThumbnailService()
|
||||
|
||||
private var cache: [String: NSImage] = [:]
|
||||
private var pendingRequests: [String: Task<NSImage?, Never>] = [:]
|
||||
private let thumbnailGenerator = QLThumbnailGenerator.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
func thumbnail(for url: URL, size: CGSize) async -> NSImage? {
|
||||
let cacheKey = "\(url.path)_\(size.width)x\(size.height)"
|
||||
|
||||
if let cached = cache[cacheKey] {
|
||||
return cached
|
||||
}
|
||||
|
||||
if let pending = pendingRequests[cacheKey] {
|
||||
return await pending.value
|
||||
}
|
||||
|
||||
let task = Task<NSImage?, Never> {
|
||||
let thumbnail = await generateQuickLookThumbnail(for: url, size: size)
|
||||
if let thumbnail = thumbnail {
|
||||
cache[cacheKey] = thumbnail
|
||||
}
|
||||
pendingRequests[cacheKey] = nil
|
||||
return thumbnail
|
||||
}
|
||||
|
||||
pendingRequests[cacheKey] = task
|
||||
return await task.value
|
||||
}
|
||||
|
||||
func clearCache() {
|
||||
cache.removeAll()
|
||||
}
|
||||
|
||||
func clearCache(for url: URL) {
|
||||
cache = cache.filter { !$0.key.starts(with: url.path) }
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func generateQuickLookThumbnail(for url: URL, size: CGSize) async -> NSImage? {
|
||||
let scale = await MainActor.run { NSScreen.main?.backingScaleFactor ?? 2.0 }
|
||||
|
||||
return await url.accessSecurityScopedResource { scopedURL in
|
||||
NSLog("🔐 ThumbnailService: obtaining security scope for \(scopedURL.path)")
|
||||
let request = QLThumbnailGenerator.Request(
|
||||
fileAt: scopedURL,
|
||||
size: size,
|
||||
scale: scale,
|
||||
representationTypes: .all
|
||||
)
|
||||
request.iconMode = true
|
||||
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<NSImage?, Never>) in
|
||||
thumbnailGenerator.generateBestRepresentation(for: request) { representation, error in
|
||||
if let rep = representation {
|
||||
NSLog("🔍 ThumbnailService: generated thumbnail for \(scopedURL.path)")
|
||||
continuation.resume(returning: rep.nsImage)
|
||||
} else {
|
||||
if let err = error {
|
||||
NSLog("⚠️ ThumbnailService: thumbnail error for \(scopedURL.path): \(err.localizedDescription)")
|
||||
}
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension QLThumbnailRepresentation {
|
||||
var nsImage: NSImage {
|
||||
return NSImage(cgImage: self.cgImage, size: self.cgImage.size)
|
||||
}
|
||||
}
|
||||
|
||||
extension CGImage {
|
||||
var size: NSSize {
|
||||
return NSSize(width: self.width, height: self.height)
|
||||
}
|
||||
}
|
||||
1112
DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift
Normal file
1112
DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
// ShelfSelectionModel.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
private let _shelfTypeAnchor: Bool = {
|
||||
_ = String(describing: ShelfItem.self)
|
||||
return true
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
final class ShelfSelectionModel: ObservableObject {
|
||||
static let shared = ShelfSelectionModel()
|
||||
|
||||
@Published private(set) var selectedIDs: Set<UUID> = []
|
||||
|
||||
// Anchor for shift-range selection
|
||||
private var lastAnchorID: UUID? = nil
|
||||
|
||||
func isSelected(_ id: UUID) -> Bool { selectedIDs.contains(id) }
|
||||
|
||||
var hasSelection: Bool { !selectedIDs.isEmpty }
|
||||
|
||||
var firstSelectedItem: ShelfItem? {
|
||||
guard let firstID = selectedIDs.first else { return nil }
|
||||
return ShelfStateViewModel.shared.items.first(where: { $0.id == firstID })
|
||||
}
|
||||
|
||||
func selectedItems(in allItems: [ShelfItem]) -> [ShelfItem] {
|
||||
allItems.filter { selectedIDs.contains($0.id) }
|
||||
}
|
||||
|
||||
func selectSingle(_ item: ShelfItem) {
|
||||
selectedIDs = [item.id]
|
||||
lastAnchorID = item.id
|
||||
}
|
||||
|
||||
func toggle(_ item: ShelfItem) {
|
||||
if selectedIDs.contains(item.id) {
|
||||
selectedIDs.remove(item.id)
|
||||
} else {
|
||||
selectedIDs.insert(item.id)
|
||||
}
|
||||
lastAnchorID = item.id
|
||||
}
|
||||
|
||||
func shiftSelect(to item: ShelfItem, in allItems: [ShelfItem]) {
|
||||
// Determine anchor
|
||||
let anchorID = lastAnchorID ?? selectedIDs.first ?? item.id
|
||||
guard let startIndex = allItems.firstIndex(where: { $0.id == anchorID }),
|
||||
let endIndex = allItems.firstIndex(where: { $0.id == item.id }) else {
|
||||
// Fallback to single select if indices not found
|
||||
return selectSingle(item)
|
||||
}
|
||||
let lower = min(startIndex, endIndex)
|
||||
let upper = max(startIndex, endIndex)
|
||||
let rangeIDs = allItems[lower...upper].map { $0.id }
|
||||
selectedIDs = Set(rangeIDs)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
selectedIDs.removeAll()
|
||||
lastAnchorID = nil
|
||||
}
|
||||
|
||||
// Keep anchor sane if items array changed drastically (optional helper)
|
||||
func ensureValidAnchor(in allItems: [ShelfItem]) {
|
||||
if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) {
|
||||
lastAnchorID = selectedIDs.first
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var isDragging: Bool = false
|
||||
|
||||
func beginDrag() {
|
||||
isDragging = true
|
||||
}
|
||||
|
||||
func endDrag() {
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
//
|
||||
// ShelfStateViewModel.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-10-09.
|
||||
|
||||
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<Void, Never>?
|
||||
|
||||
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<String> = Set(merged.map { $0.identityKey })
|
||||
for it in newItems {
|
||||
let key = it.identityKey
|
||||
if !seen.contains(key) {
|
||||
merged.append(it)
|
||||
seen.insert(key)
|
||||
}
|
||||
}
|
||||
items = merged
|
||||
}
|
||||
|
||||
func remove(_ item: ShelfItem) {
|
||||
item.cleanupStoredData()
|
||||
items.removeAll { $0.id == item.id }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
29
DynamicIsland/components/Shelf/Views/DragPreviewView.swift
Normal file
29
DynamicIsland/components/Shelf/Views/DragPreviewView.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct DragPreviewView: View {
|
||||
let thumbnail: NSImage?
|
||||
let displayName: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 4) {
|
||||
Image(nsImage: thumbnail ?? NSImage())
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text(displayName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(Color.accentColor))
|
||||
.frame(alignment: .top)
|
||||
}
|
||||
.frame(width: 105)
|
||||
}
|
||||
}
|
||||
135
DynamicIsland/components/Shelf/Views/FileShareView.swift
Normal file
135
DynamicIsland/components/Shelf/Views/FileShareView.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//
|
||||
// FileShareView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct FileShareView: View {
|
||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||
@StateObject private var quickShare = QuickShareService.shared
|
||||
@Default(.quickShareProvider) var quickShareProvider: String
|
||||
|
||||
@State private var hostView: NSView?
|
||||
@State private var interactionNonce: UUID = .init()
|
||||
@State private var isProcessing = false
|
||||
|
||||
private var selectedProvider: QuickShareProvider {
|
||||
quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
dropArea
|
||||
.background(NSViewHost(view: $hostView))
|
||||
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in
|
||||
interactionNonce = .init()
|
||||
vm.dropEvent = true
|
||||
Task { await handleDrop(providers) }
|
||||
return true
|
||||
}
|
||||
.onTapGesture {
|
||||
Task {
|
||||
await handleClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dropArea: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(
|
||||
LinearGradient(colors: [Color.black.opacity(0.35), Color.black.opacity(0.20)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(
|
||||
vm.dropZoneTargeting
|
||||
? Color.accentColor.opacity(0.9)
|
||||
: Color.white.opacity(0.1),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.6), radius: 6, x: 0, y: 2)
|
||||
|
||||
// Content
|
||||
VStack(spacing: 5) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(
|
||||
vm.dropZoneTargeting ? 0.11 : 0.09
|
||||
))
|
||||
.frame(width: 55, height: 55)
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
Group {
|
||||
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
|
||||
Image(nsImage: nsImg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.frame(width: 34, height: 34)
|
||||
.foregroundStyle(
|
||||
vm.dropZoneTargeting ? Color.accentColor : Color.gray
|
||||
)
|
||||
.scaleEffect(
|
||||
vm.dropZoneTargeting ? 1.06 : 1.0
|
||||
)
|
||||
.animation(.spring(response: 0.36, dampingFraction: 0.7), value: vm.dropZoneTargeting)
|
||||
}
|
||||
|
||||
Text(selectedProvider.id)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
}
|
||||
.padding(18)
|
||||
|
||||
// Loading overlay
|
||||
if isProcessing || quickShare.isPickerOpen {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.black.opacity(0.3))
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func handleDrop(_ providers: [NSItemProvider]) async {
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
await quickShare.shareDroppedFiles(providers, using: selectedProvider, from: hostView)
|
||||
}
|
||||
|
||||
private func handleClick() async {
|
||||
await quickShare.showFilePicker(for: selectedProvider, from: hostView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host NSView extractor for anchoring share sheet
|
||||
|
||||
private struct NSViewHost: NSViewRepresentable {
|
||||
@Binding var view: NSView?
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let v = NSView(frame: .zero)
|
||||
DispatchQueue.main.async { self.view = v }
|
||||
return v
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async { self.view = nsView }
|
||||
}
|
||||
}
|
||||
371
DynamicIsland/components/Shelf/Views/ShelfItemView.swift
Normal file
371
DynamicIsland/components/Shelf/Views/ShelfItemView.swift
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
//
|
||||
// ShelfItemView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
import QuickLook
|
||||
|
||||
struct ShelfItemView: View {
|
||||
let item: ShelfItem
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var selection = ShelfSelectionModel.shared
|
||||
@StateObject private var viewModel: ShelfItemViewModel
|
||||
@EnvironmentObject private var quickLookService: QuickLookService
|
||||
@State private var showStack = false
|
||||
@State private var cachedPreviewImage: NSImage?
|
||||
@State private var debouncedDropTarget = false
|
||||
|
||||
private var isSelected: Bool { viewModel.isSelected }
|
||||
private var shouldHideDuringDrag: Bool { selection.isDragging && selection.isSelected(item.id) && false }
|
||||
|
||||
init(item: ShelfItem) {
|
||||
self.item = item
|
||||
_viewModel = StateObject(wrappedValue: ShelfItemViewModel(item: item))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if !shouldHideDuringDrag {
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
iconView
|
||||
textView
|
||||
}
|
||||
.frame(width: 105)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 5)
|
||||
.background(backgroundView)
|
||||
.contentShape(Rectangle())
|
||||
.animation(.easeInOut(duration: 0.1), value: debouncedDropTarget)
|
||||
.animation(.easeInOut(duration: 0.1), value: isSelected)
|
||||
|
||||
DraggableClickHandler(
|
||||
item: item,
|
||||
viewModel: viewModel,
|
||||
cachedPreviewImage: $cachedPreviewImage,
|
||||
dragPreviewContent: {
|
||||
DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName)
|
||||
},
|
||||
onRightClick: viewModel.handleRightClick,
|
||||
onClick: { event, nsview in
|
||||
viewModel.handleClick(event: event, view: nsview)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: 105)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 5)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.isDropTargeted) { _, targeted in
|
||||
vm.dragDetectorTargeting = targeted
|
||||
// Debounce drop target state changes
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
debouncedDropTarget = targeted
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.loadThumbnail()
|
||||
// Pre-render drag preview once on appear
|
||||
if cachedPreviewImage == nil {
|
||||
cachedPreviewImage = await renderDragPreview()
|
||||
}
|
||||
}
|
||||
viewModel.onQuickLookRequest = { urls in
|
||||
quickLookService.show(urls: urls, selectFirst: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.thumbnail) { _, _ in
|
||||
// Invalidate cached preview when thumbnail changes
|
||||
Task {
|
||||
cachedPreviewImage = await renderDragPreview()
|
||||
}
|
||||
}
|
||||
.quickLookPresenter(using: quickLookService)
|
||||
}
|
||||
|
||||
// MARK: - View Components
|
||||
|
||||
private var iconView: some View {
|
||||
Image(nsImage: viewModel.thumbnail ?? item.icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2)
|
||||
}
|
||||
|
||||
private var textView: some View {
|
||||
Text(item.displayName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(height: 30, alignment: .top)
|
||||
}
|
||||
|
||||
private var backgroundView: some View {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(
|
||||
strokeColor,
|
||||
lineWidth: strokeWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
if debouncedDropTarget {
|
||||
return Color.accentColor.opacity(0.25)
|
||||
} else if isSelected {
|
||||
return Color.accentColor.opacity(0.15)
|
||||
} else {
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
private var strokeColor: Color {
|
||||
if debouncedDropTarget {
|
||||
return Color.accentColor.opacity(0.9)
|
||||
} else if isSelected {
|
||||
return Color.accentColor.opacity(0.8)
|
||||
} else {
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
private var strokeWidth: CGFloat {
|
||||
if debouncedDropTarget {
|
||||
return 3
|
||||
} else if isSelected {
|
||||
return 2
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Preview Rendering
|
||||
|
||||
@MainActor
|
||||
private func renderDragPreview() async -> NSImage {
|
||||
let content = DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
return renderer.nsImage ?? (viewModel.thumbnail ?? item.icon)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Draggable Click Handler with NSDraggingSource
|
||||
private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
|
||||
let item: ShelfItem
|
||||
let viewModel: ShelfItemViewModel
|
||||
@Binding var cachedPreviewImage: NSImage?
|
||||
@ViewBuilder let dragPreviewContent: () -> Content
|
||||
let onRightClick: (NSEvent, NSView) -> Void
|
||||
let onClick: (NSEvent, NSView) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> DraggableClickView {
|
||||
let view = DraggableClickView()
|
||||
view.item = item
|
||||
view.viewModel = viewModel
|
||||
view.dragPreviewImage = cachedPreviewImage ?? renderDragPreview()
|
||||
view.onRightClick = onRightClick
|
||||
view.onClick = onClick
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: DraggableClickView, context: Context) {
|
||||
nsView.item = item
|
||||
nsView.viewModel = viewModel
|
||||
// Only update preview if cached version is available
|
||||
if let cached = cachedPreviewImage {
|
||||
nsView.dragPreviewImage = cached
|
||||
}
|
||||
nsView.onRightClick = onRightClick
|
||||
nsView.onClick = onClick
|
||||
}
|
||||
|
||||
private func renderDragPreview() -> NSImage {
|
||||
let content = dragPreviewContent()
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
|
||||
if let nsImage = renderer.nsImage {
|
||||
return nsImage
|
||||
}
|
||||
|
||||
// Fallback to icon if rendering fails
|
||||
return viewModel.thumbnail ?? item.icon
|
||||
}
|
||||
|
||||
final class DraggableClickView: NSView, NSDraggingSource {
|
||||
var item: ShelfItem!
|
||||
weak var viewModel: ShelfItemViewModel?
|
||||
var dragPreviewImage: NSImage?
|
||||
var onRightClick: ((NSEvent, NSView) -> Void)?
|
||||
var onClick: ((NSEvent, NSView) -> Void)?
|
||||
|
||||
private var mouseDownEvent: NSEvent?
|
||||
private let dragThreshold: CGFloat = 3.0
|
||||
private var draggedURLs: [URL] = []
|
||||
private var draggedItems: [ShelfItem] = []
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
onRightClick?(event, self)
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
mouseDownEvent = event
|
||||
onClick?(event, self)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard let mouseDownEvent = mouseDownEvent else {
|
||||
super.mouseDragged(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
let dragDistance = hypot(
|
||||
event.locationInWindow.x - mouseDownEvent.locationInWindow.x,
|
||||
event.locationInWindow.y - mouseDownEvent.locationInWindow.y
|
||||
)
|
||||
|
||||
if dragDistance > dragThreshold {
|
||||
startDragSession(with: event)
|
||||
self.mouseDownEvent = nil
|
||||
} else {
|
||||
super.mouseDragged(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
private func startDragSession(with event: NSEvent) {
|
||||
// Prepare dragging items
|
||||
let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items)
|
||||
let itemsToDrag: [ShelfItem]
|
||||
|
||||
if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) {
|
||||
itemsToDrag = selectedItems
|
||||
} else {
|
||||
itemsToDrag = [item]
|
||||
}
|
||||
|
||||
// Store items being dragged for auto-remove feature
|
||||
draggedItems = itemsToDrag
|
||||
|
||||
// Create dragging items for AppKit
|
||||
var draggingItems: [NSDraggingItem] = []
|
||||
|
||||
for dragItem in itemsToDrag {
|
||||
if let pasteboardItem = createPasteboardItem(for: dragItem) {
|
||||
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
|
||||
|
||||
// Use the drag preview image
|
||||
let image = dragPreviewImage ?? dragItem.icon
|
||||
let imageFrame = NSRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.size.width,
|
||||
height: image.size.height
|
||||
)
|
||||
draggingItem.setDraggingFrame(imageFrame, contents: image)
|
||||
|
||||
draggingItems.append(draggingItem)
|
||||
}
|
||||
}
|
||||
|
||||
guard !draggingItems.isEmpty else { return }
|
||||
|
||||
beginDraggingSession(with: draggingItems, event: event, source: self)
|
||||
}
|
||||
|
||||
private func createPasteboardItem(for item: ShelfItem) -> NSPasteboardItem? {
|
||||
let pasteboardItem = NSPasteboardItem()
|
||||
|
||||
switch item.kind {
|
||||
case .file:
|
||||
guard let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) else {
|
||||
pasteboardItem.setString(item.displayName, forType: .string)
|
||||
return pasteboardItem
|
||||
}
|
||||
|
||||
// Start accessing security-scoped resource and keep it active during drag
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
draggedURLs.append(url)
|
||||
NSLog("🔐 Started security-scoped access for drag: \(url.path)")
|
||||
}
|
||||
|
||||
pasteboardItem.setString(url.absoluteString, forType: .fileURL)
|
||||
pasteboardItem.setString(url.path, forType: .string)
|
||||
return pasteboardItem
|
||||
|
||||
case .text(let string):
|
||||
pasteboardItem.setString(string, forType: .string)
|
||||
return pasteboardItem
|
||||
|
||||
case .link(let url):
|
||||
pasteboardItem.setString(url.absoluteString, forType: .URL)
|
||||
pasteboardItem.setString(url.absoluteString, forType: .string)
|
||||
return pasteboardItem
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSDraggingSource
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
||||
// When copyOnDrag is enabled, only allow copy operations
|
||||
if Defaults[.copyOnDrag] {
|
||||
return [.copy]
|
||||
}
|
||||
|
||||
switch context {
|
||||
case .outsideApplication:
|
||||
return [.copy, .move]
|
||||
case .withinApplication:
|
||||
return [.copy, .move, .generic]
|
||||
@unknown default:
|
||||
return [.copy]
|
||||
}
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) {
|
||||
ShelfSelectionModel.shared.beginDrag()
|
||||
}
|
||||
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
ShelfSelectionModel.shared.endDrag()
|
||||
|
||||
// Stop accessing security-scoped resources after drag completes
|
||||
for url in draggedURLs {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
NSLog("🔐 Stopped security-scoped access after drag: \(url.path)")
|
||||
}
|
||||
draggedURLs.removeAll()
|
||||
|
||||
// Auto-remove items from shelf if enabled and drag succeeded
|
||||
if Defaults[.autoRemoveShelfItems] && !operation.isEmpty {
|
||||
for item in draggedItems {
|
||||
ShelfStateViewModel.shared.remove(item)
|
||||
}
|
||||
}
|
||||
draggedItems.removeAll()
|
||||
}
|
||||
|
||||
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
115
DynamicIsland/components/Shelf/Views/ShelfView.swift
Normal file
115
DynamicIsland/components/Shelf/Views/ShelfView.swift
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//
|
||||
// ShelfItemView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-09-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct ShelfView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@StateObject var tvm = ShelfStateViewModel.shared
|
||||
@StateObject var selection = ShelfSelectionModel.shared
|
||||
@StateObject private var quickLookService = QuickLookService()
|
||||
private let spacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
FileShareView()
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.environmentObject(vm)
|
||||
panel
|
||||
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in
|
||||
handleDrop(providers: providers)
|
||||
}
|
||||
}
|
||||
// Bind Quick Look to shelf selection
|
||||
.onChange(of: selection.selectedIDs) {
|
||||
updateQuickLookSelection()
|
||||
}
|
||||
.quickLookPresenter(using: quickLookService)
|
||||
}
|
||||
|
||||
private func handleDrop(providers: [NSItemProvider]) -> Bool {
|
||||
guard !selection.isDragging else { return false }
|
||||
vm.dropEvent = true
|
||||
ShelfStateViewModel.shared.load(providers)
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateQuickLookSelection() {
|
||||
guard quickLookService.isQuickLookOpen && !selection.selectedIDs.isEmpty else { return }
|
||||
|
||||
let selectedItems = selection.selectedItems(in: tvm.items)
|
||||
let urls: [URL] = selectedItems.compactMap { item in
|
||||
if let fileURL = item.fileURL {
|
||||
return fileURL
|
||||
}
|
||||
if case .link(let url) = item.kind {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !urls.isEmpty {
|
||||
quickLookService.updateSelection(urls: urls)
|
||||
}
|
||||
}
|
||||
|
||||
var panel: some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(
|
||||
vm.dragDetectorTargeting
|
||||
? Color.accentColor.opacity(0.9)
|
||||
: Color.white.opacity(0.1),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
|
||||
)
|
||||
.overlay {
|
||||
content
|
||||
.padding()
|
||||
}
|
||||
.transaction { transaction in
|
||||
transaction.animation = vm.animation
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selection.clear() }
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
Group {
|
||||
if tvm.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "tray.and.arrow.down")
|
||||
.symbolVariant(.fill)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.white, .gray)
|
||||
.imageScale(.large)
|
||||
|
||||
Text("Drop files here")
|
||||
.foregroundStyle(.gray)
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
} else {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(tvm.items) { item in
|
||||
ShelfItemView(item: item)
|
||||
.environmentObject(quickLookService)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(-spacing)
|
||||
.scrollIndicators(.never)
|
||||
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in
|
||||
handleDrop(providers: providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ShelfStateViewModel.shared.cleanupInvalidItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue