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
42 changes: 42 additions & 0 deletions TypeSwitch/Sources/App/AppAvailabilityClient.swift
Original file line number Diff line number Diff line change
@@ -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<String> = []

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<AppRuleRecord>) -> 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 }
}
}
10 changes: 5 additions & 5 deletions TypeSwitch/Sources/App/AppFeature+MenuState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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)
Expand All @@ -317,7 +317,7 @@ extension AppFeature.State {
return runningApp
}

return appRules[bundleId]?.appInfo
return appRules[bundleId].map { appAvailability.appInfo(for: $0) }
}

private func menuItem(
Expand Down
13 changes: 12 additions & 1 deletion TypeSwitch/Sources/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -59,6 +61,7 @@ struct AppFeature {
wrappedValue: FallbackRuleStore(),
.fileStorage(.fallbackRuleStoreURL)
),
appAvailability: AppAvailabilitySnapshot = AppAvailabilitySnapshot(),
currentFrontmostBundleId: String? = nil,
inputMethodCatalogStatus: SwitchingFeature.State.InputMethodCatalogStatus = .loading,
inputMethods: [InputMethod] = [],
Expand All @@ -81,6 +84,7 @@ struct AppFeature {
inputMethods: inputMethods,
lastSwitchAttempt: lastSwitchAttempt
)
self.appAvailability = appAvailability
self.isMenuPresented = isMenuPresented
self.isReadmeDemo = isReadmeDemo
self.launchAtLoginStatus = launchAtLoginStatus
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
13 changes: 0 additions & 13 deletions TypeSwitch/Sources/Core/Models/AppRuleRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
114 changes: 114 additions & 0 deletions TypeSwitchTests/AppAvailabilityTests.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
25 changes: 7 additions & 18 deletions TypeSwitchTests/AppFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -2014,6 +2006,8 @@ final class AppFeatureTests: XCTestCase {
)
}

initialState.appAvailability = AppAvailabilitySnapshot(availablePaths: [app.path!])

XCTAssertEqual(
initialState.configuredApps.first?.selectedLabel,
TypeSwitchStrings.InputMethod.followLastMissingOption
Expand Down Expand Up @@ -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),
Expand All @@ -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")
}
Expand Down
10 changes: 10 additions & 0 deletions TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
}
}
Loading