Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions apple/TablePro/Core/Plugins/Registry/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions apple/TablePro/Core/Plugins/Registry/RegistryStorageSweep.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions apple/TableProTests/Core/Plugins/RegistryStorageSweepTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
1 change: 1 addition & 0 deletions apple/changelog.d/3677.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On macOS 15 and earlier, launching TablePro created a stray Application%20Support folder in ~/Library.
49 changes: 49 additions & 0 deletions apple/scripts/probes/check-urlcache-directory-path.sh
Original file line number Diff line number Diff line change
@@ -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
Loading