diff --git a/Sources/Core/CorrectionContext.swift b/Sources/Core/CorrectionContext.swift deleted file mode 100644 index 3c8c309..0000000 --- a/Sources/Core/CorrectionContext.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation - -public enum RecentOutcome { - case validCurrent - case corrected - case unknown -} - -public struct SuppressedShort { - public let originalWord: String - public let convertedWord: String - public let targetLayout: Layout - public let boundaryAfterWord: String - - public init(originalWord: String, convertedWord: String, targetLayout: Layout, boundaryAfterWord: String) { - self.originalWord = originalWord - self.convertedWord = convertedWord - self.targetLayout = targetLayout - self.boundaryAfterWord = boundaryAfterWord - } -} - -public struct CorrectionContext { - private var recentOutcomes: [RecentOutcome] = [] - public var pendingSuppressedShort: SuppressedShort? - - public var shortWordSuppressionContextWindow: Int = 6 - public var shortWordSuppressionMinValidContext: Int = 2 - public var shortWordSuppressionLength: Int = 2 - - public init() {} - - public mutating func reset() { - recentOutcomes.removeAll(keepingCapacity: true) - pendingSuppressedShort = nil - } - - public mutating func recordOutcome(_ outcome: RecentOutcome) { - recentOutcomes.append(outcome) - let window = max(1, shortWordSuppressionContextWindow) - if recentOutcomes.count > window { - recentOutcomes.removeFirst(recentOutcomes.count - window) - } - } - - public mutating func consumePendingSuppressedShort() -> SuppressedShort? { - let value = pendingSuppressedShort - pendingSuppressedShort = nil - return value - } - - public func hasStrongCurrentContext() -> Bool { - let window = max(1, shortWordSuppressionContextWindow) - let recent = recentOutcomes.suffix(window) - let validCount = recent.reduce(0) { partial, outcome in - if case .validCurrent = outcome { - return partial + 1 - } - return partial - } - let hasRecentCorrection = recent.contains { outcome in - if case .corrected = outcome { return true } - return false - } - return validCount >= shortWordSuppressionMinValidContext && !hasRecentCorrection - } - - public func shouldSuppressLowConfidenceCorrection( - original: String, - converted: String, - targetLayout: Layout, - sourceLayout: Layout, - isLowConfidence: Bool, - shouldSwitch: Bool - ) -> Bool { - guard isLowConfidence else { return false } - guard original.count <= shortWordSuppressionLength else { return false } - guard !shouldSwitch else { return false } - guard targetLayout != sourceLayout else { return false } - guard !converted.isEmpty else { return false } - return hasStrongCurrentContext() - } - - public func shouldSuppressAcronymFallback( - targetLayout: Layout, - sourceLayout: Layout, - shouldSwitch: Bool - ) -> Bool { - guard targetLayout != sourceLayout else { return false } - guard !shouldSwitch else { return false } - return hasStrongCurrentContext() - } - - public func mergeSuppressedShort( - _ suppressed: SuppressedShort?, - currentOriginal: String, - currentConverted: String, - targetLayout: Layout, - isLowConfidence: Bool, - shouldSwitch: Bool - ) -> (original: String, converted: String)? { - guard let suppressed = suppressed else { return nil } - guard suppressed.targetLayout == targetLayout else { return nil } - - // Merge only when the current word provides stronger evidence than the suppressed short word. - let hasStrongCurrentSignal = currentOriginal.count > shortWordSuppressionLength || shouldSwitch || !isLowConfidence - guard hasStrongCurrentSignal else { return nil } - - let bridge = suppressed.boundaryAfterWord - guard !bridge.isEmpty else { return nil } - - return ( - original: suppressed.originalWord + bridge + currentOriginal, - converted: suppressed.convertedWord + bridge + currentConverted - ) - } -} diff --git a/Sources/Core/InputEngine.swift b/Sources/Core/InputEngine.swift index 74860f8..8bc5d8a 100644 --- a/Sources/Core/InputEngine.swift +++ b/Sources/Core/InputEngine.swift @@ -265,6 +265,9 @@ public final class InputEngine { let liveContext = captureState.snapshot().context guard input.context == liveContext else { _ = stateMachine.updateContext(liveContext) + // The dropped event may have reached the app; don't let a partial + // word buffer up and get "corrected" with a wrong delete count. + stateMachine.invalidateUntilBoundary() resetDetectorState() logger.debug("buffer invalidated reason=stale-capture-context") return diff --git a/Sources/Core/InputSourceManager.swift b/Sources/Core/InputSourceManager.swift index 09e6cd5..a4d74e9 100644 --- a/Sources/Core/InputSourceManager.swift +++ b/Sources/Core/InputSourceManager.swift @@ -238,27 +238,25 @@ public final class InputSourceManager { return nil } let layoutData = unsafeBitCast(layoutDataReference, to: CFData.self) as Data - guard let keyboardLayout = layoutData.withUnsafeBytes({ pointer in - pointer.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self) - }) else { - return nil - } - var deadKeyState: UInt32 = 0 var characters = [UniChar](repeating: 0, count: 4) var actualLength = 0 - let status = UCKeyTranslate( - keyboardLayout, - keyCode, - UInt16(kUCKeyActionDown), - 0, - UInt32(LMGetKbdType()), - UInt32(kUCKeyTranslateNoDeadKeysBit), - &deadKeyState, - characters.count, - &actualLength, - &characters - ) + // The layout pointer is only valid inside withUnsafeBytes. + let status = layoutData.withUnsafeBytes { pointer -> OSStatus in + guard let baseAddress = pointer.baseAddress else { return OSStatus(paramErr) } + return UCKeyTranslate( + baseAddress.assumingMemoryBound(to: UCKeyboardLayout.self), + keyCode, + UInt16(kUCKeyActionDown), + 0, + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + characters.count, + &actualLength, + &characters + ) + } guard status == noErr, actualLength > 0 else { return nil } return String(utf16CodeUnits: characters, count: actualLength).first } diff --git a/Sources/Core/InputStateMachine.swift b/Sources/Core/InputStateMachine.swift index 9e3fc5e..0706535 100644 --- a/Sources/Core/InputStateMachine.swift +++ b/Sources/Core/InputStateMachine.swift @@ -60,6 +60,12 @@ public struct InputStateMachine { return [.invalidate(.contextChanged)] } + /// Marks the buffer invalid until the next boundary: used when an event was + /// dropped (stale context), so on-screen text and the buffer may disagree. + public mutating func invalidateUntilBoundary() { + invalidate(untilBoundary: true) + } + public mutating func updatePreferences(_ preferences: InputPreferencesSnapshot) -> [InputStateCommand] { let wasEnabled = self.preferences.isEnabled self.preferences = preferences diff --git a/Sources/Core/KeyboardMonitor.swift b/Sources/Core/KeyboardMonitor.swift index 23a115b..efff288 100644 --- a/Sources/Core/KeyboardMonitor.swift +++ b/Sources/Core/KeyboardMonitor.swift @@ -36,6 +36,8 @@ public final class KeyboardMonitor { private let translations = OSAllocatedUnfairLock(initialState: [TranslationKey: String]()) private var diagnosticRing = [EventMetadata?](repeating: nil, count: 256) private var diagnosticRingIndex = 0 + // Tap-callback-thread confined: tracks caps lock toggle state for edge detection. + private var lastAlphaShiftState: Bool? private var tapResetCount: UInt64 = 0 private static let spaceKeyCode: UInt16 = 49 @@ -48,12 +50,13 @@ public final class KeyboardMonitor { private static let functionKeyCodes: Set = Set([ 122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, - 105, 107, 113, 106, + 105, 107, 113, 106, 64, 79, 80, ]) private static let navigationKeyCodes: Set = Set([ 123, 124, 125, 126, 115, 119, 116, 121, + 117, // forward delete: must not reach the character buffer as U+F728 ]) private static let boundaryCharacterSet: CharacterSet = { @@ -315,6 +318,12 @@ public final class KeyboardMonitor { ) else { return nil } + // Caps lock emits flagsChanged on both press and release with the same + // toggled state; fire only when the alpha-shift bit actually flips so a + // single press cannot trigger the revert hotkey twice. + let alphaShiftEngaged = flags.contains(.maskAlphaShift) + guard alphaShiftEngaged != lastAlphaShiftState else { return nil } + lastAlphaShiftState = alphaShiftEngaged return .revertHotkey } @@ -412,27 +421,26 @@ public final class KeyboardMonitor { return nil } let layoutData = unsafeBitCast(layoutDataReference, to: CFData.self) as Data - guard let keyboardLayout = layoutData.withUnsafeBytes({ pointer in - pointer.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self) - }) else { - return nil - } let modifierState: UInt32 = shifted ? UInt32(shiftKey >> 8) : 0 var deadKeyState: UInt32 = 0 var characters = [UniChar](repeating: 0, count: 8) var actualLength = 0 - let status = UCKeyTranslate( - keyboardLayout, - keyCode, - UInt16(kUCKeyActionDown), - modifierState, - UInt32(LMGetKbdType()), - OptionBits(kUCKeyTranslateNoDeadKeysBit), - &deadKeyState, - characters.count, - &actualLength, - &characters - ) + // The layout pointer is only valid inside withUnsafeBytes. + let status = layoutData.withUnsafeBytes { pointer -> OSStatus in + guard let baseAddress = pointer.baseAddress else { return OSStatus(paramErr) } + return UCKeyTranslate( + baseAddress.assumingMemoryBound(to: UCKeyboardLayout.self), + keyCode, + UInt16(kUCKeyActionDown), + modifierState, + UInt32(LMGetKbdType()), + OptionBits(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + characters.count, + &actualLength, + &characters + ) + } guard status == noErr, actualLength > 0 else { return nil } return String(utf16CodeUnits: characters, count: actualLength) } diff --git a/Sources/Core/LayoutDetector.swift b/Sources/Core/LayoutDetector.swift index 5928273..a5f23a9 100644 --- a/Sources/Core/LayoutDetector.swift +++ b/Sources/Core/LayoutDetector.swift @@ -591,7 +591,7 @@ public class LayoutDetector { var hasCyrillic = false for char in text { for scalar in char.unicodeScalars { - if (scalar.value >= 0x0041 && scalar.value <= 0x007A) { + if (scalar.value >= 0x0041 && scalar.value <= 0x005A) || (scalar.value >= 0x0061 && scalar.value <= 0x007A) { hasLatin = true } else if (scalar.value >= 0x0400 && scalar.value <= 0x04FF) { hasCyrillic = true diff --git a/Sources/Core/ScriptAnalyzer.swift b/Sources/Core/ScriptAnalyzer.swift index 4f64cfd..70d8431 100644 --- a/Sources/Core/ScriptAnalyzer.swift +++ b/Sources/Core/ScriptAnalyzer.swift @@ -18,7 +18,7 @@ public struct ScriptAnalyzer { var hasCyrillic = false for scalar in text.unicodeScalars { let value = scalar.value - if (value >= 0x0041 && value <= 0x007A) || (value >= 0x0061 && value <= 0x007A) { // Quick basic latin check (A-Z, a-z) + if (value >= 0x0041 && value <= 0x005A) || (value >= 0x0061 && value <= 0x007A) { // Quick basic latin check (A-Z, a-z) hasLatin = true } else if (value >= 0x0400 && value <= 0x04FF) { hasCyrillic = true diff --git a/Sources/Core/TextCorrector.swift b/Sources/Core/TextCorrector.swift index 508f3af..c93662e 100644 --- a/Sources/Core/TextCorrector.swift +++ b/Sources/Core/TextCorrector.swift @@ -146,7 +146,10 @@ public final class TextCorrector { undoState.withLock { $0 = UndoState(plan: plan) } if let layout = plan.targetLayout, plan.isEligible(using: latestCaptureState()) { - inputSourceManager.switchTo(layout) + // TIS APIs are main-thread-only; apply() runs on the correction queue. + DispatchQueue.main.async { [inputSourceManager] in + inputSourceManager.switchTo(layout) + } } logger.debug("correction applied delete_count=\(plan.deleteCount, privacy: .public)") return true @@ -230,7 +233,11 @@ public final class TextCorrector { post(events, targetPID: inverse.targetPID) undoState.withLock { $0 = nil } if inverse.isEligible(using: latestCaptureState()) { - inputSourceManager.switchTo(undo.plan.originalLayout) + let layout = undo.plan.originalLayout + // TIS APIs are main-thread-only; undo() runs on the correction queue. + DispatchQueue.main.async { [inputSourceManager] in + inputSourceManager.switchTo(layout) + } } return true } @@ -262,7 +269,17 @@ public final class TextCorrector { } let pasteboard = NSPasteboard.general - let previousItems = pasteboard.pasteboardItems + // Snapshot item data into fresh items: items read from a pasteboard are + // invalidated by clearContents() and cannot be written back. + let previousItems: [NSPasteboardItem] = (pasteboard.pasteboardItems ?? []).map { item in + let copy = NSPasteboardItem() + for type in item.types { + if let data = item.data(forType: type) { + copy.setData(data, forType: type) + } + } + return copy + } pasteboard.clearContents() pasteboard.setString(convertedText, forType: .string) let replacementChangeCount = pasteboard.changeCount @@ -280,7 +297,7 @@ public final class TextCorrector { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { guard pasteboard.changeCount == replacementChangeCount else { return } pasteboard.clearContents() - if let previousItems, !previousItems.isEmpty { + if !previousItems.isEmpty { pasteboard.writeObjects(previousItems) } } @@ -309,7 +326,7 @@ public final class TextCorrector { } private func makeCorrectionEvents(plan: CorrectionPlan) -> [CGEvent]? { - guard eventSource != nil else { return nil } + guard eventSource != nil, !plan.replacementText.isEmpty else { return nil } var events: [CGEvent] = [] events.reserveCapacity(plan.deleteCount * 2 + 2) for _ in 0..= requiredBytes { + self.bits = bits + } else { + // Undersized payload would index out of bounds; pad instead of trapping. + self.bits = bits + [UInt8](repeating: 0, count: requiredBytes - bits.count) + } } /// Create a BloomFilter optimized for a given number of items and false positive rate. diff --git a/Sources/Dictionary/DictionaryLoader.swift b/Sources/Dictionary/DictionaryLoader.swift index 0609ca6..2169257 100644 --- a/Sources/Dictionary/DictionaryLoader.swift +++ b/Sources/Dictionary/DictionaryLoader.swift @@ -54,6 +54,9 @@ public class DictionaryLoader { } public func mightContain(_ word: String, language: Language) -> Bool { + // Bloom hashing and the binary index operate on raw UTF-8 bytes; the + // compiled dictionaries store NFC, so normalize before byte-level lookups. + let word = word.precomposedStringWithCanonicalMapping return lock.withLock { guard let index = ensureIndexLoaded(for: language) else { return false } let denyList = denyLists[language] ?? [] @@ -75,6 +78,7 @@ public class DictionaryLoader { } public func containsExact(_ word: String, language: Language) -> Bool { + let word = word.precomposedStringWithCanonicalMapping return lock.withLock { guard let index = ensureIndexLoaded(for: language) else { return false } diff --git a/Sources/Dictionary/WordValidator.swift b/Sources/Dictionary/WordValidator.swift index eeeae7b..ffa377c 100644 --- a/Sources/Dictionary/WordValidator.swift +++ b/Sources/Dictionary/WordValidator.swift @@ -17,6 +17,11 @@ public class WordValidator { "'s", "'re", "'ve", "'ll", "'d", "n't" ] + /// Contractions whose base does not survive suffix stripping ("can't" → "ca"). + private static let irregularContractions: Set = [ + "can't", "won't", "shan't", "ain't", "let's", "y'all", "o'clock", "ma'am" + ] + private static let whitelistedWords: [Language: Set] = [ .english: [ "ccs", "cmd", "opt", "ctrl", "mac", "ios", "api", "url", "app", "dev", "bot", "txt", "csv", "xml", "json", "tas", "task", "tasks" @@ -123,6 +128,9 @@ public class WordValidator { private func isEnglishContractionValid(_ word: String) -> Bool { guard word.contains("'") else { return false } + if WordValidator.irregularContractions.contains(word.lowercased()) { + return true + } for suffix in WordValidator.englishContractionSuffixes where word.hasSuffix(suffix) { let base = String(word.dropLast(suffix.count)) if base.isEmpty { continue } diff --git a/Sources/UI/PreferencesManager.swift b/Sources/UI/PreferencesManager.swift index 90aa491..92b6090 100644 --- a/Sources/UI/PreferencesManager.swift +++ b/Sources/UI/PreferencesManager.swift @@ -70,8 +70,9 @@ public class PreferencesManager { /// Hotkey virtual key code (default: Space = 49) public var hotkeyKeyCode: UInt16 { get { - let val = defaults.integer(forKey: Keys.hotkeyKeyCode) - return val == 0 ? 49 : UInt16(val) + // Key code 0 is the letter "A"; only fall back when the key is truly unset. + guard let val = defaults.object(forKey: Keys.hotkeyKeyCode) as? Int else { return 49 } + return UInt16(truncatingIfNeeded: val) } set { guard newValue != self.hotkeyKeyCode else { return } @@ -97,8 +98,9 @@ public class PreferencesManager { /// Revert-hotkey virtual key code (default: CapsLock = 57) public var revertHotkeyKeyCode: UInt16 { get { - let val = defaults.integer(forKey: Keys.revertHotkeyKeyCode) - return val == 0 ? 57 : UInt16(val) + // Key code 0 is the letter "A"; only fall back when the key is truly unset. + guard let val = defaults.object(forKey: Keys.revertHotkeyKeyCode) as? Int else { return 57 } + return UInt16(truncatingIfNeeded: val) } set { guard newValue != self.revertHotkeyKeyCode else { return } diff --git a/Sources/UI/SettingsView.swift b/Sources/UI/SettingsView.swift index 82134a5..c6eee58 100644 --- a/Sources/UI/SettingsView.swift +++ b/Sources/UI/SettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import Carbon +import UniformTypeIdentifiers import Utils // Helpers @@ -79,21 +80,29 @@ class SettingsViewModel: ObservableObject { class RecorderState: ObservableObject { @Published var isRecording = false private var monitor: Any? - + + deinit { + stop() + } + func start(completion: @escaping (UInt16, UInt64) -> Void) { stop() isRecording = true - + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in guard let self = self else { return event } - + // Handle CapsLock specifically if event.type == .flagsChanged && event.keyCode == 57 { completion(57, 0) self.stop() return nil } - + // Pass through other modifier transitions so app-wide modifier state stays intact. + if event.type == .flagsChanged { + return event + } + if event.type == .keyDown { if event.keyCode == 53 { // ESC self.stop() @@ -164,6 +173,203 @@ struct HotkeyRecorder: View { } } +struct ExcludedAppRow: Identifiable, Hashable { + let id: String // bundle identifier + let name: String + let icon: NSImage? +} + +class ExclusionsViewModel: ObservableObject { + @Published var apps: [ExcludedAppRow] = [] + @Published var selection: Set = [] + @Published var runningApps: [ExcludedAppRow] = [] + @Published var showingRunningAppsPicker = false + + init() { + reload() + NotificationCenter.default.addObserver(self, selector: #selector(reload), name: .appFilterDidChange, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc func reload() { + apps = AppFilter.shared.allBlacklisted.map { bundleID -> ExcludedAppRow in + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) + let name = url.map { FileManager.default.displayName(atPath: $0.path) } ?? bundleID + let icon = url.map { NSWorkspace.shared.icon(forFile: $0.path) } + return ExcludedAppRow(id: bundleID, name: name, icon: icon) + }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + // Drop selections that no longer exist (e.g. removed via the menu-bar toggle). + selection.formIntersection(apps.map { $0.id }) + } + + /// Refreshes the list of currently running apps eligible to be added (excludes ones already excluded and SwitchFix itself). + func refreshRunningApps() { + let alreadyExcluded = Set(apps.map { $0.id }) + let ownBundleID = Bundle.main.bundleIdentifier + + runningApps = NSWorkspace.shared.runningApplications + .filter { $0.activationPolicy == .regular } + .compactMap { app -> ExcludedAppRow? in + guard let bundleID = app.bundleIdentifier, + bundleID != ownBundleID, + !alreadyExcluded.contains(bundleID) else { return nil } + return ExcludedAppRow(id: bundleID, name: app.localizedName ?? bundleID, icon: app.icon) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + func addBundleIDs(_ bundleIDs: S) where S.Element == String { + for bundleID in bundleIDs { + AppFilter.shared.addToBlacklist(bundleID) + } + reload() + } + + /// Presents an Open panel (defaulting to /Applications, but browsable anywhere) to pick app bundles. + func addAppFromFileSystem() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + + // Non-blocking: runModal() would spin a modal session on the main run loop + // and steal frontmost-app focus from the capture pipeline. + panel.begin { [weak self] response in + guard response == .OK, let self else { return } + self.addBundleIDs(panel.urls.compactMap { Bundle(url: $0)?.bundleIdentifier }) + } + } + + func removeSelected() { + for bundleID in selection { + AppFilter.shared.removeFromBlacklist(bundleID) + } + selection.removeAll() + reload() + } +} + +struct RunningAppPickerView: View { + @ObservedObject var model: ExclusionsViewModel + @Environment(\.dismiss) private var dismiss + @State private var selection: Set = [] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Choose Running Apps").font(.headline) + + if model.runningApps.isEmpty { + Text("All running apps are already excluded.") + .font(.callout) + .foregroundColor(.secondary) + .frame(width: 360, height: 260, alignment: .center) + } else { + List(model.runningApps, selection: $selection) { app in + HStack(spacing: 6) { + if let icon = app.icon { + Image(nsImage: icon) + .resizable() + .frame(width: 16, height: 16) + } + Text(app.name) + } + .tag(app.id) + } + .frame(width: 360, height: 260) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + Button("Add") { + model.addBundleIDs(selection) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(selection.isEmpty) + } + } + .padding(20) + } +} + +struct ExcludedAppsView: View { + @StateObject private var model = ExclusionsViewModel() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Excluded Apps").font(.headline) + Text("SwitchFix won't correct text while these apps are active.") + .font(.caption) + .foregroundColor(.secondary) + + VStack(spacing: 0) { + List(model.apps, selection: $model.selection) { app in + HStack(spacing: 6) { + if let icon = app.icon { + Image(nsImage: icon) + .resizable() + .frame(width: 16, height: 16) + } + Text(app.name) + Spacer() + Text(app.id) + .font(.caption2) + .foregroundColor(.secondary) + } + .tag(app.id) + } + .frame(height: 140) + + Divider() + + HStack(spacing: 0) { + Menu { + Button("Choose from Running Apps…") { + model.refreshRunningApps() + model.showingRunningAppsPicker = true + } + Button("Choose from Applications Folder…") { + model.addAppFromFileSystem() + } + } label: { + Image(systemName: "plus") + .frame(width: 20, height: 20) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + + Divider().frame(height: 12) + + Button(action: model.removeSelected) { + Image(systemName: "minus") + .frame(width: 20, height: 20) + } + .buttonStyle(.borderless) + .disabled(model.selection.isEmpty) + + Spacer() + } + .padding(4) + .background(Color(nsColor: .controlBackgroundColor)) + } + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .sheet(isPresented: $model.showingRunningAppsPicker) { + RunningAppPickerView(model: model) + } + } +} + struct SettingsView: View { @StateObject private var model = SettingsViewModel() @@ -234,10 +440,15 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) } - + + Divider() + + // EXCLUDED APPS + ExcludedAppsView() + Spacer() } .padding(30) - .frame(width: 480, height: 500) + .frame(width: 480, height: 700) } } diff --git a/Sources/UI/SettingsWindowController.swift b/Sources/UI/SettingsWindowController.swift index 5b2e9d1..a772d5f 100644 --- a/Sources/UI/SettingsWindowController.swift +++ b/Sources/UI/SettingsWindowController.swift @@ -17,7 +17,7 @@ public class SettingsWindowController: NSObject { let hostingController = NSHostingController(rootView: settingsView) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 450, height: 350), + contentRect: NSRect(x: 0, y: 0, width: 480, height: 700), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false @@ -44,6 +44,9 @@ public class SettingsWindowController: NSObject { } @objc private func windowWillClose(_ notification: Notification) { + if let window = notification.object as? NSWindow { + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: window) + } windowController = nil } } diff --git a/Sources/UI/StatusBarController.swift b/Sources/UI/StatusBarController.swift index 5717ea7..0c37dfd 100644 --- a/Sources/UI/StatusBarController.swift +++ b/Sources/UI/StatusBarController.swift @@ -17,6 +17,9 @@ public class StatusBarController: NSObject, NSMenuDelegate { public override init() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) menu = NSMenu() + // Explicit isEnabled writes (e.g. "App Filtering Unavailable") only take + // effect when AppKit's auto-enablement is off. + menu.autoenablesItems = false super.init() @@ -102,8 +105,6 @@ public class StatusBarController: NSObject, NSMenuDelegate { menu.addItem(NSMenuItem.separator()) - menu.addItem(NSMenuItem.separator()) - // Settings let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",") settingsItem.target = self diff --git a/Sources/Utils/AppFilter.swift b/Sources/Utils/AppFilter.swift index bf261f0..0688fcb 100644 --- a/Sources/Utils/AppFilter.swift +++ b/Sources/Utils/AppFilter.swift @@ -1,11 +1,14 @@ import Foundation import AppKit +import os public class AppFilter { public static let shared = AppFilter() private let defaults = UserDefaults.standard - private static let blacklistKey = "SwitchFix_blacklistedApps" + private static let legacyBlacklistKey = "SwitchFix_blacklistedApps" + private static let userAddedKey = "SwitchFix_userAddedApps" + private static let userRemovedKey = "SwitchFix_userRemovedApps" /// Default blacklisted bundle IDs — apps where correction should be disabled. private static let defaultBlacklist: Set = [ @@ -30,14 +33,35 @@ public class AppFilter { "com.jetbrains.fleet", ] - private var blacklistedBundleIDs: Set + private struct State { + var userAdded: Set + var userRemoved: Set + + var effective: Set { + AppFilter.defaultBlacklist.union(userAdded).subtracting(userRemoved) + } + } + + // Persisting user deltas (instead of the full effective set) lets default + // blacklist additions in future releases apply to existing users. + private let state: OSAllocatedUnfairLock private init() { - if let saved = defaults.stringArray(forKey: AppFilter.blacklistKey) { - blacklistedBundleIDs = Set(saved) - } else { - blacklistedBundleIDs = AppFilter.defaultBlacklist + var userAdded = Set(defaults.stringArray(forKey: AppFilter.userAddedKey) ?? []) + var userRemoved = Set(defaults.stringArray(forKey: AppFilter.userRemovedKey) ?? []) + + // Migrate the legacy full-set format: reconstruct deltas against defaults. + if defaults.object(forKey: AppFilter.userAddedKey) == nil, + defaults.object(forKey: AppFilter.userRemovedKey) == nil, + let legacySaved = defaults.stringArray(forKey: AppFilter.legacyBlacklistKey) { + let saved = Set(legacySaved) + userAdded = saved.subtracting(AppFilter.defaultBlacklist) + userRemoved = AppFilter.defaultBlacklist.subtracting(saved) + defaults.set(Array(userAdded), forKey: AppFilter.userAddedKey) + defaults.set(Array(userRemoved), forKey: AppFilter.userRemovedKey) } + + state = OSAllocatedUnfairLock(initialState: State(userAdded: userAdded, userRemoved: userRemoved)) } /// Check if correction is allowed for the currently frontmost application. @@ -46,31 +70,44 @@ public class AppFilter { let bundleID = app.bundleIdentifier else { return true } - return !blacklistedBundleIDs.contains(bundleID) + return !isBlacklisted(bundleID) } public func addToBlacklist(_ bundleID: String) { - blacklistedBundleIDs.insert(bundleID) - save() + state.withLock { value in + value.userRemoved.remove(bundleID) + if !AppFilter.defaultBlacklist.contains(bundleID) { + value.userAdded.insert(bundleID) + } + save(value) + } NotificationCenter.default.post(name: .appFilterDidChange, object: nil) } public func removeFromBlacklist(_ bundleID: String) { - blacklistedBundleIDs.remove(bundleID) - save() + state.withLock { value in + value.userAdded.remove(bundleID) + if AppFilter.defaultBlacklist.contains(bundleID) { + value.userRemoved.insert(bundleID) + } + save(value) + } NotificationCenter.default.post(name: .appFilterDidChange, object: nil) } public func isBlacklisted(_ bundleID: String) -> Bool { - return blacklistedBundleIDs.contains(bundleID) + state.withLock { $0.effective.contains(bundleID) } } public var allBlacklisted: [String] { - return Array(blacklistedBundleIDs).sorted() + state.withLock { Array($0.effective).sorted() } } - private func save() { - defaults.set(Array(blacklistedBundleIDs), forKey: AppFilter.blacklistKey) + private func save(_ value: State) { + defaults.set(Array(value.userAdded), forKey: AppFilter.userAddedKey) + defaults.set(Array(value.userRemoved), forKey: AppFilter.userRemovedKey) + // Keep the legacy key in sync so downgrades keep the user's list. + defaults.set(Array(value.effective), forKey: AppFilter.legacyBlacklistKey) } } diff --git a/Sources/Utils/KeyCodeMapping.swift b/Sources/Utils/KeyCodeMapping.swift index 92e0ccd..cfe5960 100644 --- a/Sources/Utils/KeyCodeMapping.swift +++ b/Sources/Utils/KeyCodeMapping.swift @@ -11,27 +11,27 @@ public class KeyCodeMapping { } let layoutData = unsafeBitCast(layoutDataRef, to: CFData.self) as Data - let keyboardLayout = layoutData.withUnsafeBytes { ptr in - ptr.baseAddress!.assumingMemoryBound(to: UCKeyboardLayout.self) - } - var deadKeyState: UInt32 = 0 let modifierKeyState: UInt32 = shift ? (UInt32(shiftKey >> 8) & 0xFF) : 0 var chars = [UniChar](repeating: 0, count: 4) var actualLength = 0 - let status = UCKeyTranslate( - keyboardLayout, - keyCode, - UInt16(kUCKeyActionDown), - modifierKeyState, - UInt32(LMGetKbdType()), - UInt32(kUCKeyTranslateNoDeadKeysBit), - &deadKeyState, - chars.count, - &actualLength, - &chars - ) + // The layout pointer is only valid inside withUnsafeBytes. + let status = layoutData.withUnsafeBytes { ptr -> OSStatus in + guard let baseAddress = ptr.baseAddress else { return OSStatus(paramErr) } + return UCKeyTranslate( + baseAddress.assumingMemoryBound(to: UCKeyboardLayout.self), + keyCode, + UInt16(kUCKeyActionDown), + modifierKeyState, + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + chars.count, + &actualLength, + &chars + ) + } guard status == noErr, actualLength > 0 else { return nil } return String(utf16CodeUnits: chars, count: actualLength) diff --git a/scripts/compile_dictionary.swift b/scripts/compile_dictionary.swift index 4dd3466..bf560f2 100755 --- a/scripts/compile_dictionary.swift +++ b/scripts/compile_dictionary.swift @@ -137,6 +137,9 @@ func loadWords(from input: URL) throws -> [String] { .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: "’", with: "'") + // NFC: bloom hashing and partition lookup operate on raw UTF-8 bytes, + // so stored and queried forms must agree byte-for-byte. + .precomposedStringWithCanonicalMapping if !normalized.isEmpty { words.insert(normalized) }