Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .codex/environments/environment.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "SiriRemoteForge"

[setup]
script = ""

[[actions]]
name = "Run"
icon = "run"
command = "./script/build_and_run.sh"
7 changes: 7 additions & 0 deletions SiriRemoteCore/Sources/SiriRemoteCore/Action.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Foundation

public enum Action: Equatable {
/// Explicitly consume an input without doing anything. This is different from removing a
/// binding: removing falls through to an inherited/native action, while disabled blocks it.
case disabled
case keystroke(keys: String)
// Push-to-talk: fire `keys` on the button's PRESS edge AND again on its RELEASE edge,
// immediately, bypassing tap/double/hold/taphold discrimination and auto-repeat entirely
Expand Down Expand Up @@ -42,6 +45,7 @@ public extension Action {
/// friendly names, `shell`/`applescript`/`launch` are summarised.
var displayLabel: String {
switch self {
case .disabled: return "Disabled"
case .keystroke(let keys): return ActionLabel.keystroke(keys)
case .pushToTalk(let keys): return ActionLabel.keystroke(keys) + " ⇅"
case .media(let key): return ActionLabel.media(key)
Expand Down Expand Up @@ -173,6 +177,7 @@ extension Action: Decodable {
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: K.self)
switch try c.decode(String.self, forKey: .action) {
case "disabled": self = .disabled
case "keystroke": self = .keystroke(keys: try c.decode(String.self, forKey: .keys))
case "pushToTalk": self = .pushToTalk(keys: try c.decode(String.self, forKey: .keys))
case "media": self = .media(key: try c.decode(String.self, forKey: .key))
Expand Down Expand Up @@ -205,6 +210,8 @@ extension Action: Encodable {
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: K.self)
switch self {
case .disabled:
try c.encode("disabled", forKey: .action)
case .keystroke(let keys):
try c.encode("keystroke", forKey: .action)
try c.encode(keys, forKey: .keys)
Expand Down
39 changes: 37 additions & 2 deletions SiriRemoteCore/Sources/SiriRemoteCore/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ public struct Config: Equatable {
public var swipeVelocity: Double
public var cursorSpeed: Double
public var cursorDeadzone: Double
/// When true, one-finger movement scrolls instead of moving the pointer.
public var touchMovesScroll: Bool
/// Linear one/two-finger scroll scale in pixels per normalized movement unit.
public var touchScrollSpeed: Double
/// Add velocity gain to linear touch scrolling. Off preserves the old 1:1 response.
public var touchScrollAcceleration: Bool
/// A process-level reservation that toggles one-finger movement between pointer and scroll.
/// The user's binding for this physical button stage remains untouched underneath it.
public var touchModeSwitchEnabled: Bool
public var touchModeSwitchButton: String?
public var touchModeSwitchTrigger: String
public var circularScroll: CircularScrollConfig
// Multi-stage long-press thresholds (seconds). Stage 1 = holdThreshold (`<key>.hold`),
// stage 2 = holdThreshold2 (`<key>.hold2`), stage 3 = holdThreshold3 (`<key>.hold3`).
Expand Down Expand Up @@ -42,6 +53,8 @@ public struct Config: Equatable {
public var spacesModeWindow: Double
// Find-my-cursor: show a highlight when the cursor is shaken (rapid back-and-forth).
public var findCursorEnabled: Bool
/// 0...1: higher accepts slower movement and a longer back-and-forth shake.
public var findCursorSensitivity: Double
/// Focus the app under the cursor, but ONLY when its window is fullscreen — a fullscreen
/// window owns its whole Space, so focusing it raises nothing and disturbs no window
/// stack. Off by default: it changes which app receives input.
Expand Down Expand Up @@ -216,13 +229,16 @@ extension Config: Decodable {

extension Config.Settings: Decodable {
private enum K: String, CodingKey {
case defaultMode, swipeVelocity, cursorSpeed, cursorDeadzone, circularScroll, holdThreshold
case defaultMode, swipeVelocity, cursorSpeed, cursorDeadzone
case touchMovesScroll, touchScrollSpeed, touchScrollAcceleration
case touchModeSwitchEnabled, touchModeSwitchButton, touchModeSwitchTrigger
case circularScroll, holdThreshold
case holdThreshold2, holdThreshold3, holdCancelGrace, appWheel
case clickRiseThreshold, pressMoveMax
case accelMin, accelMax, accelLowSpeed, accelHighSpeed
case doubleTapWindow
case spacesModeWindow
case findCursorEnabled
case findCursorEnabled, findCursorSensitivity
case focusFollowsCursor
}
public init(from decoder: Decoder) throws {
Expand All @@ -231,6 +247,16 @@ extension Config.Settings: Decodable {
swipeVelocity = try c.decodeIfPresent(Double.self, forKey: .swipeVelocity) ?? 0.5
cursorSpeed = try c.decodeIfPresent(Double.self, forKey: .cursorSpeed) ?? 0.6
cursorDeadzone = try c.decodeIfPresent(Double.self, forKey: .cursorDeadzone) ?? 0.006
touchMovesScroll = try c.decodeIfPresent(Bool.self, forKey: .touchMovesScroll) ?? false
touchScrollSpeed = try c.decodeIfPresent(Double.self, forKey: .touchScrollSpeed) ?? 300
touchScrollAcceleration =
try c.decodeIfPresent(Bool.self, forKey: .touchScrollAcceleration) ?? false
touchModeSwitchEnabled =
try c.decodeIfPresent(Bool.self, forKey: .touchModeSwitchEnabled) ?? false
touchModeSwitchButton =
try c.decodeIfPresent(String.self, forKey: .touchModeSwitchButton)
touchModeSwitchTrigger =
try c.decodeIfPresent(String.self, forKey: .touchModeSwitchTrigger) ?? "tap"
circularScroll = try c.decodeIfPresent(CircularScrollConfig.self, forKey: .circularScroll)
?? .default
holdThreshold = try c.decodeIfPresent(Double.self, forKey: .holdThreshold) ?? 0.5
Expand All @@ -247,6 +273,8 @@ extension Config.Settings: Decodable {
doubleTapWindow = try c.decodeIfPresent(Double.self, forKey: .doubleTapWindow) ?? 0.3
spacesModeWindow = try c.decodeIfPresent(Double.self, forKey: .spacesModeWindow) ?? 5.0
findCursorEnabled = try c.decodeIfPresent(Bool.self, forKey: .findCursorEnabled) ?? true
findCursorSensitivity =
try c.decodeIfPresent(Double.self, forKey: .findCursorSensitivity) ?? 0.5
focusFollowsCursor = try c.decodeIfPresent(Bool.self, forKey: .focusFollowsCursor) ?? false
}
}
Expand Down Expand Up @@ -304,6 +332,12 @@ extension Config.Settings: Encodable {
try c.encode(swipeVelocity, forKey: .swipeVelocity)
try c.encode(cursorSpeed, forKey: .cursorSpeed)
try c.encode(cursorDeadzone, forKey: .cursorDeadzone)
try c.encode(touchMovesScroll, forKey: .touchMovesScroll)
try c.encode(touchScrollSpeed, forKey: .touchScrollSpeed)
try c.encode(touchScrollAcceleration, forKey: .touchScrollAcceleration)
try c.encode(touchModeSwitchEnabled, forKey: .touchModeSwitchEnabled)
try c.encodeIfPresent(touchModeSwitchButton, forKey: .touchModeSwitchButton)
try c.encode(touchModeSwitchTrigger, forKey: .touchModeSwitchTrigger)
try c.encode(circularScroll, forKey: .circularScroll)
try c.encode(holdThreshold, forKey: .holdThreshold)
try c.encode(holdThreshold2, forKey: .holdThreshold2)
Expand All @@ -319,6 +353,7 @@ extension Config.Settings: Encodable {
try c.encode(doubleTapWindow, forKey: .doubleTapWindow)
try c.encode(spacesModeWindow, forKey: .spacesModeWindow)
try c.encode(findCursorEnabled, forKey: .findCursorEnabled)
try c.encode(findCursorSensitivity, forKey: .findCursorSensitivity)
try c.encode(focusFollowsCursor, forKey: .focusFollowsCursor)
}
}
Expand Down
34 changes: 34 additions & 0 deletions SiriRemoteCore/Sources/SiriRemoteCore/Controller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ public protocol ActionExecutor: AnyObject {
public final class Controller {
private var engine: MappingEngine
private let executor: ActionExecutor
/// Ephemeral, process-local bindings owned by app features (for example the Touch Surface mode
/// switch). They sit above config resolution without mutating the user's saved binding; clearing
/// one reveals the original binding immediately.
private var runtimeOverrides: [String: RuntimeOverride] = [:]

private struct RuntimeOverride {
let presentation: Config.Presentation?
let handler: () -> Void
}

/// The active layer (from a `.layer` button — held momentary or tap-toggled sticky). While set,
/// a key `K` resolves PER-APP first — the layer-namespaced key `"<layer>.K"` looked up in the
Expand Down Expand Up @@ -35,6 +44,22 @@ public final class Controller {
/// The layer mode currently overriding resolution, or nil when resolving against the app mode.
public var currentLayer: String? { activeLayer }

/// Temporarily reserve one event without editing config. Passing nil releases the reservation.
public func setRuntimeOverride(
for eventKey: String,
presentation: Config.Presentation? = nil,
handler: (() -> Void)?
) {
if let handler {
runtimeOverrides[eventKey] = RuntimeOverride(
presentation: presentation,
handler: handler
)
} else {
runtimeOverrides.removeValue(forKey: eventKey)
}
}

/// A resolved binding together with the presentation of that SAME binding. Kept as one value so
/// the label and icon can never come from a different binding that merely shares the key.
private struct Site {
Expand Down Expand Up @@ -75,6 +100,11 @@ public final class Controller {
/// a global fallback is what lets a mode opt out: a mode with no `inherits` is standalone and
/// genuinely sees nothing else, layered or not.
private func site(_ key: String) -> Site? {
if let override = runtimeOverrides[key] {
// `.disabled` is only the introspection placeholder used by the existing press/hold
// machinery. `handle` invokes the override closure instead of executing this action.
return Site(action: .disabled, presentation: override.presentation, holdDelay: nil)
}
guard let layer = activeLayer else {
guard let action = engine.resolve(key) else { return nil }
return Site(action: action, presentation: engine.resolvePresentation(key),
Expand Down Expand Up @@ -143,6 +173,10 @@ public final class Controller {
/// fall back to native behavior. While a layer is active, resolves against the layer instead.
@discardableResult
public func handle(_ event: InputEvent) -> Bool {
if let override = runtimeOverrides[event.key] {
override.handler()
return true
}
guard let action = resolve(event.key) else { return false }
if case let .mode(to) = action {
engine.switchMode(to: to)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class ConfigWriterTests: XCTestCase {

func testActionRoundTripsForEveryCase() throws {
let cases: [Action] = [
.disabled,
.keystroke(keys: "cmd+shift+["),
.keystroke(keys: "rctrl+rcmd+ropt"), // modifier-only hyperkey chord
.pushToTalk(keys: "f17"),
Expand Down Expand Up @@ -106,6 +107,7 @@ final class ConfigWriterTests: XCTestCase {
"doubleTapWindow": 0.2,
"spacesModeWindow": 5.0,
"findCursorEnabled": true,
"findCursorSensitivity": 0.5,
"circularScroll": {
"enabled": true, "minRadius": 0.35, "startThreshold": 0.35,
"pixelsPerRadian": 160, "scrollEase": 0.3, "invert": false
Expand Down
23 changes: 23 additions & 0 deletions SiriRemoteCore/Tests/SiriRemoteCoreTests/ControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ final class ControllerTests: XCTestCase {
XCTAssertFalse(c.hasBinding(for: "button.tv.hold"))
}

func testRuntimeOverridePreservesAndRestoresSavedBinding() throws {
let spy = SpyExecutor()
let c = try makeController(cfg, spy)
var toggles = 0
c.setRuntimeOverride(
for: "button.tv",
presentation: .init(label: "Touch switch", icon: "arrow.triangle.2.circlepath")
) {
toggles += 1
}

XCTAssertEqual(c.resolvedAction(for: "button.tv"), .disabled)
XCTAssertEqual(c.resolvedPresentation(for: "button.tv")?.label, "Touch switch")
XCTAssertTrue(c.handle(InputEvent(key: "button.tv")))
XCTAssertEqual(toggles, 1)
XCTAssertTrue(spy.executed.isEmpty)

c.setRuntimeOverride(for: "button.tv", handler: nil)
XCTAssertEqual(c.resolvedAction(for: "button.tv"), .shell(command: "say hi"))
XCTAssertTrue(c.handle(InputEvent(key: "button.tv")))
XCTAssertEqual(spy.executed.map(\.0), [.shell(command: "say hi")])
}

// MARK: - Momentary layer (Feature: LAYER)

private let layerCfg = """
Expand Down
1 change: 1 addition & 0 deletions app/ActionVisual.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ enum ActionVisual {
/// A reasonable symbol per action kind, so nothing ever shows up blank.
private static func defaultSymbolName(_ action: Action) -> String {
switch action {
case .disabled: return "nosign"
case .keystroke: return "keyboard"
case .pushToTalk: return "mic.fill"
case .media: return "playpause.fill"
Expand Down
89 changes: 89 additions & 0 deletions app/KeyMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,30 @@ enum KeyMap {
return (CGKeyCode(kVK_Shift), flag(.maskShift, 0x2)) // NX_DEVICELSHIFTKEYMASK
case "rshift":
return (CGKeyCode(kVK_RightShift), flag(.maskShift, 0x4)) // NX_DEVICERSHIFTKEYMASK
case "fn", "function":
return (CGKeyCode(kVK_Function), .maskSecondaryFn)
default:
return nil
}
}

/// Physical modifier key → stable config token. Used by the shortcut recorder so left/right
/// variants survive recording and replay.
static func modifierToken(for keyCode: UInt16) -> String? {
switch Int(keyCode) {
case kVK_Command: return "lcmd"
case kVK_RightCommand: return "rcmd"
case kVK_Control: return "lctrl"
case kVK_RightControl: return "rctrl"
case kVK_Option: return "lopt"
case kVK_RightOption: return "ropt"
case kVK_Shift: return "lshift"
case kVK_RightShift: return "rshift"
case kVK_Function: return "fn"
default: return nil
}
}

private static func flag(_ generic: CGEventFlags, _ deviceBit: UInt64) -> CGEventFlags {
CGEventFlags(rawValue: generic.rawValue | deviceBit)
}
Expand All @@ -80,9 +99,27 @@ enum KeyMap {
if let c = letters[ch] { return c }
if let d = digits[ch] { return d }
}
// Lossless fallback emitted by the recorder for physical keys not yet named here (for
// example JIS/ISO-specific keys). Keeping the hardware key code makes capture/replay
// layout-independent without waiting for a new token-table release.
if token.hasPrefix("keycode"),
let raw = Int(token.dropFirst("keycode".count)),
(0...Int(UInt16.max)).contains(raw) {
return raw
}
return named[token]
}

/// Physical main key → stable config token. Key codes are layout-independent, matching
/// Karabiner-style recording: the same physical key replays even when the input source changes.
static func token(for keyCode: UInt16) -> String? {
let code = Int(keyCode)
if let pair = letters.first(where: { $0.value == code }) { return String(pair.key) }
if let pair = digits.first(where: { $0.value == code }) { return String(pair.key) }
if let named = preferredNamed.first(where: { $0.value == code })?.key { return named }
return "keycode\(code)"
}

private static let letters: [Character: Int] = [
"a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D,
"e": kVK_ANSI_E, "f": kVK_ANSI_F, "g": kVK_ANSI_G, "h": kVK_ANSI_H,
Expand All @@ -105,12 +142,64 @@ enum KeyMap {
"enter": kVK_Return, "return": kVK_Return,
"space": kVK_Space, "tab": kVK_Tab,
"delete": kVK_Delete, "backspace": kVK_Delete,
"forwarddelete": kVK_ForwardDelete,
"home": kVK_Home, "end": kVK_End,
"pageup": kVK_PageUp, "pagedown": kVK_PageDown,
"help": kVK_Help,
"capslock": kVK_CapsLock,
"f1": kVK_F1, "f2": kVK_F2, "f3": kVK_F3, "f4": kVK_F4,
"f5": kVK_F5, "f6": kVK_F6, "f7": kVK_F7, "f8": kVK_F8,
"f9": kVK_F9, "f10": kVK_F10, "f11": kVK_F11, "f12": kVK_F12,
"f13": kVK_F13, "f14": kVK_F14, "f15": kVK_F15, "f16": kVK_F16,
"f17": kVK_F17, "f18": kVK_F18, "f19": kVK_F19, "f20": kVK_F20,
"mute": kVK_Mute, "volumeup": kVK_VolumeUp, "volumedown": kVK_VolumeDown,
"keypad0": kVK_ANSI_Keypad0, "keypad1": kVK_ANSI_Keypad1,
"keypad2": kVK_ANSI_Keypad2, "keypad3": kVK_ANSI_Keypad3,
"keypad4": kVK_ANSI_Keypad4, "keypad5": kVK_ANSI_Keypad5,
"keypad6": kVK_ANSI_Keypad6, "keypad7": kVK_ANSI_Keypad7,
"keypad8": kVK_ANSI_Keypad8, "keypad9": kVK_ANSI_Keypad9,
"keypaddecimal": kVK_ANSI_KeypadDecimal,
"keypadmultiply": kVK_ANSI_KeypadMultiply,
"keypadplus": kVK_ANSI_KeypadPlus,
"keypadclear": kVK_ANSI_KeypadClear,
"keypaddivide": kVK_ANSI_KeypadDivide,
"keypadenter": kVK_ANSI_KeypadEnter,
"keypadminus": kVK_ANSI_KeypadMinus,
"keypadequals": kVK_ANSI_KeypadEquals,
"section": kVK_ISO_Section,
// Punctuation. Braces { } are Shift + [ ] — write them as e.g. "cmd+shift+[".
"[": kVK_ANSI_LeftBracket, "]": kVK_ANSI_RightBracket,
"-": kVK_ANSI_Minus, "=": kVK_ANSI_Equal, "`": kVK_ANSI_Grave,
";": kVK_ANSI_Semicolon, "'": kVK_ANSI_Quote, "\\": kVK_ANSI_Backslash,
",": kVK_ANSI_Comma, ".": kVK_ANSI_Period, "/": kVK_ANSI_Slash,
]

/// One canonical token per physical key for recording (aliases such as escape/return omitted).
private static let preferredNamed: [String: Int] = [
"up": kVK_UpArrow, "down": kVK_DownArrow, "left": kVK_LeftArrow, "right": kVK_RightArrow,
"esc": kVK_Escape, "enter": kVK_Return, "space": kVK_Space, "tab": kVK_Tab,
"delete": kVK_Delete, "forwarddelete": kVK_ForwardDelete,
"home": kVK_Home, "end": kVK_End, "pageup": kVK_PageUp, "pagedown": kVK_PageDown,
"help": kVK_Help, "capslock": kVK_CapsLock,
"f1": kVK_F1, "f2": kVK_F2, "f3": kVK_F3, "f4": kVK_F4,
"f5": kVK_F5, "f6": kVK_F6, "f7": kVK_F7, "f8": kVK_F8,
"f9": kVK_F9, "f10": kVK_F10, "f11": kVK_F11, "f12": kVK_F12,
"f13": kVK_F13, "f14": kVK_F14, "f15": kVK_F15, "f16": kVK_F16,
"f17": kVK_F17, "f18": kVK_F18, "f19": kVK_F19, "f20": kVK_F20,
"mute": kVK_Mute, "volumeup": kVK_VolumeUp, "volumedown": kVK_VolumeDown,
"keypad0": kVK_ANSI_Keypad0, "keypad1": kVK_ANSI_Keypad1,
"keypad2": kVK_ANSI_Keypad2, "keypad3": kVK_ANSI_Keypad3,
"keypad4": kVK_ANSI_Keypad4, "keypad5": kVK_ANSI_Keypad5,
"keypad6": kVK_ANSI_Keypad6, "keypad7": kVK_ANSI_Keypad7,
"keypad8": kVK_ANSI_Keypad8, "keypad9": kVK_ANSI_Keypad9,
"keypaddecimal": kVK_ANSI_KeypadDecimal, "keypadmultiply": kVK_ANSI_KeypadMultiply,
"keypadplus": kVK_ANSI_KeypadPlus, "keypadclear": kVK_ANSI_KeypadClear,
"keypaddivide": kVK_ANSI_KeypadDivide, "keypadenter": kVK_ANSI_KeypadEnter,
"keypadminus": kVK_ANSI_KeypadMinus, "keypadequals": kVK_ANSI_KeypadEquals,
"section": kVK_ISO_Section,
"[": kVK_ANSI_LeftBracket, "]": kVK_ANSI_RightBracket,
"-": kVK_ANSI_Minus, "=": kVK_ANSI_Equal, "`": kVK_ANSI_Grave,
";": kVK_ANSI_Semicolon, "'": kVK_ANSI_Quote, "\\": kVK_ANSI_Backslash,
",": kVK_ANSI_Comma, ".": kVK_ANSI_Period, "/": kVK_ANSI_Slash,
]
}
Loading