diff --git a/TypeSwitch/Sources/App/AppAvailabilityClient.swift b/TypeSwitch/Sources/App/AppAvailabilityClient.swift new file mode 100644 index 0000000..0f0d227 --- /dev/null +++ b/TypeSwitch/Sources/App/AppAvailabilityClient.swift @@ -0,0 +1,42 @@ +import Dependencies +import Foundation + +/// Seeded at startup and replaced on each menu opening or before rule cleanup. +/// Keying by path prevents a rule's changed path from reusing its old result. +struct AppAvailabilitySnapshot: Equatable, Sendable { + var availablePaths: Set = [] + + func availablePath(for rule: AppRuleRecord) -> String? { + guard let path = rule.lastKnownPath, availablePaths.contains(path) else { return nil } + return path + } + + func isAvailable(_ rule: AppRuleRecord) -> Bool { + availablePath(for: rule) != nil + } + + func appInfo(for rule: AppRuleRecord) -> AppInfo { + AppInfo(bundleId: rule.bundleId, name: rule.lastKnownName, path: availablePath(for: rule)) + } +} + +struct AppAvailabilityClient: Sendable { + var pathExists: @Sendable (String) -> Bool + + func snapshot(for rules: some Sequence) -> AppAvailabilitySnapshot { + let paths = Set(rules.compactMap(\.lastKnownPath)) + return AppAvailabilitySnapshot(availablePaths: Set(paths.filter(pathExists))) + } +} + +extension AppAvailabilityClient: DependencyKey { + static let liveValue = Self(pathExists: { FileManager.default.fileExists(atPath: $0) }) + static let testValue = Self(pathExists: { _ in false }) +} + +extension DependencyValues { + var appAvailabilityClient: AppAvailabilityClient { + get { self[AppAvailabilityClient.self] } + set { self[AppAvailabilityClient.self] = newValue } + } +} diff --git a/TypeSwitch/Sources/App/AppFeature+MenuState.swift b/TypeSwitch/Sources/App/AppFeature+MenuState.swift index 4c11d68..6fd97bd 100644 --- a/TypeSwitch/Sources/App/AppFeature+MenuState.swift +++ b/TypeSwitch/Sources/App/AppFeature+MenuState.swift @@ -185,7 +185,7 @@ extension AppFeature.State { sortedRules .filter { let strategy = strategyForMenu(bundleId: $0.bundleId) - return strategy != .none && strategy != .ignored && $0.isAvailable + return strategy != .none && strategy != .ignored && appAvailability.isAvailable($0) } .map { menuItem(from: $0, strategy: strategyForMenu(bundleId: $0.bundleId)) } } @@ -217,7 +217,7 @@ extension AppFeature.State { var unavailableApps: [AppMenuItem] { sortedRules .filter { - !$0.isAvailable && strategyForMenu(bundleId: $0.bundleId) != .ignored + !appAvailability.isAvailable($0) && strategyForMenu(bundleId: $0.bundleId) != .ignored } .map { menuItem( @@ -273,7 +273,7 @@ extension AppFeature.State { menuItem( bundleId: rule.bundleId, name: rule.lastKnownName, - path: rule.isAvailable ? rule.lastKnownPath : nil, + path: appAvailability.availablePath(for: rule), strategy: strategy ) } @@ -306,7 +306,7 @@ extension AppFeature.State { } if let rule = appRules[bundleId] { - return rule.appInfo + return appAvailability.appInfo(for: rule) } return AppInfo(bundleId: bundleId, name: bundleId, path: nil) @@ -317,7 +317,7 @@ extension AppFeature.State { return runningApp } - return appRules[bundleId]?.appInfo + return appRules[bundleId].map { appAvailability.appInfo(for: $0) } } private func menuItem( diff --git a/TypeSwitch/Sources/App/AppFeature.swift b/TypeSwitch/Sources/App/AppFeature.swift index a8b8628..7b79e58 100644 --- a/TypeSwitch/Sources/App/AppFeature.swift +++ b/TypeSwitch/Sources/App/AppFeature.swift @@ -4,6 +4,7 @@ import Sharing @Reducer struct AppFeature { + @Dependency(\.appAvailabilityClient) var appAvailabilityClient @Dependency(\.date.now) var now @Dependency(\.launchAtLoginClient) var launchAtLoginClient @Dependency(\.workspaceClient) var workspaceClient @@ -40,6 +41,7 @@ struct AppFeature { @Shared var appSwitchStatisticsStore: AppSwitchStatisticsStore @Shared var fallbackRuleStore: FallbackRuleStore var switching: SwitchingFeature.State + var appAvailability = AppAvailabilitySnapshot() var isMenuPresented = false var isReadmeDemo = false var launchAtLoginStatus: LaunchAtLoginStatus = .disabled @@ -59,6 +61,7 @@ struct AppFeature { wrappedValue: FallbackRuleStore(), .fileStorage(.fallbackRuleStoreURL) ), + appAvailability: AppAvailabilitySnapshot = AppAvailabilitySnapshot(), currentFrontmostBundleId: String? = nil, inputMethodCatalogStatus: SwitchingFeature.State.InputMethodCatalogStatus = .loading, inputMethods: [InputMethod] = [], @@ -81,6 +84,7 @@ struct AppFeature { inputMethods: inputMethods, lastSwitchAttempt: lastSwitchAttempt ) + self.appAvailability = appAvailability self.isMenuPresented = isMenuPresented self.isReadmeDemo = isReadmeDemo self.launchAtLoginStatus = launchAtLoginStatus @@ -131,6 +135,9 @@ struct AppFeature { switch action { case .menuPresented: guard !state.isMenuPresented else { return .none } + if !state.isReadmeDemo { + state.appAvailability = appAvailabilityClient.snapshot(for: state.appRules.values) + } state.isMenuPresented = true state.menuStrategiesAtPresentation = state.appRules.mapValues(\.strategy) return .none @@ -142,6 +149,7 @@ struct AppFeature { case .task: guard !state.isReadmeDemo else { return .none } + state.appAvailability = appAvailabilityClient.snapshot(for: state.appRules.values) normalizeFallbackRule(in: &state) let initialSwitchingEffect = reduceSwitching(.loadInitialState, state: &state) return .merge( @@ -221,9 +229,12 @@ struct AppFeature { return .none case .view(.removeUnavailableRulesTapped): + // Deletion must recheck paths rather than trust the menu snapshot. + let availability = appAvailabilityClient.snapshot(for: state.appRules.values) + state.appAvailability = availability state.$appRulesStore.withLock { store in store.rules = store.rules.filter { - $0.value.isAvailable || $0.value.strategy == .ignored + availability.isAvailable($0.value) || $0.value.strategy == .ignored } } return .none diff --git a/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift b/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift index aa04847..4b4b1a2 100644 --- a/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift +++ b/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift @@ -185,6 +185,7 @@ struct ReadmeScreenshotConfiguration: Equatable { fallbackRuleStore: Shared( value: FallbackRuleStore(strategy: .fixed(inputMethodId: abcID)) ), + appAvailability: AppAvailabilitySnapshot(availablePaths: Set(rules.values.compactMap(\.lastKnownPath))), currentFrontmostBundleId: safari.bundleId, inputMethods: [ InputMethod(id: abcID, name: "ABC"), diff --git a/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift b/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift index 7eacb1e..3a6c7db 100644 --- a/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift +++ b/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift @@ -10,17 +10,4 @@ struct AppRuleRecord: Identifiable, Codable, Hashable, Sendable { var updatedAt: Date var id: String { bundleId } - - var isAvailable: Bool { - guard let lastKnownPath else { return false } - return FileManager.default.fileExists(atPath: lastKnownPath) - } - - var appInfo: AppInfo { - AppInfo( - bundleId: bundleId, - name: lastKnownName, - path: isAvailable ? lastKnownPath : nil - ) - } } diff --git a/TypeSwitchTests/AppAvailabilityTests.swift b/TypeSwitchTests/AppAvailabilityTests.swift new file mode 100644 index 0000000..0b3f0f9 --- /dev/null +++ b/TypeSwitchTests/AppAvailabilityTests.swift @@ -0,0 +1,114 @@ +import ComposableArchitecture +import Foundation +import Sharing +@testable import TypeSwitch +import XCTest + +@MainActor +final class AppAvailabilityTests: XCTestCase { + func testMenuSnapshotRefreshesOnlyOnNewPresentation() async { + let rule = makeRule("editor", path: "/apps/Editor.app") + let store = TestStore(initialState: AppFeature.State( + appRulesStore: Shared(value: AppRulesStore(rules: [rule.bundleId: rule])) + )) { + AppFeature() + } + store.dependencies.appAvailabilityClient.pathExists = { $0 == "/apps/Editor.app" } + + await store.send(.menuPresented) { + $0.isMenuPresented = true + $0.menuStrategiesAtPresentation = [rule.bundleId: rule.strategy] + $0.appAvailability = AppAvailabilitySnapshot(availablePaths: [rule.lastKnownPath!]) + } + store.dependencies.appAvailabilityClient.pathExists = { _ in + XCTFail("Reading or presenting an already open menu must not probe again") + return false + } + XCTAssertEqual(store.state.configuredApps.map(\.bundleId), [rule.bundleId]) + XCTAssertEqual(store.state.configuredApps.first?.path, rule.lastKnownPath) + XCTAssertTrue(store.state.unavailableApps.isEmpty) + await store.send(.menuPresented) + await store.send(.menuDismissed) { + $0.isMenuPresented = false + $0.menuStrategiesAtPresentation = [:] + } + store.dependencies.appAvailabilityClient.pathExists = { _ in false } + await store.send(.menuPresented) { + $0.isMenuPresented = true + $0.menuStrategiesAtPresentation = [rule.bundleId: rule.strategy] + $0.appAvailability = AppAvailabilitySnapshot() + } + XCTAssertTrue(store.state.configuredApps.isEmpty) + XCTAssertEqual(store.state.unavailableApps.map(\.bundleId), [rule.bundleId]) + XCTAssertNil(store.state.unavailableApps.first?.path) + } + + func testCleanupRechecksRestoredAndRemovedAppsAndPreservesIgnoredRules() async { + let restored = makeRule("restored", path: "/apps/Restored.app") + let removed = makeRule("removed", path: "/apps/Removed.app") + let ignored = makeRule("ignored", path: "/apps/Ignored.app", strategy: .ignored) + let noPath = makeRule("no-path", path: nil) + let rules = [restored, removed, ignored, noPath] + let store = TestStore(initialState: AppFeature.State( + appRulesStore: Shared(value: AppRulesStore(rules: Dictionary(uniqueKeysWithValues: rules.map { ($0.bundleId, $0) }))) + )) { + AppFeature() + } + store.dependencies.appAvailabilityClient.pathExists = { $0 == "/apps/Removed.app" } + await store.send(.menuPresented) { + $0.isMenuPresented = true + $0.menuStrategiesAtPresentation = Dictionary(uniqueKeysWithValues: rules.map { ($0.bundleId, $0.strategy) }) + $0.appAvailability = AppAvailabilitySnapshot(availablePaths: [removed.lastKnownPath!]) + } + XCTAssertEqual(Set(store.state.unavailableApps.map(\.bundleId)), [restored.bundleId, noPath.bundleId]) + + store.dependencies.appAvailabilityClient.pathExists = { $0 == "/apps/Restored.app" } + await store.send(.view(.removeUnavailableRulesTapped)) { + $0.appAvailability = AppAvailabilitySnapshot(availablePaths: [restored.lastKnownPath!]) + $0.$appRulesStore.withLock { + $0.rules = [restored.bundleId: restored, ignored.bundleId: ignored] + } + } + XCTAssertEqual(store.state.configuredApps.map(\.bundleId), [restored.bundleId]) + XCTAssertTrue(store.state.unavailableApps.isEmpty) + } + + func testSnapshotDoesNotReuseAvailabilityForChangedPath() { + var rule = makeRule("editor", path: "/apps/Editor.app") + let snapshot = AppAvailabilityClient(pathExists: { _ in true }).snapshot(for: [rule]) + XCTAssertEqual(snapshot.appInfo(for: rule).path, rule.lastKnownPath) + rule.lastKnownPath = "/moved/Editor.app" + XCTAssertFalse(snapshot.isAvailable(rule)) + XCTAssertNil(snapshot.appInfo(for: rule).path) + XCTAssertEqual(snapshot.appInfo(for: rule).name, rule.lastKnownName) + rule.lastKnownPath = nil + XCTAssertFalse(snapshot.isAvailable(rule)) + } + + func testLiveAdapterObservesDirectoryCreationAndRemoval() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let rule = makeRule("editor", path: directory.path) + let client = AppAvailabilityClient.liveValue + XCTAssertFalse(client.snapshot(for: [rule]).isAvailable(rule)) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + XCTAssertTrue(client.snapshot(for: [rule]).isAvailable(rule)) + try FileManager.default.removeItem(at: directory) + XCTAssertFalse(client.snapshot(for: [rule]).isAvailable(rule)) + } + + private func makeRule( + _ bundleId: String, + path: String?, + strategy: InputMethodStrategy = .fixed(inputMethodId: "ime.en") + ) -> AppRuleRecord { + AppRuleRecord( + bundleId: bundleId, + lastKnownPath: path, + lastKnownName: bundleId, + strategy: strategy, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } +} diff --git a/TypeSwitchTests/AppFeatureTests.swift b/TypeSwitchTests/AppFeatureTests.swift index 95500ff..9b0e18b 100644 --- a/TypeSwitchTests/AppFeatureTests.swift +++ b/TypeSwitchTests/AppFeatureTests.swift @@ -1988,15 +1988,7 @@ final class AppFeatureTests: XCTestCase { } func testFollowLastMissingInputMethodShowsMissingLabelAndSkipsSwitch() async throws { - let appURL = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) - try FileManager.default.createDirectory( - at: appURL, - withIntermediateDirectories: true, - attributes: nil - ) - defer { try? FileManager.default.removeItem(at: appURL) } - - let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: appURL.path) + let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") let missingInputMethod = "ime.deleted" let recorder = SwitchRecorder() @@ -2014,6 +2006,8 @@ final class AppFeatureTests: XCTestCase { ) } + initialState.appAvailability = AppAvailabilitySnapshot(availablePaths: [app.path!]) + XCTAssertEqual( initialState.configuredApps.first?.selectedLabel, TypeSwitchStrings.InputMethod.followLastMissingOption @@ -2323,19 +2317,11 @@ final class AppFeatureTests: XCTestCase { } func testRemoveUnavailableRulesTappedRemovesOnlyUnavailableRules() async throws { - let availableURL = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) - try FileManager.default.createDirectory( - at: availableURL, - withIntermediateDirectories: true, - attributes: nil - ) - defer { try? FileManager.default.removeItem(at: availableURL) } - var initialState = AppFeature.State() initialState.$appRulesStore.withLock { $0.rules["available"] = AppRuleRecord( bundleId: "available", - lastKnownPath: availableURL.path, + lastKnownPath: "/Applications/Available.app", lastKnownName: "Available", strategy: .fixed(inputMethodId: "ime.en"), createdAt: Date(timeIntervalSince1970: 10), @@ -2355,7 +2341,10 @@ final class AppFeatureTests: XCTestCase { AppFeature() } + store.dependencies.appAvailabilityClient.pathExists = { $0 == "/Applications/Available.app" } + await store.send(.view(.removeUnavailableRulesTapped)) { + $0.appAvailability = AppAvailabilitySnapshot(availablePaths: ["/Applications/Available.app"]) $0.$appRulesStore.withLock { _ = $0.rules.removeValue(forKey: "missing") } diff --git a/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift b/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift index 2d09b37..458e21e 100644 --- a/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift +++ b/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift @@ -103,6 +103,10 @@ final class ReadmeScreenshotConfigurationTests: XCTestCase { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { + $0.appAvailabilityClient.pathExists = { _ in + XCTFail("README demo mode must not inspect the file system") + return false + } $0.workspaceClient.frontmostApplication = { XCTFail("README demo mode must not inspect the live frontmost app") return nil @@ -135,5 +139,11 @@ final class ReadmeScreenshotConfigurationTests: XCTestCase { await store.send(.view(.setLaunchAtLogin(false))) XCTAssertEqual(store.state, initialState) + + await store.send(.menuPresented) { + $0.isMenuPresented = true + $0.menuStrategiesAtPresentation = initialState.appRules.mapValues(\.strategy) + } + XCTAssertEqual(store.state.configuredApps.map(\.name), ["Safari", "Terminal"]) } }