From 4ae421cd049a50ea51898319bb11c07faeb3d217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 4 Feb 2026 22:32:02 +0800 Subject: [PATCH] Fix security-scoped URL access balancing --- .../Views/Dashboard/Cards/HTTPProxyCard.swift | 6 +- .../Views/Profile/NewProfileMenuView.swift | 9 +-- .../Views/Profile/NewProfileViewModel.swift | 14 ++--- Library/Database/Profile+RW.swift | 8 --- Library/Network/CommandClient.swift | 3 +- Library/Shared/URL+SecurityScopedAccess.swift | 57 +++++++++++++++++++ MacLibrary/MainViewModel.swift | 6 +- SFI/MainView.swift | 6 +- 8 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 Library/Shared/URL+SecurityScopedAccess.swift diff --git a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift index 9ce5561..39d46b7 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift @@ -24,9 +24,9 @@ public struct HTTPProxyCard: View { Spacer() Toggle("", isOn: $systemProxyEnabled) .labelsHidden() - #if os(macOS) - .toggleStyle(.switch) - #endif + #if os(macOS) + .toggleStyle(.switch) + #endif .onChangeCompat(of: systemProxyEnabled) { newValue in Task { await onToggle(newValue) diff --git a/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift index d813449..9d5bcd6 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileMenuView.swift @@ -201,10 +201,11 @@ public struct NewProfileMenuView: View { let fileName = url.deletingPathExtension().lastPathComponent localImportRequest = NewProfileView.LocalImportRequest(name: fileName, fileURL: url) } else { - _ = url.startAccessingSecurityScopedResource() - defer { url.stopAccessingSecurityScopedResource() } - - let content = try LibboxProfileContent.from(Data(contentsOf: url)) + let content = try url.withRequiredSecurityScopedAccess( + or: NSError(domain: "NewProfileMenuView", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")]) + ) { + try LibboxProfileContent.from(Data(contentsOf: url)) + } alert = AlertState( title: String(localized: "Import Profile"), diff --git a/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift index 31584d6..44c742e 100644 --- a/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift +++ b/ApplicationLibrary/Views/Profile/NewProfileViewModel.swift @@ -113,13 +113,11 @@ public final class NewProfileViewModel: BaseViewModel { guard let fileURL else { throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")]) } - if !fileURL.startAccessingSecurityScopedResource() { - throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")]) + try fileURL.withRequiredSecurityScopedAccess( + or: NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")]) + ) { + try String(contentsOf: fileURL).write(to: profileConfig, atomically: true, encoding: .utf8) } - defer { - fileURL.stopAccessingSecurityScopedResource() - } - try String(contentsOf: fileURL).write(to: profileConfig, atomically: true, encoding: .utf8) } else { try "{}".write(to: profileConfig, atomically: true, encoding: .utf8) } @@ -129,10 +127,6 @@ public final class NewProfileViewModel: BaseViewModel { try FileManager.default.createDirectory(at: FilePath.iCloudDirectory, withIntermediateDirectories: true) } let saveURL = FilePath.iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false) - _ = saveURL.startAccessingSecurityScopedResource() - defer { - saveURL.stopAccessingSecurityScopedResource() - } do { _ = try String(contentsOf: saveURL) } catch { diff --git a/Library/Database/Profile+RW.swift b/Library/Database/Profile+RW.swift index 7899f50..4d9cdd9 100644 --- a/Library/Database/Profile+RW.swift +++ b/Library/Database/Profile+RW.swift @@ -7,10 +7,6 @@ public extension Profile { return try String(contentsOfFile: path) case .icloud: let saveURL = FilePath.iCloudDirectory.appendingPathComponent(path) - _ = saveURL.startAccessingSecurityScopedResource() - defer { - saveURL.stopAccessingSecurityScopedResource() - } return try String(contentsOf: saveURL) } } @@ -21,10 +17,6 @@ public extension Profile { try content.write(toFile: path, atomically: true, encoding: .utf8) case .icloud: let saveURL = FilePath.iCloudDirectory.appendingPathComponent(path) - _ = saveURL.startAccessingSecurityScopedResource() - defer { - saveURL.stopAccessingSecurityScopedResource() - } try content.write(to: saveURL, atomically: true, encoding: .utf8) } } diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 4b9a6ac..25f0f19 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -1,7 +1,7 @@ +import Combine import Foundation import Libbox import os -import Combine private let logger = Logger(category: "CommandClient") @@ -83,6 +83,7 @@ public class CommandClient: ObservableObject { .map(\.status) .eraseToAnyPublisher() } + @Published public var groups: [LibboxOutboundGroup]? @Published public var logList: [LogEntry] @Published public var defaultLogLevel = 0 diff --git a/Library/Shared/URL+SecurityScopedAccess.swift b/Library/Shared/URL+SecurityScopedAccess.swift new file mode 100644 index 0000000..d23d23b --- /dev/null +++ b/Library/Shared/URL+SecurityScopedAccess.swift @@ -0,0 +1,57 @@ +import Foundation + +public extension URL { + /// Best-effort security-scoped access: + /// - If `startAccessingSecurityScopedResource()` succeeds, access is relinquished via `stop...` when `body` completes. + /// - If it fails, `body` still runs (useful for non-security-scoped but otherwise accessible URLs). + @discardableResult + func withSecurityScopedAccess(_ body: () throws -> T) rethrows -> T { + let didStart = startAccessingSecurityScopedResource() + defer { + if didStart { + stopAccessingSecurityScopedResource() + } + } + return try body() + } + + /// Async best-effort variant of `withSecurityScopedAccess`. + @discardableResult + func withSecurityScopedAccess(_ body: () async throws -> T) async rethrows -> T { + let didStart = startAccessingSecurityScopedResource() + defer { + if didStart { + stopAccessingSecurityScopedResource() + } + } + return try await body() + } + + /// Required security-scoped access: + /// - If `startAccessingSecurityScopedResource()` fails, throws `error` and does not run `body`. + /// - Otherwise, guarantees a balanced `stop...` when `body` completes. + @discardableResult + func withRequiredSecurityScopedAccess( + or error: @autoclosure () -> any Error, + _ body: () throws -> T + ) throws -> T { + guard startAccessingSecurityScopedResource() else { + throw error() + } + defer { stopAccessingSecurityScopedResource() } + return try body() + } + + /// Async required variant of `withRequiredSecurityScopedAccess`. + @discardableResult + func withRequiredSecurityScopedAccess( + or error: @autoclosure () -> any Error, + _ body: () async throws -> T + ) async throws -> T { + guard startAccessingSecurityScopedResource() else { + throw error() + } + defer { stopAccessingSecurityScopedResource() } + return try await body() + } +} diff --git a/MacLibrary/MainViewModel.swift b/MacLibrary/MainViewModel.swift index feebeff..01cda0f 100644 --- a/MacLibrary/MainViewModel.swift +++ b/MacLibrary/MainViewModel.swift @@ -58,9 +58,9 @@ public class MainViewModel: BaseViewModel { private func importURLProfile(_ url: URL) async { do { - _ = url.startAccessingSecurityScopedResource() - importProfile = try await .from(readURL(url)) - url.stopAccessingSecurityScopedResource() + importProfile = try await url.withSecurityScopedAccess { + try await .from(readURL(url)) + } } catch { alert = AlertState(error: error) } diff --git a/SFI/MainView.swift b/SFI/MainView.swift index 1113a7c..9167d0a 100644 --- a/SFI/MainView.swift +++ b/SFI/MainView.swift @@ -222,9 +222,9 @@ struct MainView: View { } } else if url.pathExtension == "bpf" { do { - _ = url.startAccessingSecurityScopedResource() - importProfile = try .from(Data(contentsOf: url)) - url.stopAccessingSecurityScopedResource() + importProfile = try url.withSecurityScopedAccess { + try .from(Data(contentsOf: url)) + } } catch { alert = AlertState(error: error) }