From e283ecd8d80664b1d195ff844acd3d678a5886cd Mon Sep 17 00:00:00 2001 From: Mattia Righetti Date: Tue, 24 Mar 2026 02:04:01 +0000 Subject: [PATCH 1/2] fix: resolve critical app extension link handling bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix stale externalCache causing links to be lost after first extension use - processExternalLinks now reads fresh from disk via getCache() on each call - No longer relies on unreliable notification-based cache clearing - Explicitly calls dropCache() + persistCache() after batch insert - Process external links on cold launch - Added direct call to processExternalLinks() in scene(_:willConnectTo:) - Previously only processed on background-to-foreground transitions - Links saved while app was closed would be lost until next background/foreground cycle - Fix persistCache/getCache encoding mismatch - persistCache now encodes Array(externalCache.values) instead of dictionary - Consistent with how getCache() and add() decode the file as an array - Prevent crashes in AddToUlryActionViewController - Replaced force unwraps on extensionContext and attachments with guard let - Gracefully completes request if extension input is missing - Fix appendingPathExtension → appendingPathComponent in +URL.swift - Correct method for appending path components (subdirectories) - Previously would have added .folder to filenames instead of creating directories --- .../ExtensionsAddLinkRequestsManager.swift | 2 +- Shared/Utils/+URL.swift | 2 +- .../AddToUlryActionViewController.swift | 14 ++++++++++---- Ulry/SceneDelegate.swift | 12 ++++++++---- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift b/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift index f6e16c8..e51e222 100644 --- a/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift +++ b/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift @@ -54,7 +54,7 @@ final class ExtensionsAddLinkRequestsManager: NSObject, Logging { coordinateFileWrite { url in do { - let data = try encoder.encode(externalCache) + let data = try encoder.encode(Array(externalCache.values)) try data.write(to: url) } catch { logger.error("Save to disk failed: \(error.localizedDescription, privacy: .public)") diff --git a/Shared/Utils/+URL.swift b/Shared/Utils/+URL.swift index e1b744e..53d4a4c 100644 --- a/Shared/Utils/+URL.swift +++ b/Shared/Utils/+URL.swift @@ -18,7 +18,7 @@ public extension URL { var path: URL = fileContainer if let folders = folders { for folder in folders { - path = path.appendingPathExtension(folder) + path = path.appendingPathComponent(folder) } } return path.appendingPathComponent(filename) diff --git a/Ulry/AddToUlryAction/AddToUlryActionViewController.swift b/Ulry/AddToUlryAction/AddToUlryActionViewController.swift index 2b2c3d1..c1448de 100644 --- a/Ulry/AddToUlryAction/AddToUlryActionViewController.swift +++ b/Ulry/AddToUlryAction/AddToUlryActionViewController.swift @@ -40,13 +40,19 @@ class ActionViewController: UIViewController { navigationItem.title = "Action" - let extensionItem = extensionContext?.inputItems.first as! NSExtensionItem - let itemProvider = (extensionItem.attachments?.first)! as NSItemProvider - + guard + let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, + let itemProvider = extensionItem.attachments?.first + else { + os_log(.error, "Encountered error while trying to insert link from action: missing extension item") + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + return + } + let propertyList = String(describing: UTType.propertyList) if itemProvider.hasItemConformingToTypeIdentifier(propertyList) { itemProvider.loadItem(forTypeIdentifier: propertyList, options: nil) { item, error in - let dictionary = item as! NSDictionary + guard let dictionary = item as? NSDictionary else { return } OperationQueue.main.addOperation { [weak self] in if let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary { self?.handleData(dict: results) diff --git a/Ulry/SceneDelegate.swift b/Ulry/SceneDelegate.swift index 6ea0adb..b9de69d 100644 --- a/Ulry/SceneDelegate.swift +++ b/Ulry/SceneDelegate.swift @@ -44,6 +44,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { NotificationCenter.default.addObserver(self, selector: #selector(processExternalLinks), name: UIApplication.willEnterForegroundNotification, object: nil) self.window = window + + processExternalLinks() } func sceneDidDisconnect(_ scene: UIScene) { @@ -77,12 +79,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } @objc private func processExternalLinks() { - guard addLinkRequestManger.getCache().count > 0 else { return } - os_log(.info, "moving \(self.addLinkRequestManger.externalCache.count) links from external file") - let links = addLinkRequestManger.externalCache.values.map { Link(url: $0.url, note: $0.note) } - + let cache = addLinkRequestManger.getCache() + guard cache.count > 0 else { return } + os_log(.info, "moving \(cache.count) links from external file") + let links = cache.values.map { Link(url: $0.url, note: $0.note) } + Task { await account.insertBatch(links: links) + addLinkRequestManger.dropCache() addLinkRequestManger.persistCache() } } From 2b1f0b7e0d7fbdb90e82c2e7810cb7855ac4fb6c Mon Sep 17 00:00:00 2001 From: Mattia Righetti Date: Tue, 24 Mar 2026 10:43:40 +0000 Subject: [PATCH 2/2] refactor: replace plist/NSFileCoordinator with UserDefaults for extension queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExtensionsAddLinkRequestsManager now uses UserDefaults(suiteName:) instead of a manually coordinated plist file - Removed: externalCache lazy var, getCache, dropCache, persistCache, NSFileCoordinator, PropertyListEncoder/Decoder, NSObject inheritance, and the notification-based remove mechanism - Added: pendingLinks computed property (reads fresh each time), clearAll() - SceneDelegate.processExternalLinks updated to use new API - Deleted Shared/Utils/+URL.swift — storeURL helper is now unused --- .../ExtensionsAddLinkRequestsManager.swift | 107 ++++-------------- Ulry/SceneDelegate.swift | 11 +- 2 files changed, 27 insertions(+), 91 deletions(-) diff --git a/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift b/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift index e51e222..51efaa9 100644 --- a/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift +++ b/Shared/SharedExtension/ExtensionsAddLinkRequestsManager.swift @@ -8,101 +8,38 @@ import Foundation -final class ExtensionsAddLinkRequestsManager: NSObject, Logging { +final class ExtensionsAddLinkRequestsManager: Logging { - private static var filePathUrl: URL = { - URL.storeURL(for: "group.com.mattrighetti.Ulry", filename: "external_links.plist") - }() + private static let suite = "group.com.mattrighetti.Ulry" + private static let key = "pendingLinks" - var canSaveMoreLinks: Bool { - externalCache.count < 15 - } - - override init() { - super.init() - NotificationCenter.default.addObserver(self, selector: #selector(remove(_:)), name: NSNotification.Name("UserDidAddLink"), object: nil) - } - - @objc func remove(_ notification: Notification) { - guard let url = notification.userInfo?["toRemoveFromCache"] as? String else { return } - externalCache.removeValue(forKey: url) - } - - lazy var externalCache: [String:ExtensionsAddLinkRequests] = { - getCache() - }() - - func getCache() -> [String:ExtensionsAddLinkRequests] { - let decoder = PropertyListDecoder() - var cache = [String:ExtensionsAddLinkRequests]() - if let data = try? Data(contentsOf: Self.filePathUrl), - let requests = try? decoder.decode([ExtensionsAddLinkRequests].self, from: data) { - for req in requests { - cache[req.url] = req - } - } - return cache - } - - func dropCache() { - externalCache = [String:ExtensionsAddLinkRequests]() + private var defaults: UserDefaults? { + UserDefaults(suiteName: Self.suite) } - func persistCache() { - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - - coordinateFileWrite { url in - do { - let data = try encoder.encode(Array(externalCache.values)) - try data.write(to: url) - } catch { - logger.error("Save to disk failed: \(error.localizedDescription, privacy: .public)") - } - } + var canSaveMoreLinks: Bool { + pendingLinks.count < 15 } - /// Coordinates file access - private func coordinateFileWrite(run accessor: (URL) throws -> Void) { - let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator() - let fileUrl = ExtensionsAddLinkRequestsManager.filePathUrl - - fileCoordinator.coordinate(writingItemAt: fileUrl, options: [.forMerging], error: errorPointer, byAccessor: { url in - do { - try accessor(url) - } catch { - logger.error("Save to disk failed: \(error.localizedDescription, privacy: .public)") - } - }) - - if let error = errorPointer?.pointee { - logger.error("Save to disk coordination failed: \(error.localizedDescription, privacy: .public)") - } + var pendingLinks: [ExtensionsAddLinkRequests] { + guard + let data = defaults?.data(forKey: Self.key), + let links = try? JSONDecoder().decode([ExtensionsAddLinkRequests].self, from: data) + else { return [] } + return links } - /// Stores given `urlString` and `note` to external file + /// Stores given `urlString` and `note` to UserDefaults func add(_ urlString: String, note: String?) { logger.info("saving \(urlString) with note \(note ?? "")") - let decoder = PropertyListDecoder() - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - - coordinateFileWrite { url in - var requests: [ExtensionsAddLinkRequests] - if - let fileData = try? Data(contentsOf: url), - let decodedRequests = try? decoder.decode([ExtensionsAddLinkRequests].self, from: fileData) - { - requests = decodedRequests - } else { - requests = [ExtensionsAddLinkRequests]() - } - - requests.append(ExtensionsAddLinkRequests(url: urlString, note: note)) - - let data = try encoder.encode(requests) - try data.write(to: url) + var links = pendingLinks + links.append(ExtensionsAddLinkRequests(url: urlString, note: note)) + if let data = try? JSONEncoder().encode(links) { + defaults?.set(data, forKey: Self.key) } } + + func clearAll() { + defaults?.removeObject(forKey: Self.key) + } } diff --git a/Ulry/SceneDelegate.swift b/Ulry/SceneDelegate.swift index b9de69d..ee62e93 100644 --- a/Ulry/SceneDelegate.swift +++ b/Ulry/SceneDelegate.swift @@ -79,15 +79,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } @objc private func processExternalLinks() { - let cache = addLinkRequestManger.getCache() - guard cache.count > 0 else { return } - os_log(.info, "moving \(cache.count) links from external file") - let links = cache.values.map { Link(url: $0.url, note: $0.note) } + let pending = addLinkRequestManger.pendingLinks + guard !pending.isEmpty else { return } + os_log(.info, "moving \(pending.count) links from external file") + let links = pending.map { Link(url: $0.url, note: $0.note) } Task { await account.insertBatch(links: links) - addLinkRequestManger.dropCache() - addLinkRequestManger.persistCache() + addLinkRequestManger.clearAll() } } }