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
|
|
@ -440,8 +440,8 @@
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(SDKROOT)/System/Library/Frameworks",
|
||||||
"$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks",
|
"$(SDKROOT)/System/Library/PrivateFrameworks",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
|
|
@ -509,8 +509,8 @@
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(SDKROOT)/System/Library/Frameworks",
|
||||||
"$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks",
|
"$(SDKROOT)/System/Library/PrivateFrameworks",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ struct DynamicIslandHeader: View {
|
||||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||||
@ObservedObject var tvm = TrayDrop.shared
|
@ObservedObject var shelfState = ShelfStateViewModel.shared
|
||||||
@ObservedObject var timerManager = TimerManager.shared
|
@ObservedObject var timerManager = TimerManager.shared
|
||||||
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
||||||
@State private var showClipboardPopover = false
|
@State private var showClipboardPopover = false
|
||||||
|
|
@ -26,10 +26,9 @@ struct DynamicIslandHeader: View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
if !Defaults[.enableMinimalisticUI] {
|
if !Defaults[.enableMinimalisticUI] {
|
||||||
HStack {
|
HStack {
|
||||||
if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.dynamicShelf] {
|
let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty
|
||||||
|
if shouldShowTabs {
|
||||||
TabSelectionView()
|
TabSelectionView()
|
||||||
} else if vm.notchState == .open {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
|
||||||
|
|
@ -3,62 +3,10 @@ import SwiftUI
|
||||||
|
|
||||||
struct NotchShelfView: View {
|
struct NotchShelfView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@ObservedObject var tvm = TrayDrop.shared
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
ShelfView()
|
||||||
AirDropView()
|
.environmentObject(vm)
|
||||||
panel
|
|
||||||
.onDrop(of: [.data], isTargeted: $vm.dropZoneTargeting) { providers in
|
|
||||||
vm.dropEvent = true
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
tvm.load(providers)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var panel: some View {
|
|
||||||
RoundedRectangle(cornerRadius: 16)
|
|
||||||
.strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10]))
|
|
||||||
.foregroundStyle(.white.opacity(0.1))
|
|
||||||
.overlay {
|
|
||||||
content
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.animation(vm.animation, value: tvm.items)
|
|
||||||
.animation(vm.animation, value: tvm.isLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
DropItemView(item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(spacing)
|
|
||||||
}
|
|
||||||
.padding(-spacing)
|
|
||||||
.scrollIndicators(.never)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,10 @@ struct TabSelectionView: View {
|
||||||
var tabsArray: [TabModel] = []
|
var tabsArray: [TabModel] = []
|
||||||
|
|
||||||
tabsArray.append(TabModel(label: "Home", icon: "house.fill", view: .home))
|
tabsArray.append(TabModel(label: "Home", icon: "house.fill", view: .home))
|
||||||
|
|
||||||
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
|
if Defaults[.dynamicShelf] {
|
||||||
|
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
|
||||||
|
}
|
||||||
|
|
||||||
// Stats tab only shown when stats feature is enabled
|
// Stats tab only shown when stats feature is enabled
|
||||||
if Defaults[.enableStatsFeature] {
|
if Defaults[.enableStatsFeature] {
|
||||||
|
|
|
||||||
174
DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift
Normal file
174
DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
//
|
||||||
|
// NSItemProvider+LoadHelpers.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Ported shelf drop helpers from boringNotch.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
extension NSItemProvider {
|
||||||
|
func extractItem() async -> URL? {
|
||||||
|
await loadFileURL(typeIdentifier: UTType.item.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects if this is a file dragged from the filesystem
|
||||||
|
func extractFileURL() async -> URL? {
|
||||||
|
if hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
||||||
|
return await loadFileURL(typeIdentifier: UTType.fileURL.identifier)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads raw data for the given type identifier
|
||||||
|
func loadData() async -> Data? {
|
||||||
|
guard hasItemConformingToTypeIdentifier(UTType.data.identifier) else { return nil }
|
||||||
|
return await withCheckedContinuation { (cont: CheckedContinuation<Data?, Never>) in
|
||||||
|
loadItem(forTypeIdentifier: UTType.data.identifier, options: nil) { item, error in
|
||||||
|
if let error {
|
||||||
|
print("Error loading data for type \(UTType.data.identifier): \(error.localizedDescription)")
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let url = item as? URL, let data = try? Data(contentsOf: url) {
|
||||||
|
if !url.absoluteString.contains("com.apple.SwiftUI.filePromises") {
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.suggestedName = self.suggestedName ?? url.lastPathComponent
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let folderURL = url.deletingLastPathComponent()
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: url)
|
||||||
|
let contents = try fileManager.contentsOfDirectory(atPath: folderURL.path)
|
||||||
|
if contents.isEmpty {
|
||||||
|
try fileManager.removeItem(at: folderURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
cont.resume(returning: data)
|
||||||
|
} else if let data = item as? Data {
|
||||||
|
cont.resume(returning: data)
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to extract a URL (web link) from the provider
|
||||||
|
func extractURL() async -> URL? {
|
||||||
|
if hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||||
|
if let url = await loadURL(typeIdentifier: UTType.url.identifier) {
|
||||||
|
guard url.scheme != nil else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractText() async -> String? {
|
||||||
|
let textTypes = [UTType.utf8PlainText.identifier, UTType.plainText.identifier]
|
||||||
|
for typeIdentifier in textTypes where hasItemConformingToTypeIdentifier(typeIdentifier) {
|
||||||
|
if let text = await loadText(typeIdentifier: typeIdentifier) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a file URL from the provider for the given type identifier.
|
||||||
|
func loadFileURL(typeIdentifier: String) async -> URL? {
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<URL?, Never>) in
|
||||||
|
loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in
|
||||||
|
if let error {
|
||||||
|
print("❌ Error loading item for type \(typeIdentifier): \(error.localizedDescription)")
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var resolvedURL: URL?
|
||||||
|
if let url = item as? URL {
|
||||||
|
resolvedURL = url
|
||||||
|
} else if let data = item as? Data {
|
||||||
|
if let string = String(data: data, encoding: .utf8) {
|
||||||
|
if let url = URL(string: string) {
|
||||||
|
resolvedURL = url
|
||||||
|
} else if string.hasPrefix("/") {
|
||||||
|
resolvedURL = URL(fileURLWithPath: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolvedURL == nil {
|
||||||
|
let bookmark = Bookmark(data: data)
|
||||||
|
resolvedURL = bookmark.resolveURL()
|
||||||
|
}
|
||||||
|
} else if let string = item as? String {
|
||||||
|
if let url = URL(string: string) {
|
||||||
|
resolvedURL = url
|
||||||
|
} else if string.hasPrefix("/") {
|
||||||
|
resolvedURL = URL(fileURLWithPath: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cont.resume(returning: resolvedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a URL from the provider for the given type identifier.
|
||||||
|
func loadURL(typeIdentifier: String) async -> URL? {
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<URL?, Never>) in
|
||||||
|
loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in
|
||||||
|
if error != nil {
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let url = item as? URL {
|
||||||
|
cont.resume(returning: url)
|
||||||
|
} else if let data = item as? Data {
|
||||||
|
if let string = String(data: data, encoding: .utf8) {
|
||||||
|
if let url = URL(string: string) {
|
||||||
|
cont.resume(returning: url)
|
||||||
|
return
|
||||||
|
} else if string.hasPrefix("/") {
|
||||||
|
cont.resume(returning: URL(fileURLWithPath: string))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
} else if let string = item as? String {
|
||||||
|
if let url = URL(string: string) {
|
||||||
|
cont.resume(returning: url)
|
||||||
|
} else if string.hasPrefix("/") {
|
||||||
|
cont.resume(returning: URL(fileURLWithPath: string))
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads text from the provider for the given type identifier.
|
||||||
|
func loadText(typeIdentifier: String) async -> String? {
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in
|
||||||
|
loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in
|
||||||
|
if error != nil {
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let string = item as? String {
|
||||||
|
cont.resume(returning: string)
|
||||||
|
} else if let data = item as? Data,
|
||||||
|
let string = String(data: data, encoding: .utf8) {
|
||||||
|
cont.resume(returning: string)
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
DynamicIsland/extensions/NSMenu+AssociatedObject.swift
Normal file
21
DynamicIsland/extensions/NSMenu+AssociatedObject.swift
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// NSMenu+AssociatedObject.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Retains menu action targets using associated objects.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
private final class MenuActionBox: NSObject {
|
||||||
|
let target: AnyObject
|
||||||
|
init(target: AnyObject) { self.target = target }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSMenu {
|
||||||
|
private static let retainedAction = AssociatedObject<MenuActionBox>()
|
||||||
|
|
||||||
|
func retainActionTarget(_ target: AnyObject) {
|
||||||
|
NSMenu.retainedAction[self] = MenuActionBox(target: target)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
DynamicIsland/extensions/URL+SecurityScoped.swift
Normal file
44
DynamicIsland/extensions/URL+SecurityScoped.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// URL+SecurityScoped.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Ported from boringNotch shelf utilities.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
func accessSecurityScopedResource<Value>(accessor: (URL) throws -> Value) rethrows -> Value {
|
||||||
|
let didStartAccessing = startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if didStartAccessing {
|
||||||
|
stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try accessor(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async version of accessSecurityScopedResource
|
||||||
|
func accessSecurityScopedResource<Value>(accessor: (URL) async throws -> Value) async rethrows -> Value {
|
||||||
|
let didStartAccessing = startAccessingSecurityScopedResource()
|
||||||
|
defer {
|
||||||
|
if didStartAccessing {
|
||||||
|
stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await accessor(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == URL {
|
||||||
|
func accessSecurityScopedResources<Value>(accessor: ([URL]) async throws -> Value) async rethrows -> Value {
|
||||||
|
let didStart = map { $0.startAccessingSecurityScopedResource() }
|
||||||
|
defer {
|
||||||
|
for (url, started) in zip(self, didStart) where started {
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await accessor(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
DynamicIsland/helpers/AssociatedObject.swift
Normal file
28
DynamicIsland/helpers/AssociatedObject.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
//
|
||||||
|
// AssociatedObject.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Helper for retaining objects via Objective-C associated storage.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ObjectiveC
|
||||||
|
|
||||||
|
public struct AssociatedObject<Value: AnyObject> {
|
||||||
|
private let key: UnsafeRawPointer
|
||||||
|
private let policy: objc_AssociationPolicy
|
||||||
|
|
||||||
|
public init(_ policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) {
|
||||||
|
self.key = UnsafeRawPointer(Unmanaged.passUnretained(UniqueKey()).toOpaque())
|
||||||
|
self.policy = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class UniqueKey {}
|
||||||
|
|
||||||
|
public subscript<Owner: AnyObject>(_ owner: Owner) -> Value? {
|
||||||
|
get { objc_getAssociatedObject(owner, key) as? Value }
|
||||||
|
nonmutating set { objc_setAssociatedObject(owner, key, newValue, policy) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AssociatedObject: @unchecked Sendable where Value: Sendable {}
|
||||||
|
|
@ -501,6 +501,9 @@ extension Defaults.Keys {
|
||||||
// MARK: Shelf
|
// MARK: Shelf
|
||||||
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
|
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
|
||||||
static let openShelfByDefault = Key<Bool>("openShelfByDefault", default: true)
|
static let openShelfByDefault = Key<Bool>("openShelfByDefault", default: true)
|
||||||
|
static let quickShareProvider = Key<String>("quickShareProvider", default: "AirDrop")
|
||||||
|
static let copyOnDrag = Key<Bool>("copyOnDrag", default: false)
|
||||||
|
static let autoRemoveShelfItems = Key<Bool>("autoRemoveShelfItems", default: false)
|
||||||
|
|
||||||
// MARK: Calendar
|
// MARK: Calendar
|
||||||
static let calendarSelectionState = Key<CalendarSelectionState>("calendarSelectionState", default: .all)
|
static let calendarSelectionState = Key<CalendarSelectionState>("calendarSelectionState", default: .all)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import Combine
|
||||||
import Defaults
|
import Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class DynamicIslandViewModel: NSObject, ObservableObject {
|
class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject var detector = FullscreenMediaDetector.shared
|
@ObservedObject var detector = FullscreenMediaDetector.shared
|
||||||
|
|
@ -45,6 +46,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
@Published var notchSize: CGSize = getClosedNotchSize()
|
@Published var notchSize: CGSize = getClosedNotchSize()
|
||||||
@Published var closedNotchSize: CGSize = getClosedNotchSize()
|
@Published var closedNotchSize: CGSize = getClosedNotchSize()
|
||||||
|
|
||||||
|
@MainActor
|
||||||
deinit {
|
deinit {
|
||||||
destroy()
|
destroy()
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +284,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
// Set the current view to shelf if it contains files and the user enables openShelfByDefault
|
// Set the current view to shelf if it contains files and the user enables openShelfByDefault
|
||||||
// Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home
|
// Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home
|
||||||
if !TrayDrop.shared.isEmpty && Defaults[.openShelfByDefault] {
|
if !ShelfStateViewModel.shared.isEmpty && Defaults[.openShelfByDefault] {
|
||||||
coordinator.currentView = .shelf
|
coordinator.currentView = .shelf
|
||||||
} else if !coordinator.openLastTabByDefault {
|
} else if !coordinator.openLastTabByDefault {
|
||||||
coordinator.currentView = .home
|
coordinator.currentView = .home
|
||||||
|
|
|
||||||
157
DynamicIsland/models/SharingStateManager.swift
Normal file
157
DynamicIsland/models/SharingStateManager.swift
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
//
|
||||||
|
// SharingStateManager.swift
|
||||||
|
// boringNotch
|
||||||
|
//
|
||||||
|
// Created by Alexander on 2025-10-10.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let sharingDidFinish = Notification.Name("com.ebullioscopic.sharingDidFinish")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SharingStateManager: ObservableObject {
|
||||||
|
static let shared = SharingStateManager()
|
||||||
|
|
||||||
|
private var activeSessions: Int = 0 {
|
||||||
|
didSet {
|
||||||
|
let newValue = activeSessions > 0
|
||||||
|
if newValue != preventNotchClose {
|
||||||
|
preventNotchClose = newValue
|
||||||
|
if !newValue {
|
||||||
|
NotificationCenter.default.post(name: .sharingDidFinish, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var preventNotchClose: Bool = false
|
||||||
|
|
||||||
|
private var activeDelegates: [UUID: SharingLifecycleDelegate] = [:]
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func requestCloseIfReady() {
|
||||||
|
if !preventNotchClose {
|
||||||
|
NotificationCenter.default.post(name: .sharingDidFinish, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginInteraction() {
|
||||||
|
activeSessions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func endInteraction() {
|
||||||
|
if activeSessions > 0 { activeSessions -= 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDelegate(onEnd: (() -> Void)? = nil) -> SharingLifecycleDelegate {
|
||||||
|
let id = UUID()
|
||||||
|
let delegate = SharingLifecycleDelegate(id: id, onEnd: { [weak self] in
|
||||||
|
onEnd?()
|
||||||
|
self?.unregisterDelegate(id: id)
|
||||||
|
}, onBegin: { [weak self] in
|
||||||
|
self?.beginInteraction()
|
||||||
|
}, onFinish: { [weak self] in
|
||||||
|
self?.endInteraction()
|
||||||
|
})
|
||||||
|
activeDelegates[id] = delegate
|
||||||
|
return delegate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unregisterDelegate(id: UUID) {
|
||||||
|
activeDelegates.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SharingLifecycleDelegate: NSObject, NSSharingServiceDelegate, NSSharingServicePickerDelegate {
|
||||||
|
let id: UUID
|
||||||
|
private let onEnd: () -> Void
|
||||||
|
private let onBegin: () -> Void
|
||||||
|
private let onFinish: () -> Void
|
||||||
|
|
||||||
|
private var pickerActive = false
|
||||||
|
private var serviceInProgress = false
|
||||||
|
private var finished = false
|
||||||
|
private var timeoutTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(id: UUID, onEnd: @escaping () -> Void, onBegin: @escaping () -> Void, onFinish: @escaping () -> Void) {
|
||||||
|
self.id = id
|
||||||
|
self.onEnd = onEnd
|
||||||
|
self.onBegin = onBegin
|
||||||
|
self.onFinish = onFinish
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timeoutTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPickerBegan() {
|
||||||
|
guard !pickerActive else { return }
|
||||||
|
pickerActive = true
|
||||||
|
onBegin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markServiceBegan() {
|
||||||
|
guard !serviceInProgress else { return }
|
||||||
|
serviceInProgress = true
|
||||||
|
onBegin()
|
||||||
|
startTimeoutFallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimeoutFallback() {
|
||||||
|
timeoutTask?.cancel()
|
||||||
|
timeoutTask = Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
guard let self = self, !Task.isCancelled else { return }
|
||||||
|
if !self.finished {
|
||||||
|
self.finishIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishIfNeeded() {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
timeoutTask?.cancel()
|
||||||
|
onFinish()
|
||||||
|
onEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSSharingServicePickerDelegate
|
||||||
|
|
||||||
|
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService?) {
|
||||||
|
if service == nil {
|
||||||
|
if pickerActive && !serviceInProgress {
|
||||||
|
finishIfNeeded()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service?.delegate = self
|
||||||
|
serviceInProgress = true
|
||||||
|
startTimeoutFallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSSharingServiceDelegate
|
||||||
|
|
||||||
|
func sharingService(_ sharingService: NSSharingService, willShareItems items: [Any]) {
|
||||||
|
if !pickerActive && !serviceInProgress {
|
||||||
|
onBegin()
|
||||||
|
}
|
||||||
|
serviceInProgress = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) {
|
||||||
|
finishIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) {
|
||||||
|
finishIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue