Skip to content
Open
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
26 changes: 26 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

31 changes: 27 additions & 4 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,6 @@ final class CotabbyAppEnvironment {
permissionProvider: { permissionManager.inputMonitoringGranted },
suppressionController: suppressionController
)
inputMonitor.acceptanceKeyCodeProvider = { suggestionSettings.acceptanceKeyCode }
inputMonitor.acceptanceKeyModifiersProvider = { suggestionSettings.acceptanceKeyModifiers }
inputMonitor.fullAcceptanceKeyCodeProvider = { suggestionSettings.fullAcceptanceKeyCode }
inputMonitor.fullAcceptanceKeyModifiersProvider = { suggestionSettings.fullAcceptanceKeyModifiers }
inputMonitor.globalToggleKeyCodeProvider = { suggestionSettings.globalToggleKeyCode }
inputMonitor.globalToggleKeyModifiersProvider = { suggestionSettings.globalToggleKeyModifiers }
inputMonitor.onGlobalToggleHotkey = { [weak suggestionSettings] in
Expand Down Expand Up @@ -103,6 +99,33 @@ final class CotabbyAppEnvironment {
}
return true
}
// Accept-key resolution runs at event time through `ShortcutResolver` so a per-app override
// (keyed by the live frontmost bundle id) wins over the global binding, falling back to the
// global when no override matches. Installed here — after `focusModel` exists — because the
// closures capture it weakly to read the fresh snapshot per keystroke. The poll-based
// snapshot can briefly lag a fast app switch, the same race the evaluator above already has.
// Each binding resolves once, as a unit, so the key code and its modifiers always come from
// the same per-app/global resolution and the overrides are scanned at most once per binding.
inputMonitor.acceptanceBindingProvider = { [weak focusModel] in
let binding = ShortcutResolver.acceptBinding(
frontmostBundleIdentifier: focusModel?.snapshot.bundleIdentifier,
overrides: suggestionSettings.perAppShortcutOverrides,
globalKeyCode: suggestionSettings.acceptanceKeyCode,
globalModifiers: suggestionSettings.acceptanceKeyModifiers,
globalLabel: suggestionSettings.acceptanceKeyLabel
)
return (binding.keyCode, binding.modifiers)
}
inputMonitor.fullAcceptanceBindingProvider = { [weak focusModel] in
let binding = ShortcutResolver.fullAcceptBinding(
frontmostBundleIdentifier: focusModel?.snapshot.bundleIdentifier,
overrides: suggestionSettings.perAppShortcutOverrides,
globalKeyCode: suggestionSettings.fullAcceptanceKeyCode,
globalModifiers: suggestionSettings.fullAcceptanceKeyModifiers,
globalLabel: suggestionSettings.fullAcceptanceKeyLabel
)
return (binding.keyCode, binding.modifiers)
}
let appUpdateManager = AppUpdateManager()
let welcomeCoordinator = WelcomeCoordinator(
permissionManager: permissionManager,
Expand Down
15 changes: 14 additions & 1 deletion Cotabby/Models/InputModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Foundation
/// We don't reuse `CGEventFlags` directly because it carries unrelated bits — caps lock,
/// numeric pad, secondary fn, device-specific flags — that we don't want to participate in
/// shortcut equality. Reducing to a 4-bit mask gives unambiguous storage and comparison.
struct ShortcutModifierMask: OptionSet, Hashable {
struct ShortcutModifierMask: OptionSet, Hashable, Sendable, Codable {
let rawValue: UInt32

init(rawValue: UInt32) {
Expand All @@ -35,6 +35,19 @@ struct ShortcutModifierMask: OptionSet, Hashable {
if eventFlags.contains(.maskControl) { mask.insert(.control) }
self = mask
}

// Single-value coding lets persisted per-app overrides store the modifier set as a plain
// integer in JSON — same on-disk shape the standalone `acceptanceKeyModifiers` UserDefault
// uses, so the two are debuggable side by side and migrate cleanly if we ever consolidate.
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(UInt32.self)
self.init(rawValue: raw)
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
}

struct CapturedInputEvent: Equatable {
Expand Down
61 changes: 61 additions & 0 deletions Cotabby/Models/PerAppShortcutOverride.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import CoreGraphics
import Foundation

/// File overview:
/// One application's per-app shortcut customization. Each field is **optional** so the
/// "no custom shortcut → fall back to the global binding" state is first-class instead of a
/// sentinel value: if `acceptKeyCode == nil`, the accept binding for this app is inherited
/// from `SuggestionSettingsModel.acceptanceKeyCode`.
///
/// The bundle identifier is the durable identity used by the suggestion pipeline and the input
/// monitor's event-time provider closures. The display name is saved alongside so Settings can
/// render a readable list without having to resolve installed applications on every launch.
///
/// `ShortcutResolver` is the only place that should consume these overrides; rely on it so the
/// precedence rule (per-app → global) stays in a single, testable spot.
struct PerAppShortcutOverride: Codable, Equatable, Identifiable, Sendable {
let bundleIdentifier: String
var displayName: String
/// `nil` for any field means "inherit the global binding for that action". The three accept
/// fields move as a unit — UI and resolver both treat (keyCode, modifiers, label) as one
/// binding — so cleared overrides set all three to nil.
var acceptKeyCode: CGKeyCode?
var acceptKeyModifiers: ShortcutModifierMask?
var acceptKeyLabel: String?
var fullAcceptKeyCode: CGKeyCode?
var fullAcceptKeyModifiers: ShortcutModifierMask?
var fullAcceptKeyLabel: String?

var id: String { bundleIdentifier }

/// True when there is no override left to persist — the row should be removed from the
/// settings store instead of sitting around as a no-op alongside the global bindings.
var isEmpty: Bool {
acceptKeyCode == nil && fullAcceptKeyCode == nil
}

/// A copy with any *partially*-specified binding collapsed back to "inherit global". Each binding
/// is a unit of (keyCode, modifiers, label) and `ShortcutResolver` only fires when all three are
/// present, so a row persisted with — say — a key code but no label would otherwise survive
/// `isEmpty`, appear in Settings, yet never fire at event time (a phantom override). Normalizing
/// on load keeps the stored shape matching exactly what the resolver honors.
var bindingsNormalized: PerAppShortcutOverride {
var normalized = self
if acceptKeyCode == nil || acceptKeyModifiers == nil || acceptKeyLabel == nil {
normalized.acceptKeyCode = nil
normalized.acceptKeyModifiers = nil
normalized.acceptKeyLabel = nil
}
if fullAcceptKeyCode == nil || fullAcceptKeyModifiers == nil || fullAcceptKeyLabel == nil {
normalized.fullAcceptKeyCode = nil
normalized.fullAcceptKeyModifiers = nil
normalized.fullAcceptKeyLabel = nil
}
return normalized
}

/// Whether this row pins an accept key (i.e. the user explicitly chose one, including the
/// "no key" sentinel for "this app should never accept word-by-word").
var hasAcceptOverride: Bool { acceptKeyCode != nil }
var hasFullAcceptOverride: Bool { fullAcceptKeyCode != nil }
}
3 changes: 3 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ struct SuggestionSettingsData: Equatable {
var globalToggleKeyCode: CGKeyCode
var globalToggleKeyModifiers: ShortcutModifierMask
var globalToggleKeyLabel: String
/// Per-app accept/full-accept overrides keyed by bundle id. Empty by default; a row is present
/// only for apps the user explicitly customized. `ShortcutResolver` consumes this at event time.
var perAppShortcutOverrides: [PerAppShortcutOverride]
var acceptanceGranularity: AcceptanceGranularity
var isPowerBasedModelSwitchingEnabled: Bool
var batteryEngine: SuggestionEngineKind
Expand Down
176 changes: 176 additions & 0 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ final class SuggestionSettingsModel: ObservableObject {
@Published private(set) var globalToggleKeyCode: CGKeyCode
@Published private(set) var globalToggleKeyModifiers: ShortcutModifierMask
@Published private(set) var globalToggleKeyLabel: String
/// Per-app accept/full-accept overrides. Published so the input monitor's event-time provider
/// closures (via `ShortcutResolver`) and the Apps settings pane both observe the live list.
@Published private(set) var perAppShortcutOverrides: [PerAppShortcutOverride]
@Published private(set) var acceptanceGranularity: AcceptanceGranularity
@Published private(set) var isPowerBasedModelSwitchingEnabled: Bool
@Published private(set) var batteryEngine: SuggestionEngineKind
Expand Down Expand Up @@ -206,6 +209,8 @@ final class SuggestionSettingsModel: ObservableObject {
autoAcceptTrailingPunctuation = data.autoAcceptTrailingPunctuation
addSpaceAfterAccept = data.addSpaceAfterAccept
streamSuggestionsWhileGenerating = data.streamSuggestionsWhileGenerating
fadeInSuggestions = data.fadeInSuggestions
fadeInDurationSeconds = data.fadeInDurationSeconds
acceptanceKeyCode = data.acceptanceKeyCode
acceptanceKeyModifiers = data.acceptanceKeyModifiers
acceptanceKeyLabel = data.acceptanceKeyLabel
Expand All @@ -215,6 +220,7 @@ final class SuggestionSettingsModel: ObservableObject {
globalToggleKeyCode = data.globalToggleKeyCode
globalToggleKeyModifiers = data.globalToggleKeyModifiers
globalToggleKeyLabel = data.globalToggleKeyLabel
perAppShortcutOverrides = data.perAppShortcutOverrides
acceptanceGranularity = data.acceptanceGranularity
isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled
batteryEngine = data.batteryEngine
Expand Down Expand Up @@ -282,6 +288,7 @@ final class SuggestionSettingsModel: ObservableObject {
globalToggleKeyCode = data.globalToggleKeyCode
globalToggleKeyModifiers = data.globalToggleKeyModifiers
globalToggleKeyLabel = data.globalToggleKeyLabel
perAppShortcutOverrides = data.perAppShortcutOverrides
acceptanceGranularity = data.acceptanceGranularity
isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled
batteryEngine = data.batteryEngine
Expand Down Expand Up @@ -973,6 +980,130 @@ final class SuggestionSettingsModel: ObservableObject {
setGlobalToggleKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel)
}

// MARK: - Per-app shortcut overrides

/// Fast lookup used by `ShortcutResolver` at event time. The array is small (one row per app
/// the user customized) so a linear scan is fine; we avoid materializing a dictionary on every
/// access because the published array is replaced on every mutation.
func perAppShortcutOverride(forBundleIdentifier bundleIdentifier: String?) -> PerAppShortcutOverride? {
guard let normalized = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else {
return nil
}
return perAppShortcutOverrides.first { $0.bundleIdentifier == normalized }
}

/// Replaces (or inserts) the accept-word binding for one app. Pass the disabled sentinel
/// `(SuggestionSettingsModel.disabledKeyCode, [], "None")` to bind "no key accepts in this app";
/// pass anything else for a real combo. To restore the global fallback, call
/// `clearPerAppAcceptKey` instead — that nils out the override so the resolver re-inherits.
func setPerAppAcceptKey(
bundleIdentifier: String,
displayName: String,
keyCode: CGKeyCode,
modifiers: ShortcutModifierMask,
label: String
) {
guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else {
return
}
let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers
let normalizedDisplayName = SuggestionSettingsStore.normalizedDisplayName(
displayName,
fallbackBundleIdentifier: normalizedBundleIdentifier
)

var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier)
?? PerAppShortcutOverride(bundleIdentifier: normalizedBundleIdentifier, displayName: normalizedDisplayName)
override.displayName = normalizedDisplayName
override.acceptKeyCode = keyCode
override.acceptKeyModifiers = normalizedModifiers
override.acceptKeyLabel = label

upsertPerAppOverride(override)
}

/// Clears just the accept-word override for one app. If the row also has no full-accept
/// override left, the row itself is removed so the resolver re-inherits the global pair.
func clearPerAppAcceptKey(bundleIdentifier: String) {
guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier),
var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier) else {
return
}
override.acceptKeyCode = nil
override.acceptKeyModifiers = nil
override.acceptKeyLabel = nil
upsertPerAppOverride(override)
}

func setPerAppFullAcceptKey(
bundleIdentifier: String,
displayName: String,
keyCode: CGKeyCode,
modifiers: ShortcutModifierMask,
label: String
) {
guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else {
return
}
let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers
let normalizedDisplayName = SuggestionSettingsStore.normalizedDisplayName(
displayName,
fallbackBundleIdentifier: normalizedBundleIdentifier
)

var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier)
?? PerAppShortcutOverride(bundleIdentifier: normalizedBundleIdentifier, displayName: normalizedDisplayName)
override.displayName = normalizedDisplayName
override.fullAcceptKeyCode = keyCode
override.fullAcceptKeyModifiers = normalizedModifiers
override.fullAcceptKeyLabel = label

upsertPerAppOverride(override)
}

func clearPerAppFullAcceptKey(bundleIdentifier: String) {
guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier),
var override = existingPerAppOverride(bundleIdentifier: normalizedBundleIdentifier) else {
return
}
override.fullAcceptKeyCode = nil
override.fullAcceptKeyModifiers = nil
override.fullAcceptKeyLabel = nil
upsertPerAppOverride(override)
}

/// Drops the row for `bundleIdentifier` entirely — both accept fields revert to the global
/// binding. UI exposes this as "Reset to global" so the fallback path is a first-class action.
func removePerAppOverride(bundleIdentifier: String) {
guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else {
return
}
let updated = perAppShortcutOverrides.filter { $0.bundleIdentifier != normalizedBundleIdentifier }
guard perAppShortcutOverrides != updated else { return }
perAppShortcutOverrides = updated
store.savePerAppShortcutOverrides(updated)
}

private func existingPerAppOverride(bundleIdentifier: String) -> PerAppShortcutOverride? {
perAppShortcutOverrides.first { $0.bundleIdentifier == bundleIdentifier }
}

/// Upserts `override` into the sorted, deduped list. An override that has nilled out both
/// accept fields is removed from the store so the resolver naturally falls back to global —
/// the array never holds rows that decode to a no-op.
private func upsertPerAppOverride(_ override: PerAppShortcutOverride) {
var byBundle = Dictionary(uniqueKeysWithValues: perAppShortcutOverrides.map { ($0.bundleIdentifier, $0) })
if override.isEmpty {
byBundle.removeValue(forKey: override.bundleIdentifier)
} else {
byBundle[override.bundleIdentifier] = override
}
let updated = SuggestionSettingsStore.sortedPerAppShortcutOverrides(Array(byBundle.values))
guard perAppShortcutOverrides != updated else { return }
perAppShortcutOverrides = updated
store.savePerAppShortcutOverrides(updated)
}

// All stored state is thread-safe to release (Combine subjects, the value-typed store). The
// nonisolated deinit prevents Swift from scheduling the teardown through the
// back-deployment main-actor executor shim, which has a StopLookupScope bug on macOS 26.
Expand Down Expand Up @@ -1009,6 +1140,51 @@ final class SuggestionSettingsModel: ObservableObject {
return nil
}

/// Per-app conflict scoping. A per-app override is only checked against the **same app's**
/// other binding (accept-word vs accept-entire) and against the **global** toggle key, never
/// against unrelated apps. Two different apps may legitimately bind the same combo: the
/// resolver picks the right one at event time based on the frontmost bundle id, so there is no
/// ambiguity at the tap layer.
///
/// `excluding` is the action the user is currently re-recording in *this* app, so we never
/// flag an in-place edit as colliding with its own existing binding.
func conflictingPerAppShortcutName(
forBundleIdentifier bundleIdentifier: String,
keyCode: CGKeyCode,
modifiers: ShortcutModifierMask,
excluding action: ShortcutAction
) -> String? {
guard keyCode != Self.disabledKeyCode else { return nil }

// Same-app check: only consult the other accept binding on the same row. The full set of
// ShortcutAction cases includes the global toggle, which intentionally falls through to
// the global-only check below.
if let override = perAppShortcutOverride(forBundleIdentifier: bundleIdentifier) {
if action != .acceptWord,
let overrideKey = override.acceptKeyCode,
let overrideModifiers = override.acceptKeyModifiers,
overrideKey == keyCode,
overrideModifiers == modifiers {
return ShortcutAction.acceptWord.displayName
}
if action != .acceptEntireSuggestion,
let overrideKey = override.fullAcceptKeyCode,
let overrideModifiers = override.fullAcceptKeyModifiers,
overrideKey == keyCode,
overrideModifiers == modifiers {
return ShortcutAction.acceptEntireSuggestion.displayName
}
}

// The global toggle is an app-spanning binding — a per-app accept key that collides with
// it would still get eaten by the toggle tap, so we refuse the combo even though it isn't
// in `ShortcutAction` for per-app rows.
if globalToggleKeyCode == keyCode, globalToggleKeyModifiers == modifiers {
return ShortcutAction.toggleTabby.displayName
}
return nil
}

private func shortcutBinding(for action: ShortcutAction) -> (keyCode: CGKeyCode, modifiers: ShortcutModifierMask) {
switch action {
case .acceptWord:
Expand Down
Loading
Loading