diff --git a/Sources/Hub/HubApi.swift b/Sources/Hub/HubApi.swift index 7887abce..59d31aa7 100644 --- a/Sources/Hub/HubApi.swift +++ b/Sources/Hub/HubApi.swift @@ -132,7 +132,7 @@ public struct HubApi: Sendable { /// /// - Parameters: /// - downloadBase: The base directory for local snapshot outputs. - /// Defaults to `Documents/huggingface` to preserve historical behavior. + /// Defaults to `Application Support/huggingface`. /// This location is independent from `HubCache` storage used by cached /// `HubClient` requests, and is the location used by offline snapshot checks. /// - cache: The cache used by cached `HubClient` requests. @@ -157,12 +157,7 @@ public struct HubApi: Sendable { } else { .environment } - if let downloadBase { - self.downloadBase = downloadBase - } else { - let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - self.downloadBase = documents.appending(component: "huggingface") - } + self.downloadBase = downloadBase ?? Self.defaultDownloadBase if let endpoint, let parsed = URL(string: endpoint), let scheme = parsed.scheme, !scheme.isEmpty, @@ -210,6 +205,18 @@ public struct HubApi: Sendable { /// The shared Hub API instance with default configuration. public static let shared = HubApi() + /// The default base directory used when no `downloadBase` is supplied. + /// + /// Resolves to `Application Support/huggingface` so that snapshot outputs are + /// stored in the standard Apple location for app data. This avoids the + /// macOS Sequoia+ TCC consent prompt for the user's `Documents` folder + /// triggered by `.documentDirectory`, which used to fire even for callers + /// that only used `Tokenizers` for local model loading. + static var defaultDownloadBase: URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return support.appending(component: "huggingface") + } + #if canImport(os) private static let logger = Logger() #else diff --git a/Tests/HubTests/HubApiTests.swift b/Tests/HubTests/HubApiTests.swift index dae9310a..580447eb 100644 --- a/Tests/HubTests/HubApiTests.swift +++ b/Tests/HubTests/HubApiTests.swift @@ -23,6 +23,38 @@ class HubApiTests: XCTestCase { XCTAssertEqual(hubApi.endpoint, "https://hf-mirror.com") } + /// The default `downloadBase` must not reside under the user's `Documents` directory. + /// + /// Regression coverage for #339: on macOS Sequoia+, resolving `.documentDirectory` + /// triggers a TCC consent prompt for "Documents folder" even when the directory + /// is never actually used (e.g. tokenizer-only flows that go through `HubApi.shared` + /// via a default parameter). + func testDefaultDownloadBaseIsNotInDocumentsFolder() { + let hubApi = HubApi() + + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + XCTAssertFalse( + hubApi.downloadBase.path.hasPrefix(documents.path), + "Default downloadBase \(hubApi.downloadBase.path) should not live under \(documents.path)" + ) + } + + /// The default `downloadBase` resolves to `Application Support/huggingface`. + func testDefaultDownloadBaseIsApplicationSupportHuggingface() { + let hubApi = HubApi() + + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let expected = support.appending(component: "huggingface") + XCTAssertEqual(hubApi.downloadBase.standardizedFileURL, expected.standardizedFileURL) + } + + /// An explicit `downloadBase` argument is preserved verbatim. + func testExplicitDownloadBaseIsRespected() { + let custom = URL(fileURLWithPath: "/tmp/swift-transformers-tests/custom-download-base", isDirectory: true) + let hubApi = HubApi(downloadBase: custom) + XCTAssertEqual(hubApi.downloadBase.standardizedFileURL, custom.standardizedFileURL) + } + /// Test that revision values containing slashes (like "pr/1") are properly URL encoded. /// The Hub API requires "pr/1" to be encoded as "pr%2F1" - otherwise it returns 404. func testGetFilenamesWithPRRevision() async throws {