Fix security-scoped URL access balancing

This commit is contained in:
世界
2026-02-04 22:32:02 +08:00
parent 20e22b691a
commit 4ae421cd04
8 changed files with 77 additions and 32 deletions
@@ -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)
@@ -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"),
@@ -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 {
-8
View File
@@ -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)
}
}
+2 -1
View File
@@ -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
@@ -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<T>(_ body: () throws -> T) rethrows -> T {
let didStart = startAccessingSecurityScopedResource()
defer {
if didStart {
stopAccessingSecurityScopedResource()
}
}
return try body()
}
/// Async best-effort variant of `withSecurityScopedAccess`.
@discardableResult
func withSecurityScopedAccess<T>(_ 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<T>(
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<T>(
or error: @autoclosure () -> any Error,
_ body: () async throws -> T
) async throws -> T {
guard startAccessingSecurityScopedResource() else {
throw error()
}
defer { stopAccessingSecurityScopedResource() }
return try await body()
}
}
+3 -3
View File
@@ -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)
}
+3 -3
View File
@@ -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)
}