Somewhat working file tray

This commit is contained in:
Hariharan Mudaliar 2025-12-08 13:29:25 +05:30
commit 30422c3d95
29 changed files with 4032 additions and 65 deletions

View file

@ -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;

View file

@ -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)

View file

@ -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)
}
}
} }
} }

View 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)
}
}
}

View 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
}
}

View file

@ -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"
}
}
}

View 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
}
}

View 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)
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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)")
}
}
}

View file

@ -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>
"""
}
}

View file

@ -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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -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
}
}

View file

@ -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
}
}

View 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)
}
}

View 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 }
}
}

View 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
}
}
}

View 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()
}
}
}

View file

@ -26,7 +26,9 @@ struct TabSelectionView: View {
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] {

View 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)
}
}
}
}
}

View 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)
}
}

View 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)
}
}

View 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 {}

View file

@ -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)

View file

@ -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

View 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()
}
}