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

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