diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e67cae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: macos-26 + steps: + - name: Check out Floodlight + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Run tests + run: make test + + - name: Build app bundle + run: make bundle diff --git a/Makefile b/Makefile index 9cb0bee..cd9d7bc 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ run: debug ./scripts/run.sh test: + swift test --package-path Vendor/fff-swift swift test bundle: build diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index c048ff7..0000000 --- a/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "d09b6290027042f03030e04e135ee9939b910481f1f6c4c5901d1c6414d86201", - "pins" : [ - { - "identity" : "fff-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vmg-dev/fff-swift", - "state" : { - "revision" : "dbc38f5c81f44d0c7bac434f1f247ca01f1dfc9c", - "version" : "0.2.1" - } - } - ], - "version" : 3 -} diff --git a/Package.swift b/Package.swift index f1fa135..7721a5b 100644 --- a/Package.swift +++ b/Package.swift @@ -11,10 +11,7 @@ let package = Package( .executable(name: "Floodlight", targets: ["Floodlight"]) ], dependencies: [ - .package( - url: "https://github.com/vmg-dev/fff-swift", - from: "0.2.1" - ) + .package(path: "Vendor/fff-swift") ], targets: [ .executableTarget( diff --git a/Sources/Floodlight/Search/SearchCoordinator.swift b/Sources/Floodlight/Search/SearchCoordinator.swift index 7242871..3e7fd63 100644 --- a/Sources/Floodlight/Search/SearchCoordinator.swift +++ b/Sources/Floodlight/Search/SearchCoordinator.swift @@ -1,7 +1,6 @@ import AppKit import Foundation import Observation -import ServiceManagement @MainActor @Observable @@ -55,6 +54,7 @@ final class SearchCoordinator { private let index: FFFIndex private let applicationCatalog: ApplicationCatalog + private let launchAtLoginController = LaunchAtLoginController() private let recentStore: RecentStore private let quickLook = QuickLookController() private var allResults: [SearchItem] = [] @@ -102,6 +102,7 @@ final class SearchCoordinator { index = FFFIndex( rootURL: initialRoot, storageURL: indexStorage, + enableHomeDirectoryScanning: true, logFilePath: environment["FLOODLIGHT_FFF_LOG"], logLevel: environment["FLOODLIGHT_FFF_LOG_LEVEL"] ?? "info" ) @@ -323,7 +324,7 @@ final class SearchCoordinator { } var launchesAtLogin: Bool { - SMAppService.mainApp.status == .enabled + launchAtLoginController.isEnabled } /// Registers the login item on the very first launch only. @@ -333,32 +334,13 @@ final class SearchCoordinator { /// decision: if you later turn it off — here or in System Settings — the /// next launch leaves it off instead of switching it back on. func enableLaunchAtLoginOnFirstRun() { - let defaults = UserDefaults.standard - guard !defaults.bool(forKey: Self.launchAtLoginConfiguredKey) else { return } - defaults.set(true, forKey: Self.launchAtLoginConfiguredKey) - - guard SMAppService.mainApp.status != .enabled else { return } - do { - try SMAppService.mainApp.register() - } catch { - NSLog( - "Floodlight could not enable launch at login: %@", - error.localizedDescription - ) - } + launchAtLoginController.enableOnFirstRun() } func setLaunchAtLogin(_ enabled: Bool) throws { - if enabled { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - UserDefaults.standard.set(true, forKey: Self.launchAtLoginConfiguredKey) + try launchAtLoginController.setEnabled(enabled) } - private static let launchAtLoginConfiguredKey = "launch-at-login-configured" - private var selectedItem: SearchItem? { guard let selectedID else { return results.first } return results.first { $0.id == selectedID } diff --git a/Sources/Floodlight/Utilities/LaunchAtLoginController.swift b/Sources/Floodlight/Utilities/LaunchAtLoginController.swift new file mode 100644 index 0000000..e19838e --- /dev/null +++ b/Sources/Floodlight/Utilities/LaunchAtLoginController.swift @@ -0,0 +1,122 @@ +import Foundation +import ServiceManagement + +@MainActor +protocol LaunchAtLoginService: AnyObject { + var status: SMAppService.Status { get } + + func register() throws + func unregister() throws +} + +extension SMAppService: LaunchAtLoginService {} + +enum LaunchAtLoginError: LocalizedError { + case requiresApproval + case unavailable + + var errorDescription: String? { + switch self { + case .requiresApproval: + "Allow Floodlight in System Settings → General → Login Items & Extensions." + case .unavailable: + "macOS did not enable Floodlight as a login item. Try again after moving Floodlight to Applications." + } + } +} + +@MainActor +final class LaunchAtLoginController { + static let configuredKey = "launch-at-login-configured" + + private let service: any LaunchAtLoginService + private let defaults: UserDefaults + private let logError: (String) -> Void + + init( + service: any LaunchAtLoginService = SMAppService.mainApp, + defaults: UserDefaults = .standard, + logError: @escaping (String) -> Void = { message in + NSLog("Floodlight could not enable launch at login: %@", message) + } + ) { + self.service = service + self.defaults = defaults + self.logError = logError + } + + var isEnabled: Bool { + service.status == .enabled + } + + /// Opts in on the first successful registration attempt. + /// + /// A failed attempt deliberately leaves the preference unset so a later + /// launch can retry. A user-denied item is considered configured and is + /// never re-enabled behind the user's back. + func enableOnFirstRun() { + guard !defaults.bool(forKey: Self.configuredKey) else { return } + + switch service.status { + case .enabled, .requiresApproval: + markConfigured() + return + case .notRegistered, .notFound: + break + @unknown default: + break + } + + do { + try service.register() + try requireEnabledStatus() + markConfigured() + } catch { + logError(error.localizedDescription) + } + } + + func setEnabled(_ enabled: Bool) throws { + if enabled { + switch service.status { + case .enabled: + break + case .requiresApproval: + throw LaunchAtLoginError.requiresApproval + case .notRegistered, .notFound: + try service.register() + try requireEnabledStatus() + @unknown default: + throw LaunchAtLoginError.unavailable + } + } else { + switch service.status { + case .enabled, .requiresApproval: + try service.unregister() + case .notRegistered, .notFound: + break + @unknown default: + break + } + } + + markConfigured() + } + + private func requireEnabledStatus() throws { + switch service.status { + case .enabled: + return + case .requiresApproval: + throw LaunchAtLoginError.requiresApproval + case .notRegistered, .notFound: + throw LaunchAtLoginError.unavailable + @unknown default: + throw LaunchAtLoginError.unavailable + } + } + + private func markConfigured() { + defaults.set(true, forKey: Self.configuredKey) + } +} diff --git a/Tests/FloodlightTests/LaunchAtLoginControllerTests.swift b/Tests/FloodlightTests/LaunchAtLoginControllerTests.swift new file mode 100644 index 0000000..f95396a --- /dev/null +++ b/Tests/FloodlightTests/LaunchAtLoginControllerTests.swift @@ -0,0 +1,112 @@ +import ServiceManagement +import XCTest +@testable import Floodlight + +@MainActor +final class LaunchAtLoginControllerTests: XCTestCase { + func testFailedFirstRegistrationIsRetriedOnNextLaunch() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = FakeLaunchAtLoginService(status: .notRegistered) + service.registerError = TestError.registrationFailed + var loggedErrors: [String] = [] + let controller = LaunchAtLoginController( + service: service, + defaults: defaults, + logError: { loggedErrors.append($0) } + ) + + controller.enableOnFirstRun() + + XCTAssertEqual(service.registerCallCount, 1) + XCTAssertFalse(defaults.bool(forKey: LaunchAtLoginController.configuredKey)) + XCTAssertEqual(loggedErrors.count, 1) + + service.registerError = nil + controller.enableOnFirstRun() + + XCTAssertEqual(service.registerCallCount, 2) + XCTAssertTrue(defaults.bool(forKey: LaunchAtLoginController.configuredKey)) + XCTAssertTrue(controller.isEnabled) + } + + func testAlreadyEnabledServiceCompletesFirstRunWithoutReregistering() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = FakeLaunchAtLoginService(status: .enabled) + let controller = LaunchAtLoginController(service: service, defaults: defaults) + + controller.enableOnFirstRun() + + XCTAssertEqual(service.registerCallCount, 0) + XCTAssertTrue(defaults.bool(forKey: LaunchAtLoginController.configuredKey)) + } + + func testExplicitOptOutPreventsAutomaticReregistration() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = FakeLaunchAtLoginService(status: .enabled) + let controller = LaunchAtLoginController(service: service, defaults: defaults) + + try controller.setEnabled(false) + controller.enableOnFirstRun() + + XCTAssertEqual(service.unregisterCallCount, 1) + XCTAssertEqual(service.registerCallCount, 0) + XCTAssertFalse(controller.isEnabled) + XCTAssertTrue(defaults.bool(forKey: LaunchAtLoginController.configuredKey)) + } + + func testDeniedServiceReturnsActionableError() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = FakeLaunchAtLoginService(status: .requiresApproval) + let controller = LaunchAtLoginController(service: service, defaults: defaults) + + XCTAssertThrowsError(try controller.setEnabled(true)) { error in + XCTAssertEqual( + error.localizedDescription, + "Allow Floodlight in System Settings → General → Login Items & Extensions." + ) + } + XCTAssertFalse(defaults.bool(forKey: LaunchAtLoginController.configuredKey)) + } + + private func makeDefaults() throws -> (UserDefaults, String) { + let suiteName = "FloodlightLaunchAtLoginTests-\(UUID().uuidString)" + return (try XCTUnwrap(UserDefaults(suiteName: suiteName)), suiteName) + } +} + +@MainActor +private final class FakeLaunchAtLoginService: LaunchAtLoginService { + var status: SMAppService.Status + var registerError: Error? + var unregisterError: Error? + private(set) var registerCallCount = 0 + private(set) var unregisterCallCount = 0 + + init(status: SMAppService.Status) { + self.status = status + } + + func register() throws { + registerCallCount += 1 + if let registerError { + throw registerError + } + status = .enabled + } + + func unregister() throws { + unregisterCallCount += 1 + if let unregisterError { + throw unregisterError + } + status = .notRegistered + } +} + +private enum TestError: Error { + case registrationFailed +} diff --git a/Vendor/fff-swift/LICENSE b/Vendor/fff-swift/LICENSE new file mode 100644 index 0000000..76d6d97 --- /dev/null +++ b/Vendor/fff-swift/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 FFF Swift contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Vendor/fff-swift/Package.swift b/Vendor/fff-swift/Package.swift new file mode 100644 index 0000000..b946e06 --- /dev/null +++ b/Vendor/fff-swift/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.10 + +import Foundation +import PackageDescription + +let localArtifactPath = "Artifacts/CFFF.xcframework" +let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() +let localArtifactURL = packageDirectory.appendingPathComponent(localArtifactPath) +let binaryVersion = "0.2.1" +let binaryChecksum = "900b222ced0ee8921cc35b037309d3957c32c4675ca0d12eef3a89370c418e23" +let cfffTarget: Target = FileManager.default.fileExists(atPath: localArtifactURL.path) + ? .binaryTarget(name: "CFFF", path: localArtifactPath) + : .binaryTarget( + name: "CFFF", + url: "https://github.com/vmg-dev/fff-swift/releases/download/\(binaryVersion)/CFFF.xcframework.zip", + checksum: binaryChecksum + ) + +let package = Package( + name: "FFFKit", + platforms: [.macOS(.v14)], + products: [ + .library(name: "FFFKit", targets: ["FFFKit"]) + ], + targets: [ + cfffTarget, + .target( + name: "FFFKit", + dependencies: ["CFFF"], + linkerSettings: [ + .linkedFramework("CoreFoundation"), + .linkedFramework("CoreServices"), + .linkedFramework("Security"), + .linkedLibrary("iconv"), + .linkedLibrary("z") + ] + ), + .testTarget(name: "FFFKitTests", dependencies: ["FFFKit"]) + ], + swiftLanguageVersions: [.v5] +) diff --git a/Vendor/fff-swift/README.md b/Vendor/fff-swift/README.md new file mode 100644 index 0000000..630a4cb --- /dev/null +++ b/Vendor/fff-swift/README.md @@ -0,0 +1,9 @@ +# Vendored FFFKit wrapper + +This directory contains the Swift wrapper sources from `fff-swift` 0.2.1. +Floodlight vendors the wrapper so it can explicitly allow its documented +home-directory search scope while continuing to use the unchanged, checksummed +`CFFF.xcframework` binary published with that release. + +The local change is intentionally limited to the +`enableHomeDirectoryScanning` initializer option and its regression test. diff --git a/Vendor/fff-swift/Sources/FFFKit/FFFIndex.swift b/Vendor/fff-swift/Sources/FFFKit/FFFIndex.swift new file mode 100644 index 0000000..867d6f6 --- /dev/null +++ b/Vendor/fff-swift/Sources/FFFKit/FFFIndex.swift @@ -0,0 +1,606 @@ +import CFFF +import Darwin +import Foundation + +public enum FFFIndexError: LocalizedError, Sendable { + case invalidResult + case message(String) + + public var errorDescription: String? { + switch self { + case .invalidResult: + "FFF returned an invalid result." + case .message(let message): + message + } + } +} + +public final class FFFIndex: @unchecked Sendable { + private let queue = DispatchQueue(label: "dev.vmg.fff-swift", qos: .userInitiated) + private let searchGenerationLock = NSLock() + private var latestSearchGeneration: UInt64 = 0 + private var handle: UnsafeMutableRawPointer? + private var rootURL: URL + private let homeURL: URL + private let storageURL: URL? + private let enableContentIndexing: Bool + private let includeBinaryFiles: Bool + private let watch: Bool + private let enableHomeDirectoryScanning: Bool + private let logFilePath: String? + private let logLevel: String + + public init( + rootURL: URL, + storageURL: URL? = nil, + enableContentIndexing: Bool = true, + includeBinaryFiles: Bool = true, + watch: Bool = true, + enableHomeDirectoryScanning: Bool = false, + logFilePath: String? = nil, + logLevel: String = "info", + homeURL: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + // FSEvents reports canonical paths. Resolve symlinked scopes up front + // so watcher events (notably /var -> /private/var) map back into the + // same path space as the initial index. + self.rootURL = Self.canonicalFileURL(rootURL) + self.homeURL = Self.canonicalFileURL(homeURL) + self.storageURL = storageURL + self.enableContentIndexing = enableContentIndexing + self.includeBinaryFiles = includeBinaryFiles + self.watch = watch + self.enableHomeDirectoryScanning = enableHomeDirectoryScanning + self.logFilePath = logFilePath + self.logLevel = logLevel + } + + deinit { + if let handle { + fff_destroy(handle) + } + } + + public func start() async throws { + try await perform { + guard self.handle == nil else { return } + + let fileManager = FileManager.default + let supportURL = try self.storageURL ?? fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("FFFKit", isDirectory: true) + try fileManager.createDirectory(at: supportURL, withIntermediateDirectories: true) + + let frecencyPath = supportURL.appendingPathComponent("frecency.lmdb").path + let historyPath = supportURL.appendingPathComponent("history.lmdb").path + let envelope = Self.withOptionalCString(self.logFilePath) { logFile in + self.logLevel.withCString { logLevel in + self.rootURL.path.withCString { root in + frecencyPath.withCString { frecency in + historyPath.withCString { history in + var options = FffCreateOptions() + options.version = UInt32(FFF_CREATE_OPTIONS_VERSION) + options.base_path = root + options.frecency_db_path = frecency + options.history_db_path = history + options.enable_mmap_cache = true + options.enable_content_indexing = self.enableContentIndexing + options.watch = self.watch + options.ai_mode = false + options.log_file_path = logFile + options.log_level = logFile == nil ? nil : logLevel + options.cache_budget_max_files = 0 + options.cache_budget_max_bytes = 0 + options.cache_budget_max_file_size = 0 + options.enable_fs_root_scanning = false + options.enable_home_dir_scanning = self.enableHomeDirectoryScanning + options.follow_symlinks = false + options.include_binary_files = self.includeBinaryFiles + return fff_create_instance_with(&options) + } + } + } + } + } + + self.handle = try Self.takeHandle(from: envelope) + } + } + + public func search(_ query: String, limit: UInt32 = 60) async throws -> [FFFSearchResult] { + let requestGeneration = reserveSearchGeneration() + return try await perform { + guard self.isLatestSearch(requestGeneration) else { + throw CancellationError() + } + guard let handle = self.handle else { + throw FFFIndexError.message("The FFF index has not started.") + } + + let resolvedQuery = Self.resolvePathQuery( + query, + rootURL: self.rootURL, + homeURL: self.homeURL + ) + if resolvedQuery.fffQuery.isEmpty, + let exactItem = Self.exactPathItem( + at: resolvedQuery.exactURL, + rootURL: self.rootURL + ) { + return [exactItem] + } + + let envelope = resolvedQuery.fffQuery.withCString { + fff_search_mixed(handle, $0, nil, 0, 0, limit, 100, 3) + } + guard let envelope else { throw FFFIndexError.invalidResult } + defer { fff_free_result(envelope) } + + guard envelope.pointee.success else { + throw FFFIndexError.message(Self.errorMessage(from: envelope)) + } + guard let raw = envelope.pointee.handle else { return [] } + + let result = raw.assumingMemoryBound(to: FffMixedSearchResult.self) + defer { fff_free_mixed_search_result(result) } + + var items: [FFFSearchResult] = (0.. [FFFSearchResult] { + let requestGeneration = reserveSearchGeneration() + return try await perform { + guard self.isLatestSearch(requestGeneration) else { + throw CancellationError() + } + guard let handle = self.handle else { + throw FFFIndexError.message("The FFF index has not started.") + } + + let envelope = query.withCString { + fff_search(handle, $0, nil, 0, 0, limit, 100, 3) + } + guard let envelope else { throw FFFIndexError.invalidResult } + defer { fff_free_result(envelope) } + + guard envelope.pointee.success else { + throw FFFIndexError.message(Self.errorMessage(from: envelope)) + } + guard let raw = envelope.pointee.handle else { return [] } + + let result = raw.assumingMemoryBound(to: FffSearchResult.self) + defer { fff_free_search_result(result) } + + return (0.. [FFFSearchResult] { + let requestGeneration = reserveSearchGeneration() + return try await perform { + guard self.isLatestSearch(requestGeneration) else { + throw CancellationError() + } + guard let handle = self.handle else { + throw FFFIndexError.message("The FFF index has not started.") + } + + let envelope = query.withCString { + fff_search_directories(handle, $0, nil, 0, 0, limit) + } + guard let envelope else { throw FFFIndexError.invalidResult } + defer { fff_free_result(envelope) } + + guard envelope.pointee.success else { + throw FFFIndexError.message(Self.errorMessage(from: envelope)) + } + guard let raw = envelope.pointee.handle else { return [] } + + let result = raw.assumingMemoryBound(to: FffDirSearchResult.self) + defer { fff_free_dir_search_result(result) } + + return (0.. UInt64 { + searchGenerationLock.lock() + defer { searchGenerationLock.unlock() } + latestSearchGeneration &+= 1 + return latestSearchGeneration + } + + private func isLatestSearch(_ generation: UInt64) -> Bool { + searchGenerationLock.lock() + defer { searchGenerationLock.unlock() } + return generation == latestSearchGeneration + } + + public func progress() async throws -> FFFIndexProgress { + try await perform { + guard let handle = self.handle else { + throw FFFIndexError.message("The FFF index has not started.") + } + + let envelope = fff_get_scan_progress(handle) + guard let envelope else { throw FFFIndexError.invalidResult } + defer { fff_free_result(envelope) } + + guard envelope.pointee.success else { + throw FFFIndexError.message(Self.errorMessage(from: envelope)) + } + guard let raw = envelope.pointee.handle else { throw FFFIndexError.invalidResult } + + let progress = raw.assumingMemoryBound(to: FffScanProgress.self) + defer { fff_free_scan_progress(progress) } + return FFFIndexProgress( + scannedFiles: progress.pointee.scanned_files_count, + isScanning: progress.pointee.is_scanning, + isWatcherReady: progress.pointee.is_watcher_ready + ) + } + } + + public func searchContent( + _ query: String, + limit: UInt32 = 16, + timeBudgetMilliseconds: UInt64 = 35 + ) async throws -> [FFFContentMatch] { + try await perform { + guard let handle = self.handle else { + throw FFFIndexError.message("The FFF index has not started.") + } + guard !query.isEmpty else { return [] } + + let envelope = query.withCString { + fff_live_grep( + handle, + $0, + 0, + 10 * 1_024 * 1_024, + 1, + true, + 0, + limit, + timeBudgetMilliseconds, + 0, + 0, + false + ) + } + guard let envelope else { throw FFFIndexError.invalidResult } + defer { fff_free_result(envelope) } + + guard envelope.pointee.success else { + throw FFFIndexError.message(Self.errorMessage(from: envelope)) + } + guard let raw = envelope.pointee.handle else { return [] } + + let result = raw.assumingMemoryBound(to: FffGrepResult.self) + defer { fff_free_grep_result(result) } + + return (0..( + _ operation: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try operation()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func requireSuccess(_ envelope: UnsafeMutablePointer?) throws { + guard let envelope else { throw FFFIndexError.invalidResult } + defer { fff_free_result(envelope) } + guard envelope.pointee.success else { + throw FFFIndexError.message(errorMessage(from: envelope)) + } + } + + private static func withOptionalCString( + _ value: String?, + _ body: (UnsafePointer?) -> Result + ) -> Result { + guard let value else { return body(nil) } + return value.withCString(body) + } + + private static func canonicalFileURL(_ url: URL) -> URL { + let standardized = url.standardizedFileURL + let resolvedPath = standardized.path.withCString { path -> String? in + guard let resolved = realpath(path, nil) else { return nil } + defer { free(resolved) } + return String(cString: resolved) + } + guard let resolvedPath else { return standardized } + return URL(fileURLWithPath: resolvedPath, isDirectory: standardized.hasDirectoryPath) + } + + private struct ResolvedPathQuery { + let fffQuery: String + let exactURL: URL? + } + + /// Converts shell-style home paths into the relative path space used by FFF. + /// It also records exact path-like queries so a matching folder cannot be + /// crowded out of a mixed result page by files with similar names. + private static func resolvePathQuery( + _ query: String, + rootURL: URL, + homeURL: URL + ) -> ResolvedPathQuery { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let keepsDirectorySuffix = trimmed.hasSuffix("/") + + let candidate: URL? + let requiresTranslation: Bool + if trimmed == "~" || trimmed == "~/" { + candidate = homeURL + requiresTranslation = true + } else if trimmed.hasPrefix("~/") { + candidate = homeURL.appendingPathComponent(String(trimmed.dropFirst(2))) + requiresTranslation = true + } else if trimmed.hasPrefix("/") { + candidate = URL(fileURLWithPath: trimmed) + requiresTranslation = true + } else if trimmed.contains("/") { + candidate = rootURL.appendingPathComponent(trimmed) + requiresTranslation = false + } else { + candidate = nil + requiresTranslation = false + } + + guard let candidate else { + return ResolvedPathQuery(fffQuery: trimmed, exactURL: nil) + } + + let standardizedCandidate = candidate.standardizedFileURL + guard let relativePath = relativePath(of: standardizedCandidate, within: rootURL) else { + return ResolvedPathQuery(fffQuery: trimmed, exactURL: nil) + } + + var fffQuery = requiresTranslation ? relativePath : trimmed + if requiresTranslation, keepsDirectorySuffix, !fffQuery.isEmpty { + fffQuery += "/" + } + return ResolvedPathQuery(fffQuery: fffQuery, exactURL: standardizedCandidate) + } + + private static func exactPathItem(at url: URL?, rootURL: URL) -> FFFSearchResult? { + guard let url, + let relativePath = relativePath(of: url, within: rootURL) else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return nil + } + + let components = relativePath.split(separator: "/") + if components.dropLast().contains(where: { $0.lowercased().hasSuffix(".app") }) { + return nil + } + + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + let modified = (attributes?[.modificationDate] as? Date).map { + UInt64(max(0, $0.timeIntervalSince1970)) + } ?? 0 + let size = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 + + return FFFSearchResult( + name: url.lastPathComponent, + relativePath: relativePath, + url: url, + isDirectory: isDirectory.boolValue, + // An explicitly typed path should rank above fuzzy matches. + score: 300_000, + modified: modified, + size: size + ) + } + + private static func relativePath(of url: URL, within rootURL: URL) -> String? { + let rootPath = rootURL.standardizedFileURL.path + let candidatePath = url.standardizedFileURL.path + if candidatePath == rootPath { + return "" + } + + let prefix = rootPath == "/" ? "/" : rootPath + "/" + guard candidatePath.hasPrefix(prefix) else { return nil } + return String(candidatePath.dropFirst(prefix.count)) + } + + private static func takeHandle( + from envelope: UnsafeMutablePointer? + ) throws -> UnsafeMutableRawPointer { + guard let envelope else { throw FFFIndexError.invalidResult } + defer { fff_free_result(envelope) } + guard envelope.pointee.success else { + throw FFFIndexError.message(errorMessage(from: envelope)) + } + guard let handle = envelope.pointee.handle else { throw FFFIndexError.invalidResult } + return handle + } + + private static func errorMessage(from envelope: UnsafeMutablePointer) -> String { + guard let pointer = envelope.pointee.error else { return "Unknown FFF error." } + return String(cString: pointer) + } +} diff --git a/Vendor/fff-swift/Sources/FFFKit/FFFModels.swift b/Vendor/fff-swift/Sources/FFFKit/FFFModels.swift new file mode 100644 index 0000000..6112104 --- /dev/null +++ b/Vendor/fff-swift/Sources/FFFKit/FFFModels.swift @@ -0,0 +1,65 @@ +import Foundation + +/// A file or directory returned by an FFF filename search. +public struct FFFSearchResult: Equatable, Sendable { + public let name: String + public let relativePath: String + public let url: URL + public let isDirectory: Bool + public let score: Int + public let modified: UInt64 + public let size: UInt64 + + public init( + name: String, + relativePath: String, + url: URL, + isDirectory: Bool, + score: Int, + modified: UInt64, + size: UInt64 + ) { + self.name = name + self.relativePath = relativePath + self.url = url + self.isDirectory = isDirectory + self.score = score + self.modified = modified + self.size = size + } +} +/// A line of file content returned by an FFF content search. +public struct FFFContentMatch: Equatable, Sendable { + public let name: String + public let relativePath: String + public let url: URL + public let line: UInt64 + public let snippet: String + + public init( + name: String, + relativePath: String, + url: URL, + line: UInt64, + snippet: String + ) { + self.name = name + self.relativePath = relativePath + self.url = url + self.line = line + self.snippet = snippet + } +} + +/// The current state of the asynchronous FFF scan and filesystem watcher. +public struct FFFIndexProgress: Equatable, Sendable { + public let scannedFiles: UInt64 + public let isScanning: Bool + public let isWatcherReady: Bool + + public init(scannedFiles: UInt64, isScanning: Bool, isWatcherReady: Bool) { + self.scannedFiles = scannedFiles + self.isScanning = isScanning + self.isWatcherReady = isWatcherReady + } +} diff --git a/Vendor/fff-swift/Tests/FFFKitTests/HomeDirectoryScanningTests.swift b/Vendor/fff-swift/Tests/FFFKitTests/HomeDirectoryScanningTests.swift new file mode 100644 index 0000000..4ee53cf --- /dev/null +++ b/Vendor/fff-swift/Tests/FFFKitTests/HomeDirectoryScanningTests.swift @@ -0,0 +1,40 @@ +import Foundation +import XCTest +@testable import FFFKit + +final class HomeDirectoryScanningTests: XCTestCase { + func testExplicitlyEnabledHomeDirectoryCanStart() async throws { + let fileManager = FileManager.default + let parent = fileManager.temporaryDirectory + .appendingPathComponent("FFFKitHomeScanTests-\(UUID().uuidString)") + let home = fileManager.homeDirectoryForCurrentUser + let storage = parent.appendingPathComponent("Storage", isDirectory: true) + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: parent) } + + let denied = FFFIndex( + rootURL: home, + storageURL: storage.appendingPathComponent("Denied"), + enableContentIndexing: false, + watch: false + ) + do { + try await denied.start() + XCTFail("FFF unexpectedly allowed a home-directory scan without opt-in") + } catch FFFIndexError.message(let message) { + XCTAssertFalse(message.isEmpty) + } catch { + XCTFail("FFF returned an unexpected error: \(error)") + } + + let allowed = FFFIndex( + rootURL: home, + storageURL: storage.appendingPathComponent("Allowed"), + enableContentIndexing: false, + watch: false, + enableHomeDirectoryScanning: true + ) + try await allowed.start() + _ = try await allowed.progress() + } +}