From 3fd836d6fa841d4576fb05c48ee7a2b0c2c5ad20 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 01:51:30 +0700 Subject: [PATCH 1/2] fix(plugins): stop creating a stray Application%20Support folder for the registry cache --- .../Plugins/Registry/RegistryClient.swift | 18 +-- .../Registry/RegistryStorageSweep.swift | 88 ++++++++++++++ .../Infrastructure/PostLaunchWork.swift | 1 + .../Plugins/RegistryStorageSweepTests.swift | 115 ++++++++++++++++++ .../probes/check-urlcache-directory-path.sh | 49 ++++++++ 5 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 apple/TablePro/Core/Plugins/Registry/RegistryStorageSweep.swift create mode 100644 apple/TableProTests/Core/Plugins/RegistryStorageSweepTests.swift create mode 100755 apple/scripts/probes/check-urlcache-directory-path.sh diff --git a/apple/TablePro/Core/Plugins/Registry/RegistryClient.swift b/apple/TablePro/Core/Plugins/Registry/RegistryClient.swift index 082bc1366f..f02c07a89a 100644 --- a/apple/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/apple/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -44,16 +44,20 @@ internal final class RegistryClient: ObservableObject { config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 config.waitsForConnectivity = true - // URLCache stores nothing larger than a twentieth of its capacity. - config.urlCache = URLCache( - memoryCapacity: 2_000_000, - diskCapacity: 20_000_000, - directory: AppStorageEnvironment.shared.applicationSupportRoot - .appendingPathComponent("TablePro/Registry/URLCache", isDirectory: true) - ) + config.urlCache = makeCache(at: cacheDirectory(in: AppStorageEnvironment.shared.supportDirectory)) return URLSession(configuration: config) } + nonisolated internal static func cacheDirectory(in supportDirectory: URL) -> URL { + supportDirectory.appendingPathComponent("Registry/URLCache", isDirectory: true) + } + + // URLCache stores nothing larger than a twentieth of its capacity. The directory: initializer + // opens the cache at the percent-encoded path on macOS 15 and earlier (FB9117600). + nonisolated internal static func makeCache(at directory: URL) -> URLCache { + URLCache(memoryCapacity: 2_000_000, diskCapacity: 20_000_000, diskPath: directory.path) + } + // Shares the interactive session's cache; offline it waits for the network until the resource timeout. nonisolated internal static func makeUpdatePassSession(from session: URLSession) -> URLSession { let config = session.configuration diff --git a/apple/TablePro/Core/Plugins/Registry/RegistryStorageSweep.swift b/apple/TablePro/Core/Plugins/Registry/RegistryStorageSweep.swift new file mode 100644 index 0000000000..8a15838626 --- /dev/null +++ b/apple/TablePro/Core/Plugins/Registry/RegistryStorageSweep.swift @@ -0,0 +1,88 @@ +import Foundation +import os + +// Removes plugin registry storage that nothing reads any more. Every check is one stat or one +// defaults lookup, so it runs on each launch rather than tracking whether it already ran. +internal enum RegistryStorageSweep { + private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient") + + // 0.81 and earlier kept the manifest, its fetch date and an override URL here; URLCache holds + // the manifest now. The unprefixed keys predate 0.40, whose launch migration 0.82 dropped. + private static let retiredDefaultsKeys = [ + "com.TablePro.registryLastFetch", + "com.TablePro.customRegistryURL", + "com.TablePro.lastRegistryURL", + "com.TablePro.registryETag", + "registryETag", + "registryLastFetch", + "registryManifestCache", + ] + + private static let retiredManifestFileName = "registry-manifest.json" + + internal static func sweepProductionStorage() { + let environment = AppStorageEnvironment.shared + let legacyCaches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "com.TablePro", isDirectory: true) + sweep(supportDirectory: environment.supportDirectory, legacyCachesDirectory: legacyCaches, defaults: environment.defaults) + } + + internal static func sweep( + supportDirectory: URL, + legacyCachesDirectory: URL?, + defaults: UserDefaults, + fileManager: FileManager = .default + ) { + removeMisplacedCache(for: RegistryClient.cacheDirectory(in: supportDirectory), fileManager: fileManager) + + var manifests = [supportDirectory.appendingPathComponent("Registry/\(retiredManifestFileName)")] + if let legacyCachesDirectory { + manifests.append(legacyCachesDirectory.appendingPathComponent(retiredManifestFileName)) + } + for manifest in manifests where fileManager.fileExists(atPath: manifest.path) { + remove(manifest, fileManager) + } + + for key in retiredDefaultsKeys where defaults.object(forKey: key) != nil { + defaults.removeObject(forKey: key) + } + } + + // The cache the directory: initializer opened at the percent-encoded path, then each parent that + // only exists because of it. An ancestor of the real path is never touched. + internal static func removeMisplacedCache(for cacheDirectory: URL, fileManager: FileManager = .default) { + let realPath = cacheDirectory.standardizedFileURL.path + let misplaced = URL(fileURLWithPath: cacheDirectory.path(percentEncoded: true), isDirectory: true) + .standardizedFileURL + guard misplaced.path != realPath, fileManager.fileExists(atPath: misplaced.path) else { return } + guard remove(misplaced, fileManager) else { return } + + var parent = misplaced.deletingLastPathComponent() + while !isAncestor(parent.path, of: realPath), removeIfEmpty(parent, fileManager) { + parent = parent.deletingLastPathComponent() + } + } + + private static func isAncestor(_ directory: String, of path: String) -> Bool { + directory == "/" || path.hasPrefix(directory + "/") + } + + // Finder leaves a .DS_Store in any folder someone opened to look at the stray tree. + private static func removeIfEmpty(_ directory: URL, _ fileManager: FileManager) -> Bool { + guard let entries = try? fileManager.contentsOfDirectory(atPath: directory.path), + entries.allSatisfy({ $0 == ".DS_Store" }) + else { return false } + return remove(directory, fileManager) + } + + @discardableResult + private static func remove(_ url: URL, _ fileManager: FileManager) -> Bool { + do { + try fileManager.removeItem(at: url) + return true + } catch { + logger.warning("Could not remove retired registry storage: \(error.publicLogShape, privacy: .public)") + return false + } + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift b/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift index ab012e9bb8..c85b3461fe 100644 --- a/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift +++ b/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift @@ -36,6 +36,7 @@ internal enum PostLaunchWork { guard !AppStorageEnvironment.shared.isIsolated else { return } Task { await RegistryClient.shared.fetch(policy: .ifStale) } + Task.detached(priority: .utility) { RegistryStorageSweep.sweepProductionStorage() } /// The retired per-tab split name left one permanent defaults record per query tab ever /// opened, and AppKit offers no way to forget one. Here rather than in the delegate because diff --git a/apple/TableProTests/Core/Plugins/RegistryStorageSweepTests.swift b/apple/TableProTests/Core/Plugins/RegistryStorageSweepTests.swift new file mode 100644 index 0000000000..15a004179c --- /dev/null +++ b/apple/TableProTests/Core/Plugins/RegistryStorageSweepTests.swift @@ -0,0 +1,115 @@ +import Foundation +@testable import TablePro +import Testing + +struct RegistryStorageSweepTests { + private let fileManager = FileManager.default + + private func withBase(_ body: (URL) throws -> Void) throws { + let base = fileManager.temporaryDirectory + .appendingPathComponent("RegistryStorageSweepTests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: base) } + try body(base) + } + + private func touch(_ url: URL) throws { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("x".utf8).write(to: url) + } + + private func exists(_ url: URL) -> Bool { + fileManager.fileExists(atPath: url.path) + } + + @Test("The registry cache under Application Support opens at the file-system path") + func cacheOpensAtTheFileSystemPath() throws { + try withBase { base in + let directory = RegistryClient.cacheDirectory( + in: base.appendingPathComponent("Application Support/TablePro", isDirectory: true) + ) + let cache = RegistryClient.makeCache(at: directory) + _ = cache.currentDiskUsage + + #expect(exists(directory.appendingPathComponent("Cache.db"))) + #expect(!exists(base.appendingPathComponent("Application%20Support"))) + } + } + + @Test("A misplaced cache goes, with every encoded parent it left empty") + func removesTheMisplacedCacheAndItsEmptyParents() throws { + try withBase { base in + let directory = RegistryClient.cacheDirectory( + in: base.appendingPathComponent("Application Support/TablePro", isDirectory: true) + ) + try touch(directory.appendingPathComponent("Cache.db")) + let encodedRoot = base.appendingPathComponent("Application%20Support", isDirectory: true) + try touch(encodedRoot.appendingPathComponent("TablePro/Registry/URLCache/Cache.db")) + try touch(encodedRoot.appendingPathComponent("TablePro/.DS_Store")) + + RegistryStorageSweep.removeMisplacedCache(for: directory) + + #expect(!exists(encodedRoot)) + #expect(exists(directory.appendingPathComponent("Cache.db"))) + #expect(exists(base)) + } + } + + @Test("An encoded parent holding anything else stays") + func keepsAnEncodedParentThatHoldsOtherFiles() throws { + try withBase { base in + let directory = RegistryClient.cacheDirectory( + in: base.appendingPathComponent("Application Support/TablePro", isDirectory: true) + ) + let encodedRoot = base.appendingPathComponent("Application%20Support", isDirectory: true) + try touch(encodedRoot.appendingPathComponent("TablePro/Registry/URLCache/Cache.db")) + try touch(encodedRoot.appendingPathComponent("OtherApp/Cache.db")) + + RegistryStorageSweep.removeMisplacedCache(for: directory) + + #expect(!exists(encodedRoot.appendingPathComponent("TablePro"))) + #expect(exists(encodedRoot.appendingPathComponent("OtherApp/Cache.db"))) + } + } + + @Test("A cache whose path has nothing to encode is the real one and stays") + func keepsACacheWhosePathHasNothingToEncode() throws { + try withBase { base in + let directory = RegistryClient.cacheDirectory(in: base.appendingPathComponent("Support/TablePro", isDirectory: true)) + try touch(directory.appendingPathComponent("Cache.db")) + + RegistryStorageSweep.removeMisplacedCache(for: directory) + + #expect(exists(directory.appendingPathComponent("Cache.db"))) + } + } + + @Test("The manifest files and defaults keys older builds wrote go; the cache and other keys stay") + func removesRetiredManifestsAndKeys() throws { + try withBase { base in + let support = base.appendingPathComponent("Support/TablePro", isDirectory: true) + let caches = base.appendingPathComponent("Caches/com.TablePro", isDirectory: true) + let cacheDatabase = RegistryClient.cacheDirectory(in: support).appendingPathComponent("Cache.db") + try touch(support.appendingPathComponent("Registry/registry-manifest.json")) + try touch(cacheDatabase) + try touch(caches.appendingPathComponent("registry-manifest.json")) + try touch(caches.appendingPathComponent("Cache.db")) + let suite = try EphemeralDefaults() + for key in ["com.TablePro.registryLastFetch", "com.TablePro.customRegistryURL", "registryManifestCache"] { + suite.defaults.set("x", forKey: key) + } + suite.defaults.set("x", forKey: "com.TablePro.unrelated") + + RegistryStorageSweep.sweep(supportDirectory: support, legacyCachesDirectory: caches, defaults: suite.defaults) + + #expect(!exists(support.appendingPathComponent("Registry/registry-manifest.json"))) + #expect(!exists(caches.appendingPathComponent("registry-manifest.json"))) + #expect(exists(cacheDatabase)) + #expect(exists(caches.appendingPathComponent("Cache.db"))) + #expect(suite.defaults.object(forKey: "com.TablePro.registryLastFetch") == nil) + #expect(suite.defaults.object(forKey: "com.TablePro.customRegistryURL") == nil) + #expect(suite.defaults.object(forKey: "registryManifestCache") == nil) + #expect(suite.defaults.string(forKey: "com.TablePro.unrelated") == "x") + } + } +} diff --git a/apple/scripts/probes/check-urlcache-directory-path.sh b/apple/scripts/probes/check-urlcache-directory-path.sh new file mode 100755 index 0000000000..aedd68cc32 --- /dev/null +++ b/apple/scripts/probes/check-urlcache-directory-path.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Where URLCache puts its database for a directory whose path has a space. +# +# RegistryClient keeps the plugin manifest cache under "Application Support". On macOS 15 and +# earlier URLCache(memoryCapacity:diskCapacity:directory:) opens it at the percent-encoded path, +# a sibling "Application%20Support" folder (FB9117600, #3676), so the client uses the diskPath: +# initializer. This opens a cache with each initializer and reports where Cache.db landed. Run it +# on each macOS the app supports; it fails when the initializer RegistryClient uses misplaces it. +# +# Usage: scripts/probes/check-urlcache-directory-path.sh + +cd "$(dirname "$0")/../.." + +SOURCE="TablePro/Core/Plugins/Registry/RegistryClient.swift" +grep -q 'diskPath: directory.path' "$SOURCE" || { echo "FAIL: $SOURCE no longer uses the diskPath: initializer" >&2; exit 1; } + +workdir=$(mktemp -d) +trap 'rm -rf "$workdir"' EXIT + +cat > "$workdir/probe.swift" <<'SWIFT' +import Foundation + +let base = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +for initializer in ["directory", "diskPath"] { + let root = base.appendingPathComponent(initializer, isDirectory: true) + let directory = root.appendingPathComponent("Application Support/URLCache", isDirectory: true) + let cache = initializer == "directory" + ? URLCache(memoryCapacity: 2_000_000, diskCapacity: 20_000_000, directory: directory) + : URLCache(memoryCapacity: 2_000_000, diskCapacity: 20_000_000, diskPath: directory.path) + _ = cache.currentDiskUsage + let real = FileManager.default.fileExists(atPath: directory.appendingPathComponent("Cache.db").path) + let encoded = FileManager.default.fileExists(atPath: root.appendingPathComponent("Application%20Support").path) + print("\(initializer) real=\(real) encoded=\(encoded)") +} +SWIFT + +swiftc -O "$workdir/probe.swift" -o "$workdir/probe" +output=$("$workdir/probe" "$workdir") + +echo "macOS $(sw_vers -productVersion)" +echo "$output" +if grep -qx 'diskPath real=true encoded=false' <<<"$output"; then + echo "ok: diskPath: opens the cache at the file-system path" +else + echo "FAIL: diskPath: does not open the cache at the file-system path" >&2 + exit 1 +fi From ff19f8b24dda8a98e8468fbf0af6462cfd0404f9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 01:51:50 +0700 Subject: [PATCH 2/2] fix(plugins): add the changelog fragment --- apple/changelog.d/3677.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3677.fixed.md diff --git a/apple/changelog.d/3677.fixed.md b/apple/changelog.d/3677.fixed.md new file mode 100644 index 0000000000..7f69a10c6d --- /dev/null +++ b/apple/changelog.d/3677.fixed.md @@ -0,0 +1 @@ +On macOS 15 and earlier, launching TablePro created a stray Application%20Support folder in ~/Library.